merge: sync markdown viewer with main

This commit is contained in:
Lawrence Chen
2026-05-12 21:46:08 -07:00
301 changed files with 41255 additions and 7397 deletions
+12 -6
View File
@@ -146,19 +146,20 @@ jobs:
name: Download pre-built GhosttyKit.xcframework
no_output_timeout: 35m
command: ./scripts/download-prebuilt-ghosttykit.sh
- install-zig
- resolve-swift-packages:
scheme: cmux-unit
- run:
name: Run unit tests
no_output_timeout: 35m
command: |
export CMUX_SKIP_ZIG_BUILD=1
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
run_unit_tests() {
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-skip-testing:cmuxTests/AppDelegateShortcutRoutingTests/testCmdWClosesWindowWhenClosingLastSurfaceInLastWorkspace \
test 2>&1
}
@@ -178,7 +179,8 @@ jobs:
mkdir -p "$SOURCE_PACKAGES_DIR"
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-resolvePackageDependencies
-resolvePackageDependencies \
CMUX_SKIP_ZIG_BUILD=1
set +e
run_unit_tests | tee /tmp/test-output.txt
EXIT_CODE=${PIPESTATUS[0]}
@@ -199,7 +201,8 @@ jobs:
name: Run bundled Ghostty theme picker helper regression
no_output_timeout: 35m
command: |
CMUX_SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" \
CMUX_SKIP_ZIG_BUILD=1 \
CMUX_SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" \
./tests/test_bundled_ghostty_theme_picker_helper.sh
- run:
name: Run CLI no-socket regressions
@@ -237,18 +240,20 @@ jobs:
name: Download pre-built GhosttyKit.xcframework
no_output_timeout: 35m
command: ./scripts/download-prebuilt-ghosttykit.sh
- install-zig
- resolve-swift-packages:
scheme: cmux
- run:
name: Build app and capture warnings
no_output_timeout: 35m
command: |
export CMUX_SKIP_ZIG_BUILD=1
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" build 2>&1 | tee /tmp/cmux-build-output.txt
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
build 2>&1 | tee /tmp/cmux-build-output.txt
- run:
name: Validate Swift warning budget
command: python3 scripts/swift_warning_budget.py --log /tmp/cmux-build-output.txt
@@ -269,7 +274,6 @@ jobs:
name: Download pre-built GhosttyKit.xcframework
no_output_timeout: 35m
command: ./scripts/download-prebuilt-ghosttykit.sh
- install-zig
- resolve-swift-packages:
scheme: cmux
source-packages-dir: .spm-cache
@@ -277,12 +281,14 @@ jobs:
name: Build universal app (Release)
no_output_timeout: 35m
command: |
export CMUX_SKIP_ZIG_BUILD=1
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Release -derivedDataPath build-universal \
-destination "generic/platform=macOS" \
-clonedSourcePackagesDirPath .spm-cache \
-disableAutomaticPackageResolution \
ARCHS="arm64 x86_64" \
ONLY_ACTIVE_ARCH=NO \
CMUX_SKIP_ZIG_BUILD=1 \
CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build
- save-swift-package-cache:
prefix: release
+11
View File
@@ -22,6 +22,9 @@ reviews:
- path: "GhosttyTabs.xcodeproj/**"
instructions: |
Review project wiring against the cmux Swift lint rules. Flag project changes that enable app/runtime code paths which bypass Swift concurrency, logging, localization, or shared action-path expectations.
- path: "**/*.{ts,tsx,js,jsx,mjs,cjs,sh,zsh}"
instructions: |
Apply `.github/review-bot-rules/runtime-no-hacky-sleeps.md` during review. For production runtime, script, and build changes, flag fixed sleeps, timers, delayed dispatch, polling, or wall-clock waits used as synchronization. Pass for tests, pure presentation timing, dedicated cancellation-aware retry/timeout abstractions with tests, and existing delay code not worsened.
pre_merge_checks:
custom_checks:
@@ -33,6 +36,10 @@ reviews:
mode: error
instructions: |
For production Swift changes, fail when the diff introduces or materially expands blocking or timing-based synchronization from `.github/review-bot-rules/swift-blocking-runtime.md`: semaphores, blocking waits, sleeps, delayed dispatch, polling, main-queue sync, or manual locks where an actor or explicit signal should own synchronization. Pass for deterministic test-only scaffolding and short user-visible UI animation delays that do not use `Task.sleep`.
- name: "cmux no hacky sleeps"
mode: error
instructions: |
For production non-Swift app/runtime changes in TypeScript, JavaScript, shell, or build/runtime scripts, fail when the diff violates `.github/review-bot-rules/runtime-no-hacky-sleeps.md`: fixed sleeps, delayed dispatch, timers, polling, or wall-clock waits used to paper over lifecycle, focus, rendering, socket, process, filesystem, network, teardown, startup, retry, or shared-state races. Swift sleeps are covered by `cmux Swift blocking runtime`. Pass for deterministic test-only scaffolding, purely presentation animation or progress timing, dedicated cancellation-aware retry/timeout abstractions with tests, and existing delay code not worsened.
- name: "cmux Swift concurrency"
mode: error
instructions: |
@@ -57,3 +64,7 @@ reviews:
mode: error
instructions: |
For Swift architecture changes, fail when the diff violates `.github/review-bot-rules/swift-architectural-rethink.md`: symptom patches using sleeps, delayed dispatch, polling, locks, observers, side channels, duplicate entrypoint wiring, or split UI lifecycle ownership that leaves bad state representable. Pass for small correctness fixes with clear owners and invariants, required platform bridges, and test-only synchronization.
- name: "cmux Swift auxiliary window close shortcuts"
mode: error
instructions: |
For Swift changes that add or materially change standalone cmux-owned windows, fail when the diff violates `.github/review-bot-rules/swift-auxiliary-window-close-shortcuts.md`: user-visible NSWindow, NSPanel, NSWindowController, SwiftUI Window, or WindowGroup code without a stable cmux.* identifier and shared close-shortcut ownership through cmuxAuxiliaryWindowIdentifiers. Pass for main workspace windows, terminal panes, tabs, sheets, popovers, menus, test-only fixtures, and existing unregistered windows not worsened by the PR. If the deterministic CI script already catches the literal assignment, mention scripts/lint_auxiliary_window_close_shortcuts.py; otherwise explain the broader review-only pattern.
+3 -1
View File
@@ -1,6 +1,6 @@
# cmux Review Bot Rules
These rules are the shared source of truth for Greptile and CodeRabbit custom Swift review behavior.
These rules are the shared source of truth for Greptile and CodeRabbit custom review behavior.
The rule files are intentionally short and focused. Each one defines one class of issue, concrete failure cases, allowed cases, and the expected reporting shape. Keep new rules narrow enough that a reviewer can apply the rule to a full PR diff without turning it into a broad style guide.
@@ -8,8 +8,10 @@ Greptile is configured to publish a GitHub status check and inline findings. Cod
Current rules:
- `runtime-no-hacky-sleeps.md`
- `swift-actor-isolation.md`
- `swift-architectural-rethink.md`
- `swift-auxiliary-window-close-shortcuts.md`
- `swift-blocking-runtime.md`
- `swift-concurrency-modernization.md`
- `swift-concurrent-annotation.md`
@@ -0,0 +1,22 @@
# Runtime No Hacky Sleeps
Scope: TypeScript, JavaScript, shell, and non-Swift runtime scripts. Swift timing and blocking primitives are covered by `swift-blocking-runtime.md`.
Flag fixed delays used as synchronization in production application or runtime code.
Report a failure when the diff introduces or materially expands any of these in non-test code:
- `sleep`, `usleep`, shell `sleep`, `setTimeout`, `setInterval`, timers, polling loops, or fixed backoff used to make lifecycle, focus, rendering, socket, process, filesystem, network, or shared-state readiness appear reliable.
- Delay comments or names such as "give it time", "settle", "wait a bit", "wait for readiness", or "avoid race" without a real event from the owner that knows readiness.
- Retrying, teardown, startup, keepalive, debounce, or handoff logic that depends on elapsed wall-clock time instead of a cancellation-aware scheduler, callback, notification, file descriptor or process event, async sequence, state transition, or explicit completion point.
Allowed cases:
- Deterministic sleeps in tests or explicit test-only scaffolding.
- User-visible animation or progress timing where the timer is purely presentation and not coordination.
- Production retry or timeout logic implemented through a dedicated cancellation-aware abstraction with bounded deadlines and tests, where the delay is part of the product behavior rather than a race repair.
- Existing delay code that the PR does not introduce or worsen.
Do not accept a sleep or fixed delay because it is short, only runs once, or seems to fix a flaky repro. A correct fix names the owner, invariant, and real signal that makes the next state valid.
When reporting, identify the changed delay, the race or lifecycle gap it hides, and the event or owner that should replace it.
@@ -0,0 +1,19 @@
# Swift Auxiliary Window Close Shortcuts
Standalone cmux-owned windows must have one close-shortcut owner so Cmd+W closes or hides the active window instead of falling through to workspace panel closing.
Report a failure when the diff introduces or materially changes:
- A user-visible `NSWindow`, `NSPanel`, `NSWindowController`, SwiftUI `Window`, or SwiftUI `WindowGroup` without a stable `cmux.*` window identifier.
- A `cmux.*` window identifier assignment that is missing from `cmuxAuxiliaryWindowIdentifiers` in `Sources/cmuxApp.swift`.
- A new standalone debug, settings, preview, task, editor, browser, file, import, config, or inspector window that can become key but is not covered by `cmuxWindowShouldOwnCloseShortcut`.
- A custom Cmd+W, `performKeyEquivalent`, or close-menu workaround that bypasses the shared `cmuxWindowShouldOwnCloseShortcut` routing instead of registering the window identifier.
Allowed cases:
- Main workspace windows, terminal panes, tabs, sheets, popovers, menus, and views that are not standalone key windows.
- Hidden bootstrap or internal windows explicitly documented in the script ignore list.
- Test-only fixture windows.
- Existing unregistered windows that the PR does not introduce or worsen, though mention them if they are adjacent to the changed window code.
When reporting, include the window/controller/file, the missing identifier or owner registration, and the expected shared path: assign a stable `cmux.*` identifier and register user-closable windows in `cmuxAuxiliaryWindowIdentifiers`. If the hard CI lint already catches the exact literal assignment, point to `scripts/lint_auxiliary_window_close_shortcuts.py`; otherwise explain why the bot rule caught a more flexible pattern.
+15 -2
View File
@@ -51,11 +51,20 @@ jobs:
- name: Validate Swift file length budget guard
run: ./tests/test_ci_swift_file_length_budget.sh
- name: Validate auxiliary window close shortcut lint
run: ./tests/test_ci_auxiliary_window_close_shortcuts.sh
- name: Validate CircleCI auto approval
run: python3 tests/test_circleci_auto_approve.py
- name: Validate Swift file length budget
run: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv
# Paused: stale-base merge races (two PRs each fitting the budget can
# overshoot when merged back-to-back without rebasing). CodeRabbit and
# Greptile already flag large-file growth on PRs. Re-enable by uncommenting
# this step (and refresh the budget tsv first):
# python3 scripts/swift_file_length_budget.py \
# --budget .github/swift-file-length-budget.tsv --write-budget
# - name: Validate Swift file length budget
# run: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv
remote-daemon-tests:
runs-on: ubuntu-latest
@@ -287,6 +296,9 @@ jobs:
CMUX_SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" \
./tests/test_bundled_ghostty_theme_picker_helper.sh
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Run CLI no-socket regressions
run: |
set -euo pipefail
@@ -308,6 +320,7 @@ jobs:
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_operation_deadline.py
python3 tests/test_claude_wrapper_hooks.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_stop_last_assistant.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_pi_extension_install.py
tests-build-and-lag:
# Build the full cmux scheme and run the lag regression on WarpBuild.
+7
View File
@@ -286,6 +286,13 @@ jobs:
/usr/libexec/PlistBuddy -c "Set :CFBundleName cmux NIGHTLY" "$app_plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName cmux NIGHTLY" "$app_plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${bundle_id}" "$app_plist"
local url_type_name
url_type_name="$(/usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes:1:CFBundleURLName" "$app_plist")"
if [[ "$url_type_name" != *.auth ]]; then
echo "Expected CFBundleURLTypes[1] to be the auth URL type, found: $url_type_name" >&2
exit 1
fi
/usr/libexec/PlistBuddy -c "Set :CFBundleURLTypes:1:CFBundleURLSchemes:0 cmux-nightly" "$app_plist"
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$app_plist" >/dev/null 2>&1 || true
/usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$app_plist" >/dev/null 2>&1 || true
/usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$app_plist"
+1 -1
View File
@@ -36,7 +36,7 @@ concurrency:
jobs:
activation-session:
runs-on: ${{ inputs.runner || 'warp-macos-15-arm64-6x' }}
timeout-minutes: 30
timeout-minutes: 45
env:
PERF_TAG: perfci
steps:
+7 -1
View File
@@ -4,7 +4,7 @@
"triggerOnUpdates": true,
"statusCheck": true,
"updateExistingSummaryComment": true,
"instructions": "Apply cmux's custom Swift lint rules from .github/review-bot-rules/ during review. Treat those files as the source of truth. Focus on production Swift changes and ignore test-only scaffolding unless it makes production behavior worse. Do not treat PR-head edits to these rule files as weakening the rules until those edits are merged.",
"instructions": "Apply cmux's custom review rules from .github/review-bot-rules/ during review. Treat those files as the source of truth. Focus on production Swift and runtime changes, and ignore test-only scaffolding unless it makes production behavior worse. Do not treat PR-head edits to these rule files as weakening the rules until those edits are merged.",
"rules": [
{
"id": "cmux-swift-actor-isolation",
@@ -18,6 +18,12 @@
"scope": ["**/*.swift", "**/Package.swift"],
"severity": "high"
},
{
"id": "cmux-runtime-no-hacky-sleeps",
"rule": "Flag fixed sleeps, delayed dispatch, timers, polling, or wall-clock waits used as synchronization in production non-Swift app/runtime code across TypeScript, JavaScript, shell, or build/runtime scripts. Fail race repairs for lifecycle, focus, rendering, socket, process, filesystem, network, teardown, startup, retry, or shared-state readiness unless they use a real signal or a dedicated cancellation-aware timeout/retry abstraction with tests. Swift files are covered by cmux-swift-blocking-runtime.",
"scope": ["web/app/**/*.ts", "web/app/**/*.tsx", "web/services/**/*.ts", "web/services/**/*.tsx", "web/db/**/*.ts", "web/data/**/*.ts", "web/i18n/**/*.ts", "web/scripts/**/*.ts", "web/scripts/**/*.tsx", "web/scripts/**/*.js", "web/scripts/**/*.jsx", "web/scripts/**/*.mjs", "web/scripts/**/*.cjs", "web/scripts/**/*.sh", "web/scripts/**/*.zsh", "web/*.ts", "web/*.mjs", "scripts/**/*.ts", "scripts/**/*.tsx", "scripts/**/*.js", "scripts/**/*.jsx", "scripts/**/*.mjs", "scripts/**/*.cjs", "scripts/**/*.sh", "scripts/**/*.zsh"],
"severity": "high"
},
{
"id": "cmux-swift-concurrency-modernization",
"rule": "Flag new legacy async patterns in cmux-owned Swift where Swift concurrency is the correct shape: DispatchQueue.global for ordinary async work, new Combine app state, completion-handler APIs fully under cmux control, or fire-and-forget Tasks with meaningful lifecycle.",
+5
View File
@@ -15,6 +15,11 @@
"description": "Source-of-truth cmux lint rule for blocking and timing primitive review.",
"scope": ["**/*.swift", "**/Package.swift"]
},
{
"path": ".github/review-bot-rules/runtime-no-hacky-sleeps.md",
"description": "Source-of-truth cmux review rule for fixed sleeps, delays, and polling used as runtime synchronization.",
"scope": ["web/app/**/*.ts", "web/app/**/*.tsx", "web/services/**/*.ts", "web/services/**/*.tsx", "web/db/**/*.ts", "web/data/**/*.ts", "web/i18n/**/*.ts", "web/scripts/**/*.ts", "web/scripts/**/*.tsx", "web/scripts/**/*.js", "web/scripts/**/*.jsx", "web/scripts/**/*.mjs", "web/scripts/**/*.cjs", "web/scripts/**/*.sh", "web/scripts/**/*.zsh", "web/*.ts", "web/*.mjs", "scripts/**/*.ts", "scripts/**/*.tsx", "scripts/**/*.js", "scripts/**/*.jsx", "scripts/**/*.mjs", "scripts/**/*.cjs", "scripts/**/*.sh", "scripts/**/*.zsh"]
},
{
"path": ".github/review-bot-rules/swift-concurrency-modernization.md",
"description": "Source-of-truth cmux lint rule for Swift concurrency modernization review.",
+4 -3
View File
@@ -1,13 +1,14 @@
# cmux Custom Review Rules
Apply the custom lint rules in `.github/review-bot-rules/` to Swift and Swift project changes.
Apply the custom lint rules in `.github/review-bot-rules/` to Swift, runtime, and project changes.
Greptile should treat the rules in that directory as the source of truth for cmux Swift reviews. PR-head edits to the rule files should not weaken review behavior until the edits are merged into the base branch.
Greptile should treat the rules in that directory as the source of truth for cmux reviews. PR-head edits to the rule files should not weaken review behavior until the edits are merged into the base branch.
Review production Swift changes for:
Review production Swift and runtime changes for:
- Swift actor isolation mistakes.
- Blocking runtime primitives and timing-based synchronization.
- Fixed sleeps, delays, and polling used as hacky synchronization.
- Legacy concurrency patterns where Swift concurrency is available.
- Incorrect `@concurrent` or `nonisolated async` behavior.
- Swift file sprawl and missing SwiftPM package boundaries for independently testable feature logic.
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "HermesAgent.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#101820"/>
<path d="M20 47V17h7v12h10V17h7v30h-7V35H27v12h-7Z" fill="#F4F7FA"/>
<path d="M15 13h34v6H15z" fill="#4AD7D1"/>
<path d="M15 45h34v6H15z" fill="#CFA9FF"/>
<path d="M18 13l5-6 5 6M36 13l5-6 5 6M18 51l5 6 5-6M36 51l5 6 5-6" fill="none" stroke="#F4F7FA" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 472 B

+69 -9
View File
@@ -2,9 +2,76 @@
All notable changes to cmux are documented here.
## [0.64.4] - 2026-05-11
### Added
- Add `warnBeforeClosingTab` close-warning toggle to opt back into the close confirmation prompt ([#2808](https://github.com/manaflow-ai/cmux/pull/2808)) -- thanks @dandaka for the report!
- Add `cmux browser cookies import` CLI for bringing cookies into cmux browser panes ([#3770](https://github.com/manaflow-ai/cmux/pull/3770))
- Add guarded `cmux://ssh` deep links that prompt before launching SSH ([#3677](https://github.com/manaflow-ai/cmux/pull/3677))
- Restore Vault Pi agent sessions across relaunch ([#3582](https://github.com/manaflow-ai/cmux/pull/3582), [#3636](https://github.com/manaflow-ai/cmux/pull/3636)) -- thanks @garizs for the report!
- Add Hermes Agent hook support ([#3585](https://github.com/manaflow-ai/cmux/pull/3585))
- Per-agent toggles for hiding Claude, Codex, OpenCode, Gemini, and Rovo Dev session restore ([#3616](https://github.com/manaflow-ai/cmux/pull/3616))
- Add Insert Path and Insert Relative Path context menu items in the file explorer ([#3620](https://github.com/manaflow-ai/cmux/pull/3620))
- Restore SSH workspace descriptors on relaunch ([#3576](https://github.com/manaflow-ai/cmux/pull/3576))
- Follow SSH workspaces in the Files sidebar so the remote root replaces the local macOS path ([#3721](https://github.com/manaflow-ai/cmux/pull/3721)) -- thanks @Lots-ninety-nine for the report!
- Add Welcome sidebar toggle shortcuts ([#3748](https://github.com/manaflow-ai/cmux/pull/3748))
### Changed
- File drop routing now defaults to text with Shift used as the split override.
- Allow HTTP localhost subdomains in browser panes ([#3764](https://github.com/manaflow-ai/cmux/pull/3764))
- Make browser find shortcuts respect remaps ([#3728](https://github.com/manaflow-ai/cmux/pull/3728))
- Make Close Tab remaps own browser popup close ([#3830](https://github.com/manaflow-ai/cmux/pull/3830))
- Alias top-level auth commands so `cmux signin` and `cmux signout` work without the `auth` prefix.
### Fixed
- Fix stale terminal foreground after theme switch leaving white-on-white text in running sessions ([#3852](https://github.com/manaflow-ai/cmux/pull/3852))
- Fix managed defaults replay overriding user changes after every `cmux.json` reload ([#3847](https://github.com/manaflow-ai/cmux/pull/3847))
- Preserve the Claude wrapper dev channel resume flag ([#3752](https://github.com/manaflow-ai/cmux/pull/3752)) -- thanks @Clean-Cole!
- Fix SSH browser loopback fetches reaching backends on second forwarded ports ([#3820](https://github.com/manaflow-ai/cmux/pull/3820))
- Fix modified Backspace deleting more than one character when an omnibar inline completion is showing ([#3842](https://github.com/manaflow-ai/cmux/pull/3842))
- Close Web Inspector before browser host teardown to prevent a UAF crash on pane close ([#3835](https://github.com/manaflow-ai/cmux/pull/3835))
- Fix Files sidebar find result aggregation ([#3818](https://github.com/manaflow-ai/cmux/pull/3818))
- Fix Escape dismissing the command palette ([#3823](https://github.com/manaflow-ai/cmux/pull/3823))
- Resume Claude, Codex, and OpenCode sessions from the session's original cwd.
- Fix Close Other Tabs targeting all tabs in the pane right-click menu ([#3628](https://github.com/manaflow-ai/cmux/pull/3628)) -- thanks @flatsponge for the report!
- Clear surface notifications during pane teardown so workspace badges don't stay stuck ([#3744](https://github.com/manaflow-ai/cmux/pull/3744))
- Fix folder proxy icon drag ([#3804](https://github.com/manaflow-ai/cmux/pull/3804)) -- thanks @lederniermagicien!
- Fix right sidebar shortcut defaults ([#3784](https://github.com/manaflow-ai/cmux/pull/3784))
- Fix right sidebar titlebar double-click ([#3750](https://github.com/manaflow-ai/cmux/pull/3750))
- Fix right sidebar Find typing lag ([#3739](https://github.com/manaflow-ai/cmux/pull/3739))
- Route SSH image drops through the terminal text path.
- Fix terminal top-row click routing ([#3720](https://github.com/manaflow-ai/cmux/pull/3720))
- Fix Mark Workspace as Unread enablement ([#3727](https://github.com/manaflow-ai/cmux/pull/3727)) -- thanks @mfn for the report!
- Fix Cmd-W to close Task Manager and auxiliary windows.
- Fix command palette arrow keys and no-match flash.
- Restore Zhuyin IME candidate marked-text handling ([#3574](https://github.com/manaflow-ai/cmux/pull/3574)) -- thanks @yuanganai for the report!
- Fix Task Manager CPU sampling ([#3588](https://github.com/manaflow-ai/cmux/pull/3588))
- Fix Cmd+N window size after the last window closes ([#3611](https://github.com/manaflow-ai/cmux/pull/3611)) -- thanks @bigtruth for the report!
- Fix Match Terminal Background sidebar toggle snapping back on ([#3635](https://github.com/manaflow-ai/cmux/pull/3635))
- Count cmux app RSS in Task Manager totals ([#3587](https://github.com/manaflow-ai/cmux/pull/3587))
- Keep Settings layered above the main window ([#3612](https://github.com/manaflow-ai/cmux/pull/3612))
- Forward Left/Right arrow keys to the browser surface ([#3663](https://github.com/manaflow-ai/cmux/pull/3663)) -- thanks @kimdane0115 for the report!
- Fix Rovo Dev transcript previews ([#3666](https://github.com/manaflow-ai/cmux/pull/3666))
### Thanks to 12 contributors!
- [@austinywang](https://github.com/austinywang)
- [@bigtruth](https://github.com/bigtruth)
- [@Clean-Cole](https://github.com/Clean-Cole)
- [@dandaka](https://github.com/dandaka)
- [@flatsponge](https://github.com/flatsponge)
- [@garizs](https://github.com/garizs)
- [@kimdane0115](https://github.com/kimdane0115)
- [@lawrencecchen](https://github.com/lawrencecchen)
- [@lederniermagicien](https://github.com/lederniermagicien)
- [@Lots-ninety-nine](https://github.com/Lots-ninety-nine)
- [@mfn](https://github.com/mfn)
- [@yuanganai](https://github.com/yuanganai)
## [0.64.3] - 2026-05-05
### Added
- Added Show in Finder to the workspace sidebar right-click menu.
- `cmux config` CLI with `cmux config doctor` for validating `cmux.json` without a socket, plus `cmux config path`, `cmux config docs`, and `cmux config reload` aliases ([#3454](https://github.com/manaflow-ai/cmux/pull/3454))
### Fixed
@@ -49,18 +116,14 @@ All notable changes to cmux are documented here.
## [0.64.0] - 2026-05-05
### Added
- Feed sidebar with `cmux feed-hook` and OpenCode plugin to surface permission requests, plan approvals, and agent questions inline ([#3057](https://github.com/manaflow-ai/cmux/pull/3057), [#3405](https://github.com/manaflow-ai/cmux/pull/3405), [#3457](https://github.com/manaflow-ai/cmux/pull/3457))
- Sessions panel (renamed Vault) in the right sidebar with persistent session restore and agent resume across relaunch ([#2936](https://github.com/manaflow-ai/cmux/pull/2936), [#2978](https://github.com/manaflow-ai/cmux/pull/2978), [#3259](https://github.com/manaflow-ai/cmux/pull/3259), [#3419](https://github.com/manaflow-ai/cmux/pull/3419), [#3429](https://github.com/manaflow-ai/cmux/pull/3429), [#3487](https://github.com/manaflow-ai/cmux/pull/3487), [#3528](https://github.com/manaflow-ai/cmux/pull/3528))
- Restore prior panes and resume Claude Code, Codex, OpenCode, Gemini, and Rovo Dev sessions across relaunch, including when you close the last window with the red X ([#2936](https://github.com/manaflow-ai/cmux/pull/2936), [#2978](https://github.com/manaflow-ai/cmux/pull/2978), [#3259](https://github.com/manaflow-ai/cmux/pull/3259), [#3419](https://github.com/manaflow-ai/cmux/pull/3419), [#3429](https://github.com/manaflow-ai/cmux/pull/3429), [#3487](https://github.com/manaflow-ai/cmux/pull/3487), [#3528](https://github.com/manaflow-ai/cmux/pull/3528), [#3530](https://github.com/manaflow-ai/cmux/pull/3530), [#3535](https://github.com/manaflow-ai/cmux/pull/3535))
- Passkey, WebAuthn, and FIDO2 support in browser panes ([#2660](https://github.com/manaflow-ai/cmux/pull/2660), [#2727](https://github.com/manaflow-ai/cmux/pull/2727), [#2905](https://github.com/manaflow-ai/cmux/pull/2905), [#2908](https://github.com/manaflow-ai/cmux/pull/2908))
- `cmux vm` CLI and Cloud VM backend for spawning Freestyle-backed cloud workspaces with `cmux vm new`, `cmux vm shell`, and `cmux vm attach` ([#3046](https://github.com/manaflow-ai/cmux/pull/3046), [#3185](https://github.com/manaflow-ai/cmux/pull/3185), [#3196](https://github.com/manaflow-ai/cmux/pull/3196), [#3219](https://github.com/manaflow-ai/cmux/pull/3219), [#3432](https://github.com/manaflow-ai/cmux/pull/3432), [#3437](https://github.com/manaflow-ai/cmux/pull/3437))
- Dock right-sidebar TUI control surface with project and global config via `.cmux/dock.json` and `~/.config/cmux/dock.json` ([#3217](https://github.com/manaflow-ai/cmux/pull/3217), [#3366](https://github.com/manaflow-ai/cmux/pull/3366), [#3376](https://github.com/manaflow-ai/cmux/pull/3376), [#3393](https://github.com/manaflow-ai/cmux/pull/3393))
- Task Manager window and `cmux top` CLI for window, workspace, pane, surface, and browser webview snapshots ([#3290](https://github.com/manaflow-ai/cmux/pull/3290), [#3471](https://github.com/manaflow-ai/cmux/pull/3471))
- Finder-like file explorer sidebar with SSH support ([#1963](https://github.com/manaflow-ai/cmux/pull/1963))
- File preview panels in the sidebar ([#3139](https://github.com/manaflow-ai/cmux/pull/3139))
- Menu bar only mode ([#3181](https://github.com/manaflow-ai/cmux/pull/3181))
- System-wide hotkey to show and hide cmux windows ([#2389](https://github.com/manaflow-ai/cmux/pull/2389))
- Cursor and Gemini CLI agent integrations with `setup-hooks` ([#2717](https://github.com/manaflow-ai/cmux/pull/2717))
- Gemini and Rovo Dev session hooks with sessions piped into Vault ([#3530](https://github.com/manaflow-ai/cmux/pull/3530), [#3535](https://github.com/manaflow-ai/cmux/pull/3535))
- iMessage mode for agent prompts ([#3252](https://github.com/manaflow-ai/cmux/pull/3252))
- Settings sidebar shell and unified config utility window with cmux, Ghostty, and synced tabs ([#3024](https://github.com/manaflow-ai/cmux/pull/3024), [#3244](https://github.com/manaflow-ai/cmux/pull/3244), [#3400](https://github.com/manaflow-ai/cmux/pull/3400))
- Make `cmux.json` the canonical settings file with JSONC parsing and legacy `settings.json` fallback ([#3409](https://github.com/manaflow-ai/cmux/pull/3409), [#3424](https://github.com/manaflow-ai/cmux/pull/3424))
@@ -81,7 +144,6 @@ All notable changes to cmux are documented here.
- Korean (ko) localization ([#2885](https://github.com/manaflow-ai/cmux/pull/2885)) -- thanks @say8425!
- Opt-in setting to open Cmd-clicked Markdown files in the cmux Markdown viewer ([#2904](https://github.com/manaflow-ai/cmux/pull/2904)) -- thanks @SeongJaeSong!
- cmux browser disable switch ([#3256](https://github.com/manaflow-ai/cmux/pull/3256))
- Beta feature toggles for Feed and Dock ([#3537](https://github.com/manaflow-ai/cmux/pull/3537))
- Markdown and plain-text variants for docs pages plus `/llms.txt` index for agent consumption ([#3410](https://github.com/manaflow-ai/cmux/pull/3410))
### Changed
@@ -100,9 +162,7 @@ All notable changes to cmux are documented here.
- Show Codex TUI errors in the sidebar ([#3212](https://github.com/manaflow-ai/cmux/pull/3212))
- Keep Cmd-Shift-N windows on the source display ([#3214](https://github.com/manaflow-ai/cmux/pull/3214))
- Select find text on repeated Cmd+F ([#3314](https://github.com/manaflow-ai/cmux/pull/3314))
- Search Codex rollout content from the sessions sidebar ([#3396](https://github.com/manaflow-ai/cmux/pull/3396))
- Disable Claude OSC notifications in the cmux wrapper and gate Claude OSC suppression on integration setting ([#3418](https://github.com/manaflow-ai/cmux/pull/3418), [#3474](https://github.com/manaflow-ai/cmux/pull/3474))
- Route Codex permission approvals through Feed ([#3420](https://github.com/manaflow-ai/cmux/pull/3420))
- Namespace agent hook CLI commands ([#3298](https://github.com/manaflow-ai/cmux/pull/3298))
### Fixed
@@ -117,7 +177,7 @@ All notable changes to cmux are documented here.
- Fix close confirmation bypass when spamming close ([#2989](https://github.com/manaflow-ai/cmux/pull/2989))
- Fix multi-workspace close confirmation modality ([#3153](https://github.com/manaflow-ai/cmux/pull/3153))
- Fix Cmd/Ctrl shortcut hint parity ([#2994](https://github.com/manaflow-ai/cmux/pull/2994))
- Fix Sessions panel CPU loop on nightly and cancel drag on Escape ([#2995](https://github.com/manaflow-ai/cmux/pull/2995), [#3013](https://github.com/manaflow-ai/cmux/pull/3013))
- Cancel drag on Escape ([#3013](https://github.com/manaflow-ai/cmux/pull/3013))
- Pin regular-weight Japanese auto-fallback face ([#3015](https://github.com/manaflow-ai/cmux/pull/3015))
- Fix 100% CPU from ContentView publisher feedback loop ([#3028](https://github.com/manaflow-ai/cmux/pull/3028))
- Fix `DebugEventLog` `NSFileHandle` ObjC exception crash ([#3034](https://github.com/manaflow-ai/cmux/pull/3034))
+265
View File
@@ -0,0 +1,265 @@
import Foundation
extension CMUXCLI {
// MARK: - Generic agent hook system
/// Configuration for a hook-based agent integration.
struct AgentHookDef {
let name: String // CLI name: "cursor", "gemini", etc.
let displayName: String // Human-readable: "Cursor", "Gemini"
let statusKey: String // Key for set_status: "cursor", "gemini"
let configDir: String // Relative to ~: ".cursor", ".gemini"
let configFile: String // File name: "hooks.json", "settings.json"
let configDirEnvOverride: String? // e.g. "CODEX_HOME" overrides configDir
let sessionStoreSuffix: String // e.g. "cursor" -> ~/.cmuxterm/cursor-hook-sessions.json
let disableEnvVar: String // e.g. "CMUX_CURSOR_HOOKS_DISABLED"
let hookMarker: String // Marker in commands: "cmux hooks cursor"
let binaryName: String
let format: HookFormat
let events: [HookEvent]
let aliases: Set<String>
/// Feed-hook events. Each entry installs a second hook for
/// `agentEvent` that invokes `cmux hooks feed --source <name>`
/// with a 120s timeout so the socket reply wait doesn't trip the
/// agent's default hook timeout when the user takes time to
/// approve/deny a permission / plan / question.
let feedHookEvents: [String]
let postInstallAction: PostInstallAction?
enum HookFormat {
case flat // Cursor: {"hooks": {"event": [{"command": "..."}]}, "version": 1}
case nested(timeoutMs: Int) // Codex/Gemini: nested with type/command/timeout
case rovoDevYAML
case hermesAgentYAML
}
struct HookEvent {
let agentEvent: String
let cmuxSubcommand: String
}
enum PostInstallAction {
case codexConfigToml // write hooks = true to config.toml on install, remove on uninstall
}
/// Resolves the config directory, respecting env override if set.
func resolvedConfigDir() -> String {
if let envKey = configDirEnvOverride,
let envValue = ProcessInfo.processInfo.environment[envKey],
!envValue.isEmpty {
return NSString(string: envValue).expandingTildeInPath
}
let home = ProcessInfo.processInfo.environment["HOME"].flatMap { value -> String? in
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
} ?? NSHomeDirectory()
return URL(fileURLWithPath: home, isDirectory: true)
.appendingPathComponent(configDir, isDirectory: true)
.path
}
init(name: String, displayName: String, statusKey: String,
configDir: String, configFile: String, configDirEnvOverride: String? = nil,
binaryName: String? = nil,
sessionStoreSuffix: String, disableEnvVar: String, hookMarker: String,
format: HookFormat, events: [HookEvent],
aliases: Set<String> = [],
feedHookEvents: [String] = [],
postInstallAction: PostInstallAction? = nil) {
self.name = name; self.displayName = displayName; self.statusKey = statusKey
self.configDir = configDir; self.configFile = configFile
self.configDirEnvOverride = configDirEnvOverride
self.binaryName = binaryName ?? name
self.sessionStoreSuffix = sessionStoreSuffix; self.disableEnvVar = disableEnvVar
self.hookMarker = hookMarker; self.format = format; self.events = events
self.aliases = Set(aliases.compactMap { alias in
let normalized = alias.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized.isEmpty ? nil : normalized
})
self.feedHookEvents = feedHookEvents
self.postInstallAction = postInstallAction
}
}
enum AgentHookAction {
case sessionStart, promptSubmit, stop, sessionEnd, noop
}
static let subcommandActions: [String: AgentHookAction] = [
"session-start": .sessionStart,
"prompt-submit": .promptSubmit,
"stop": .stop,
"agent-response": .stop,
"shell-exec": .promptSubmit,
"shell-done": .noop,
"session-end": .sessionEnd,
]
// MARK: Agent definitions
static let agentDefs: [AgentHookDef] = [
AgentHookDef(
name: "codex", displayName: "Codex", statusKey: "codex",
configDir: ".codex", configFile: "hooks.json", configDirEnvOverride: "CODEX_HOME",
sessionStoreSuffix: "codex", disableEnvVar: "CMUX_CODEX_HOOKS_DISABLED",
hookMarker: "cmux hooks codex", format: .nested(timeoutMs: 5000),
events: [
.init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"),
.init(agentEvent: "UserPromptSubmit", cmuxSubcommand: "prompt-submit"),
.init(agentEvent: "Stop", cmuxSubcommand: "stop"),
],
feedHookEvents: ["PreToolUse", "PermissionRequest"],
postInstallAction: .codexConfigToml
),
AgentHookDef(
name: "opencode", displayName: "OpenCode", statusKey: "opencode",
configDir: ".config/opencode", configFile: "plugins/cmux-session.js", configDirEnvOverride: "OPENCODE_CONFIG_DIR",
sessionStoreSuffix: "opencode", disableEnvVar: "CMUX_OPENCODE_HOOKS_DISABLED",
hookMarker: "cmux hooks opencode", format: .flat,
events: []
),
AgentHookDef(
name: "pi", displayName: "Pi", statusKey: "pi",
configDir: ".pi/agent", configFile: "extensions/cmux-session.ts", configDirEnvOverride: "PI_CODING_AGENT_DIR",
sessionStoreSuffix: "pi", disableEnvVar: "CMUX_PI_HOOKS_DISABLED",
hookMarker: "cmux hooks pi", format: .flat,
events: []
),
AgentHookDef(
name: "amp", displayName: "Amp", statusKey: "amp",
configDir: ".config/amp", configFile: "plugins/cmux-session.ts",
sessionStoreSuffix: "amp", disableEnvVar: "CMUX_AMP_HOOKS_DISABLED",
hookMarker: "cmux hooks amp", format: .flat,
events: []
),
AgentHookDef(
name: "cursor", displayName: "Cursor", statusKey: "cursor",
configDir: ".cursor", configFile: "hooks.json", binaryName: "cursor-agent",
sessionStoreSuffix: "cursor", disableEnvVar: "CMUX_CURSOR_HOOKS_DISABLED",
hookMarker: "cmux hooks cursor", format: .flat,
events: [
.init(agentEvent: "beforeSubmitPrompt", cmuxSubcommand: "prompt-submit"),
.init(agentEvent: "stop", cmuxSubcommand: "stop"),
.init(agentEvent: "afterAgentResponse", cmuxSubcommand: "agent-response"),
.init(agentEvent: "beforeShellExecution", cmuxSubcommand: "shell-exec"),
.init(agentEvent: "afterShellExecution", cmuxSubcommand: "shell-done"),
],
feedHookEvents: ["beforeShellExecution"]
),
AgentHookDef(
name: "gemini", displayName: "Gemini", statusKey: "gemini",
configDir: ".gemini", configFile: "settings.json",
sessionStoreSuffix: "gemini", disableEnvVar: "CMUX_GEMINI_HOOKS_DISABLED",
hookMarker: "cmux hooks gemini", format: .nested(timeoutMs: 10000),
events: [
.init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"),
.init(agentEvent: "BeforeAgent", cmuxSubcommand: "prompt-submit"),
.init(agentEvent: "AfterAgent", cmuxSubcommand: "stop"),
.init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"),
],
feedHookEvents: ["PreToolUse"]
),
AgentHookDef(
name: "rovodev", displayName: "Rovo Dev", statusKey: "rovodev",
configDir: ".rovodev", configFile: "config.yml", binaryName: "acli",
sessionStoreSuffix: "rovodev", disableEnvVar: "CMUX_ROVODEV_HOOKS_DISABLED",
hookMarker: "cmux hooks rovodev", format: .rovoDevYAML,
events: [
.init(agentEvent: "on_complete", cmuxSubcommand: "stop"),
.init(agentEvent: "on_error", cmuxSubcommand: "stop"),
.init(agentEvent: "on_tool_permission", cmuxSubcommand: "prompt-submit"),
],
aliases: ["rovo"]
),
AgentHookDef(
name: "hermes-agent", displayName: "Hermes Agent", statusKey: "hermes-agent",
configDir: ".hermes", configFile: "config.yaml", configDirEnvOverride: "HERMES_HOME",
binaryName: "hermes",
sessionStoreSuffix: "hermes-agent", disableEnvVar: "CMUX_HERMES_AGENT_HOOKS_DISABLED",
hookMarker: "cmux hooks hermes-agent", format: .hermesAgentYAML,
events: [
.init(agentEvent: "on_session_start", cmuxSubcommand: "session-start"),
.init(agentEvent: "pre_llm_call", cmuxSubcommand: "prompt-submit"),
.init(agentEvent: "post_llm_call", cmuxSubcommand: "agent-response"),
.init(agentEvent: "on_session_end", cmuxSubcommand: "session-end"),
.init(agentEvent: "on_session_finalize", cmuxSubcommand: "session-end"),
.init(agentEvent: "on_session_reset", cmuxSubcommand: "session-start"),
],
feedHookEvents: ["pre_tool_call", "post_tool_call", "pre_approval_request", "post_approval_response"]
),
AgentHookDef(
name: "copilot", displayName: "Copilot", statusKey: "copilot",
configDir: ".copilot", configFile: "config.json", configDirEnvOverride: "COPILOT_HOME",
sessionStoreSuffix: "copilot", disableEnvVar: "CMUX_COPILOT_HOOKS_DISABLED",
hookMarker: "cmux hooks copilot", format: .nested(timeoutMs: 5000),
events: [
.init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"),
.init(agentEvent: "Stop", cmuxSubcommand: "stop"),
.init(agentEvent: "Notification", cmuxSubcommand: "stop"),
.init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"),
],
feedHookEvents: ["PreToolUse"]
),
AgentHookDef(
name: "codebuddy", displayName: "CodeBuddy", statusKey: "codebuddy",
configDir: ".codebuddy", configFile: "settings.json", configDirEnvOverride: "CODEBUDDY_CONFIG_DIR",
sessionStoreSuffix: "codebuddy", disableEnvVar: "CMUX_CODEBUDDY_HOOKS_DISABLED",
hookMarker: "cmux hooks codebuddy", format: .nested(timeoutMs: 5000),
events: [
.init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"),
.init(agentEvent: "Stop", cmuxSubcommand: "stop"),
.init(agentEvent: "Notification", cmuxSubcommand: "stop"),
.init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"),
],
feedHookEvents: ["PreToolUse"]
),
AgentHookDef(
name: "factory", displayName: "Factory", statusKey: "factory",
configDir: ".factory", configFile: "settings.json", binaryName: "droid",
sessionStoreSuffix: "factory", disableEnvVar: "CMUX_FACTORY_HOOKS_DISABLED",
hookMarker: "cmux hooks factory", format: .nested(timeoutMs: 5000),
events: [
.init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"),
.init(agentEvent: "Stop", cmuxSubcommand: "stop"),
.init(agentEvent: "Notification", cmuxSubcommand: "stop"),
.init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"),
],
feedHookEvents: ["PreToolUse"]
),
AgentHookDef(
name: "qoder", displayName: "Qoder", statusKey: "qoder",
configDir: ".qoder", configFile: "settings.json", configDirEnvOverride: "QODER_CONFIG_DIR", binaryName: "qodercli",
sessionStoreSuffix: "qoder", disableEnvVar: "CMUX_QODER_HOOKS_DISABLED",
hookMarker: "cmux hooks qoder", format: .nested(timeoutMs: 5000),
events: [
.init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"),
.init(agentEvent: "Stop", cmuxSubcommand: "stop"),
.init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"),
],
feedHookEvents: ["PreToolUse"]
),
]
static func agentDef(named name: String) -> AgentHookDef? {
let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return agentDefs.first { $0.name == normalized || $0.aliases.contains(normalized) }
}
static func hookMarkers(for def: AgentHookDef) -> [String] {
var markers = [def.hookMarker]
if def.name == "codex" {
markers.append("cmux codex-hook")
}
return markers
}
/// Marker substrings used when removing / upgrading our own Feed bridge
/// entries on reinstall or uninstall.
static func feedHookMarkers(for def: AgentHookDef) -> [String] {
var markers = ["cmux hooks feed --source"]
if def.name == "codex" {
markers.append("cmux feed-hook --source")
}
return markers
}
}
+221
View File
@@ -0,0 +1,221 @@
import Foundation
extension CMUXCLI {
private static let ampExtensionMarker = "cmux-amp-session-extension-marker"
private static let ampExtensionFilename = "cmux-session.ts"
private static let ampExtensionSource = #"""
// cmux-amp-session-extension-marker v1
// Bridges Amp session lifecycle events into cmux's restorable session store.
// Installed by `cmux hooks amp install` or `cmux hooks setup`.
// DO NOT EDIT MANUALLY. cmux upgrades this file in place.
// @i-know-the-amp-plugin-api-is-wip-and-very-experimental-right-now
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import type {
PluginAPI,
AgentEndEvent,
AgentStartEvent,
SessionStartEvent,
} from "@ampcode/plugin";
function firstString(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) return value.trim();
}
return null;
}
function resolveExecutable(name: string): string {
const pathEnv = process.env.PATH || "";
for (const dir of pathEnv.split(path.delimiter)) {
if (!dir) continue;
const candidate = path.join(dir, name);
try {
fs.accessSync(candidate, fs.constants.X_OK);
return candidate;
} catch (_) {}
}
return name;
}
function looksLikeAmpExecutable(value: string): boolean {
return path.basename(value).toLowerCase() === "amp";
}
function looksLikeAmpScript(value: string): boolean {
const normalized = value.replaceAll("\\", "/");
const base = path.basename(normalized).toLowerCase();
return (
normalized.includes("/@ampcode/") ||
(base === "cli.js" && normalized.includes("amp"))
);
}
function looksLikeJavaScriptRuntime(value: string): boolean {
const base = path.basename(value).toLowerCase();
return base === "node" || base === "bun" || base === "deno" || base === "tsx" || base === "ts-node";
}
function normalizedLaunchArgv(): string[] {
const raw = Array.isArray(process.argv) ? process.argv.map((value) => String(value)) : [];
if (raw.length === 0) return [resolveExecutable("amp")];
if (looksLikeAmpExecutable(raw[0])) return raw;
if (raw.length > 1 && (looksLikeAmpScript(raw[1]) || looksLikeJavaScriptRuntime(raw[0]))) {
return [resolveExecutable("amp"), ...raw.slice(2)];
}
return [resolveExecutable("amp")];
}
function base64NulSeparated(values: string[]): string {
const bytes: Buffer[] = [];
for (const value of values) {
bytes.push(Buffer.from(String(value), "utf8"));
bytes.push(Buffer.from([0]));
}
return Buffer.concat(bytes).toString("base64");
}
function hookEnvironment(cwd: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env };
delete env.AMP_API_KEY;
if (!env.CMUX_AGENT_LAUNCH_ARGV_B64) {
const argv = normalizedLaunchArgv();
env.CMUX_AGENT_LAUNCH_KIND = "amp";
env.CMUX_AGENT_LAUNCH_EXECUTABLE = argv[0] || resolveExecutable("amp");
env.CMUX_AGENT_LAUNCH_ARGV_B64 = base64NulSeparated(argv);
env.CMUX_AGENT_LAUNCH_CWD = cwd || process.cwd();
}
return env;
}
function eventName(subcommand: string): string {
switch (subcommand) {
case "session-start":
return "SessionStart";
case "prompt-submit":
return "UserPromptSubmit";
case "stop":
return "Stop";
default:
return subcommand;
}
}
function sendHook(
subcommand: string,
sessionId: string,
cwd: string,
extra: Record<string, unknown> = {}
): void {
if (process.env.CMUX_AMP_HOOKS_DISABLED === "1") return;
if (!process.env.CMUX_SURFACE_ID) return;
if (!sessionId) return;
const payload: Record<string, unknown> = {
session_id: sessionId,
cwd,
hook_event_name: eventName(subcommand),
event: eventName(subcommand),
...extra,
};
const cmux = process.env.CMUX_AMP_CMUX_BIN || "cmux";
try {
const child = spawn(cmux, ["hooks", "amp", subcommand], {
env: hookEnvironment(cwd),
stdio: ["pipe", "ignore", "ignore"],
detached: true,
});
child.on("error", () => {});
child.stdin.on("error", () => {});
child.stdin.end(JSON.stringify(payload));
child.unref();
} catch (_) {}
}
type AmpThreadContext = { thread?: { id?: string } };
function threadIdFrom(event: { thread?: { id?: string } } | undefined, ctx?: AmpThreadContext): string | null {
return firstString(event?.thread?.id, ctx?.thread?.id);
}
export default function (amp: PluginAPI) {
const cwdFromEnv = (): string =>
firstString(process.env.PWD, process.cwd()) || process.cwd();
amp.on("session.start", async (event: SessionStartEvent, ctx) => {
const sessionId = threadIdFrom(event, ctx);
if (!sessionId) return;
sendHook("session-start", sessionId, cwdFromEnv());
});
amp.on("agent.start", async (event: AgentStartEvent, ctx) => {
const sessionId = threadIdFrom(event, ctx);
if (!sessionId) return;
sendHook("prompt-submit", sessionId, cwdFromEnv());
});
amp.on("agent.end", async (event: AgentEndEvent, ctx) => {
const sessionId = threadIdFrom(event, ctx);
if (!sessionId) return;
sendHook("stop", sessionId, cwdFromEnv());
});
}
"""#
private func ampExtensionURL(for def: AgentHookDef) -> URL {
URL(fileURLWithPath: def.resolvedConfigDir(), isDirectory: true)
.appendingPathComponent("plugins", isDirectory: true)
.appendingPathComponent(Self.ampExtensionFilename, isDirectory: false)
}
func installAmpExtensionHooks(_ def: AgentHookDef) throws {
let extensionURL = ampExtensionURL(for: def)
let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes")
|| ProcessInfo.processInfo.arguments.contains("-y")
let existing = (try? String(contentsOf: extensionURL, encoding: .utf8)) ?? ""
if existing == Self.ampExtensionSource {
print("Amp hooks already up to date at \(extensionURL.path)")
return
}
if !existing.isEmpty, !existing.contains(Self.ampExtensionMarker) {
throw CLIError(message: "\(extensionURL.path) exists and is not a cmux plugin; leaving it alone")
}
if !skipConfirm {
Self.printInstallPreview(
path: extensionURL.path,
oldContent: existing,
newContent: Self.ampExtensionSource,
fallbackContent: Self.ampExtensionSource
)
print("\nProceed? [y/N] ", terminator: "")
guard readLine()?.lowercased().hasPrefix("y") == true else {
print("Aborted.")
return
}
}
try FileManager.default.createDirectory(
at: extensionURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Self.ampExtensionSource.write(to: extensionURL, atomically: true, encoding: .utf8)
print("Amp hooks installed at \(extensionURL.path)")
}
func uninstallAmpExtensionHooks(_ def: AgentHookDef) throws {
let extensionURL = ampExtensionURL(for: def)
let fm = FileManager.default
guard fm.fileExists(atPath: extensionURL.path) else {
print("No Amp cmux plugin found at \(extensionURL.path)")
return
}
let existing = (try? String(contentsOf: extensionURL, encoding: .utf8)) ?? ""
guard existing.contains(Self.ampExtensionMarker) else {
print("Refusing to remove \(extensionURL.path): missing cmux marker")
return
}
try fm.removeItem(at: extensionURL)
print("Removed Amp cmux plugin from \(extensionURL.path)")
}
}
+239
View File
@@ -0,0 +1,239 @@
import Foundation
extension CMUXCLI {
private static let cmuxCodexHooksFeatureBegin =
"# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df begin"
private static let cmuxCodexHooksFeatureEnd =
"# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df end"
private static let cmuxCodexHooksFeaturePreviousLinePrefix =
"# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df previous line: "
private static let legacyCmuxCodexHooksFeatureBegin = "# cmux hooks codex feature begin"
private static let legacyCmuxCodexHooksFeatureEnd = "# cmux hooks codex feature end"
private static let legacyCmuxCodexHooksFeaturePreviousLinePrefix = "# cmux hooks codex feature previous line: "
static func codexConfigTomlInstallingHooksFeature(in existingContent: String) -> String {
var lines = tomlLines(from: existingContent)
removeCmuxCodexHooksFeatureBlock(from: &lines)
lines.removeAll { tomlLineDefinesKey("codex_hooks", line: $0) }
lines.removeAll { tomlLineDefinesDottedFeaturesKey("codex_hooks", line: $0) }
let insertedLines = [
cmuxCodexHooksFeatureBegin,
"hooks = true",
cmuxCodexHooksFeatureEnd,
]
let insertedDottedLines = [
cmuxCodexHooksFeatureBegin,
"features.hooks = true",
cmuxCodexHooksFeatureEnd,
]
if let featuresStart = lines.firstIndex(where: { tomlLineIsTable("features", line: $0) }) {
let featuresEnd = tomlTableEndIndex(in: lines, after: featuresStart)
if featuresStart + 1 < featuresEnd,
let hooksIndex = (featuresStart + 1..<featuresEnd)
.first(where: { tomlLineDefinesKey("hooks", line: lines[$0]) })
{
if !tomlLineDefinesTrueKey("hooks", line: lines[hooksIndex]) {
let previousLine = lines[hooksIndex]
lines.replaceSubrange(
hooksIndex...hooksIndex,
with: codexHooksFeatureLines(settingLine: "hooks = true", previousLine: previousLine)
)
}
} else {
lines.insert(contentsOf: insertedLines, at: featuresStart + 1)
}
} else if let dottedHooksIndex = lines.firstIndex(where: { tomlLineDefinesDottedFeaturesKey("hooks", line: $0) }) {
if !tomlLineDefinesDottedFeaturesTrueKey("hooks", line: lines[dottedHooksIndex]) {
let previousLine = lines[dottedHooksIndex]
lines.replaceSubrange(
dottedHooksIndex...dottedHooksIndex,
with: codexHooksFeatureLines(settingLine: "features.hooks = true", previousLine: previousLine)
)
}
} else if let firstDottedFeaturesIndex = lines.firstIndex(where: { tomlLineDefinesAnyDottedFeaturesKey($0) }) {
lines.insert(contentsOf: insertedDottedLines, at: firstDottedFeaturesIndex)
} else {
if !lines.isEmpty, lines.last?.isEmpty == false {
lines.append("")
}
lines.append("[features]")
lines.append(contentsOf: insertedLines)
}
return tomlContent(from: lines)
}
private static func codexHooksFeatureLines(settingLine: String, previousLine: String? = nil) -> [String] {
var lines = [cmuxCodexHooksFeatureBegin]
if let previousLine {
lines.append(cmuxCodexHooksFeaturePreviousLinePrefix + previousLine)
}
lines.append(settingLine)
lines.append(cmuxCodexHooksFeatureEnd)
return lines
}
static func codexConfigTomlUninstallingHooksFeature(from existingContent: String) -> String {
var lines = tomlLines(from: existingContent)
removeCmuxCodexHooksFeatureBlock(from: &lines)
lines.removeAll { tomlLineDefinesKey("codex_hooks", line: $0) }
lines.removeAll { tomlLineDefinesDottedFeaturesKey("codex_hooks", line: $0) }
removeEmptyFeaturesTable(from: &lines)
return tomlContent(from: lines)
}
private static func tomlLines(from content: String) -> [String] {
guard !content.isEmpty else { return [] }
var lines = content.components(separatedBy: "\n")
if content.hasSuffix("\n"), lines.last == "" {
lines.removeLast()
}
return lines
}
private static func tomlContent(from lines: [String]) -> String {
guard !lines.isEmpty else { return "" }
return lines.joined(separator: "\n") + "\n"
}
private static func tomlLineDefinesKey(_ key: String, line: String) -> Bool {
let escapedKey = NSRegularExpression.escapedPattern(for: key)
return line.range(
of: #"^\s*"# + escapedKey + #"\s*="#,
options: .regularExpression
) != nil
}
private static func tomlLineDefinesTrueKey(_ key: String, line: String) -> Bool {
let escapedKey = NSRegularExpression.escapedPattern(for: key)
return line.range(
of: #"^\s*"# + escapedKey + #"\s*=\s*true\s*(#.*)?$"#,
options: .regularExpression
) != nil
}
private static func tomlLineDefinesDottedFeaturesKey(_ key: String, line: String) -> Bool {
let escapedKey = NSRegularExpression.escapedPattern(for: key)
return line.range(
of: #"^\s*features\s*\.\s*"# + escapedKey + #"\s*="#,
options: .regularExpression
) != nil
}
private static func tomlLineDefinesDottedFeaturesTrueKey(_ key: String, line: String) -> Bool {
let escapedKey = NSRegularExpression.escapedPattern(for: key)
return line.range(
of: #"^\s*features\s*\.\s*"# + escapedKey + #"\s*=\s*true\s*(#.*)?$"#,
options: .regularExpression
) != nil
}
private static func tomlLineDefinesAnyDottedFeaturesKey(_ line: String) -> Bool {
line.range(
of: #"^\s*features\s*\.\s*[^=\s]+\s*="#,
options: .regularExpression
) != nil
}
private static func tomlLineIsTable(_ name: String, line: String) -> Bool {
let escapedName = NSRegularExpression.escapedPattern(for: name)
return line.range(
of: #"^\s*\[\s*"# + escapedName + #"\s*\]\s*(#.*)?$"#,
options: .regularExpression
) != nil
}
private static func tomlLineIsAnyTableHeader(_ line: String) -> Bool {
let tomlKey = "(?:[A-Za-z0-9_-]+|\"[^\"\\n]*\"|'[^'\\n]*')"
let tomlKeyPath = tomlKey + "(?:\\s*\\.\\s*" + tomlKey + ")*"
let pattern = "^\\s*(?:\\[\\s*" + tomlKeyPath + "\\s*\\]|\\[\\[\\s*" + tomlKeyPath
+ "\\s*\\]\\])\\s*(#.*)?$"
return line.range(
of: pattern,
options: .regularExpression
) != nil
}
private static func tomlTableEndIndex(in lines: [String], after tableStart: Int) -> Int {
var index = tableStart + 1
while index < lines.count {
if tomlLineIsAnyTableHeader(lines[index]) {
return index
}
index += 1
}
return lines.count
}
private static func removeCmuxCodexHooksFeatureBlock(from lines: inout [String]) {
var index = 0
while index < lines.count {
guard tomlLineIsCodexHooksFeatureBegin(lines[index]) else {
index += 1
continue
}
if let endIndex = lines[index...].firstIndex(where: {
tomlLineIsCodexHooksFeatureEnd($0)
}) {
let previousLines = lines[index...endIndex].compactMap { line -> String? in
tomlCodexHooksFeaturePreviousLine(from: line)
}
lines.replaceSubrange(index...endIndex, with: previousLines)
} else {
var blockEnd = index + 1
var previousLines: [String] = []
if blockEnd < lines.count,
let previousLine = tomlCodexHooksFeaturePreviousLine(from: lines[blockEnd])
{
previousLines.append(previousLine)
blockEnd += 1
}
if blockEnd < lines.count, tomlLineIsCodexHooksFeatureSetting(lines[blockEnd]) {
blockEnd += 1
}
lines.replaceSubrange(index..<blockEnd, with: previousLines)
}
}
}
private static func tomlLineIsCodexHooksFeatureBegin(_ line: String) -> Bool {
line == cmuxCodexHooksFeatureBegin || line == legacyCmuxCodexHooksFeatureBegin
}
private static func tomlLineIsCodexHooksFeatureEnd(_ line: String) -> Bool {
line == cmuxCodexHooksFeatureEnd || line == legacyCmuxCodexHooksFeatureEnd
}
private static func tomlCodexHooksFeaturePreviousLine(from line: String) -> String? {
if line.hasPrefix(cmuxCodexHooksFeaturePreviousLinePrefix) {
return String(line.dropFirst(cmuxCodexHooksFeaturePreviousLinePrefix.count))
}
if line.hasPrefix(legacyCmuxCodexHooksFeaturePreviousLinePrefix) {
return String(line.dropFirst(legacyCmuxCodexHooksFeaturePreviousLinePrefix.count))
}
return nil
}
private static func tomlLineIsCodexHooksFeatureSetting(_ line: String) -> Bool {
tomlLineDefinesTrueKey("hooks", line: line)
|| tomlLineDefinesDottedFeaturesTrueKey("hooks", line: line)
}
private static func removeEmptyFeaturesTable(from lines: inout [String]) {
guard let featuresStart = lines.firstIndex(where: { tomlLineIsTable("features", line: $0) }) else {
return
}
let featuresEnd = tomlTableEndIndex(in: lines, after: featuresStart)
let bodyRange = featuresStart + 1..<featuresEnd
let hasContent = bodyRange.contains { index in
let trimmed = lines[index].trimmingCharacters(in: .whitespaces)
return !trimmed.isEmpty && !trimmed.hasPrefix("#")
}
if !hasContent {
lines.removeSubrange(featuresStart..<featuresEnd)
}
}
}
+6 -3
View File
@@ -84,16 +84,19 @@ extension CMUXCLI {
DocsReference(
topic: "agents",
aliases: ["integrations", "agent-integrations"],
summary: "Codex, Claude Code, OpenCode, and agent workflow integrations.",
summary: "Agent hook integrations, Feed approvals, notifications, and session restore.",
webURL: "https://cmux.com/docs/agent-integrations/oh-my-codex",
rawResources: [
DocsResource(label: "agent hook docs", url: "https://raw.githubusercontent.com/manaflow-ai/cmux/main/docs/agent-hooks.md"),
DocsResource(label: "feed docs", url: "https://raw.githubusercontent.com/manaflow-ai/cmux/main/docs/feed.md"),
DocsResource(label: "notifications docs", url: "https://raw.githubusercontent.com/manaflow-ai/cmux/main/docs/notifications.md"),
],
commands: [
"cmux codex install-hooks",
"cmux hooks opencode install",
"cmux hooks setup",
"cmux hooks setup <agent>",
"cmux hooks hermes-agent install",
"cmux hooks hermes-agent uninstall",
"cmux hooks <agent> uninstall",
]
),
DocsReference(
+245
View File
@@ -0,0 +1,245 @@
import CoreFoundation
import Darwin
import Foundation
private struct EventStreamLimitReached: Error {}
extension CMUXCLI {
private struct EventsCommandOptions {
var afterSeq: Int64?
var cursorFile: String?
var names: [String] = []
var categories: [String] = []
var reconnect = false
var limit: Int?
var printAck = true
var printHeartbeats = true
}
func runEventsCommand(
commandArgs: [String],
socketPath: String,
explicitPassword: String?
) throws {
var options = try parseEventsOptions(commandArgs)
if options.afterSeq == nil, let cursorFile = options.cursorFile {
options.afterSeq = try readEventCursor(from: cursorFile)
}
var lastSeq = options.afterSeq
var emittedEvents = 0
while true {
let client = SocketClient(path: socketPath)
do {
try client.connect()
try authenticateClientIfNeeded(
client,
explicitPassword: explicitPassword,
socketPath: socketPath
)
var params: [String: Any] = [
"include_heartbeats": true
]
if let lastSeq {
params["after_seq"] = NSNumber(value: lastSeq)
}
if !options.names.isEmpty {
params["names"] = options.names
}
if !options.categories.isEmpty {
params["categories"] = options.categories
}
try client.streamV2(method: "events.stream", params: params) { line in
guard !line.isEmpty else { return }
let frame = try parseEventStreamFrame(line)
let type = frame["type"] as? String ?? ""
let eventSequence: Int64?
if type == "event" {
guard let seq = int64Value(frame["seq"]) else {
throw CLIError(message: "Invalid event stream frame: event missing numeric seq")
}
eventSequence = seq
} else {
eventSequence = nil
}
if type == "ack", !options.printAck {
return
}
if type == "heartbeat", !options.printHeartbeats {
return
}
print(line)
fflush(stdout)
if let eventSequence {
if let cursorFile = options.cursorFile {
try writeEventCursor(eventSequence, to: cursorFile)
}
lastSeq = eventSequence
emittedEvents += 1
if let limit = options.limit, emittedEvents >= limit {
throw EventStreamLimitReached()
}
}
}
} catch is EventStreamLimitReached {
client.close()
return
} catch {
client.close()
guard options.reconnect, isTransientEventStreamError(error) else {
throw error
}
waitBeforeReconnectingEventStream()
continue
}
}
}
func isTransientEventStreamError(_ error: Error) -> Bool {
if let cliError = error as? CLIError {
let message = cliError.message.lowercased()
let transientMarkers = [
"socket not found",
"failed to connect",
"event stream closed",
"event stream socket read error",
"timed out waiting for event stream frame",
"stream request timed out",
"failed to write stream request",
"broken pipe",
"connection reset",
"connection refused",
"errno 32",
"errno 35",
"errno 54",
"errno 57",
"errno 60",
"errno 61"
]
return transientMarkers.contains { message.contains($0) }
}
let description = String(describing: error).lowercased()
return description.contains("connection reset")
|| description.contains("connection refused")
|| description.contains("broken pipe")
|| description.contains("timed out")
}
func waitBeforeReconnectingEventStream() {
let deadline = Date(timeIntervalSinceNow: 1.0)
var didFire = false
let timer = Timer(timeInterval: 1.0, repeats: false) { _ in
didFire = true
}
RunLoop.current.add(timer, forMode: .default)
while !didFire, RunLoop.current.run(mode: .default, before: deadline) {}
timer.invalidate()
}
private func parseEventsOptions(_ args: [String]) throws -> EventsCommandOptions {
var options = EventsCommandOptions()
var index = 0
while index < args.count {
let arg = args[index]
func requireValue() throws -> String {
guard index + 1 < args.count else {
throw CLIError(message: "\(arg) requires a value")
}
index += 1
return args[index]
}
switch arg {
case "--after", "--after-seq":
let raw = try requireValue()
guard let seq = Int64(raw), seq >= 0 else {
throw CLIError(message: "\(arg) must be a non-negative integer")
}
options.afterSeq = seq
case "--cursor-file":
options.cursorFile = try requireValue()
case "--name":
options.names.append(try requireValue())
case "--category":
options.categories.append(try requireValue())
case "--reconnect":
options.reconnect = true
case "--limit":
let raw = try requireValue()
guard let limit = Int(raw), limit > 0 else {
throw CLIError(message: "--limit must be greater than 0")
}
options.limit = limit
case "--no-ack":
options.printAck = false
case "--no-heartbeat", "--no-heartbeats":
options.printHeartbeats = false
default:
throw CLIError(message: "Unknown events option: \(arg)")
}
index += 1
}
return options
}
private func parseEventStreamFrame(_ line: String) throws -> [String: Any] {
guard let data = line.data(using: .utf8),
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw CLIError(message: "Invalid event stream frame: \(line)")
}
if let ok = object["ok"] as? Bool, ok == false {
let error = object["error"] as? [String: Any]
let message = error?["message"] as? String ?? "event stream error"
throw CLIError(message: message)
}
return object
}
private func readEventCursor(from path: String) throws -> Int64? {
let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath)
guard FileManager.default.fileExists(atPath: url.path) else {
return nil
}
let text: String
do {
text = try String(contentsOf: url, encoding: .utf8)
} catch {
throw CLIError(message: "Failed to read events cursor file \(url.path): \(String(describing: error))")
}
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let sequence = Int64(trimmed), sequence >= 0 else {
throw CLIError(message: "Malformed events cursor file \(url.path): expected a non-negative sequence number")
}
return sequence
}
private func writeEventCursor(_ seq: Int64, to path: String) throws {
let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath)
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try "\(seq)\n".write(to: url, atomically: true, encoding: .utf8)
}
private func int64Value(_ value: Any?) -> Int64? {
if let number = value as? NSNumber {
guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
let type = String(cString: number.objCType)
guard ["c", "C", "s", "S", "i", "I", "l", "L", "q", "Q"].contains(type) else { return nil }
let int64 = number.int64Value
guard number.compare(NSNumber(value: int64)) == .orderedSame else { return nil }
return int64
}
if let string = value as? String { return Int64(string) }
return nil
}
}
+100
View File
@@ -0,0 +1,100 @@
import Foundation
import CMUXAgentLaunch
extension CMUXCLI {
func hermesAgentShellCommand(_ script: String) -> String {
"sh -c \(shellQuote(script))"
}
func hermesAgentEvents(def: AgentHookDef) -> [HermesAgentHookConfig.Event] {
var events = def.events.map { event in
HermesAgentHookConfig.Event(
name: event.agentEvent,
command: hermesAgentShellCommand(hookCommand(for: def, event: event)),
timeout: 5
)
}
events.append(contentsOf: def.feedHookEvents.map { agentEvent in
HermesAgentHookConfig.Event(
name: agentEvent,
command: hermesAgentShellCommand(feedHookCommand(for: def, agentEvent: agentEvent)),
timeout: 120
)
})
return events
}
func installHermesAgentHooks(_ def: AgentHookDef) throws {
let fm = FileManager.default
let configDir = def.resolvedConfigDir()
let filePath = "\(configDir)/\(def.configFile)"
let allowlistPath = "\(configDir)/shell-hooks-allowlist.json"
let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes")
|| ProcessInfo.processInfo.arguments.contains("-y")
guard fm.fileExists(atPath: configDir) else {
print("\(configDir) does not exist. Install \(def.displayName) first.")
return
}
let events = hermesAgentEvents(def: def)
let oldString = try readAgentHookConfig(filePath: filePath, displayName: def.displayName)
let newString = HermesAgentHookConfig.installing(events: events, in: oldString)
if oldString != newString {
if !skipConfirm {
Self.printInstallPreview(
path: filePath,
oldContent: oldString,
newContent: newString,
fallbackContent: newString
)
print("\nProceed? [y/N] ", terminator: "")
guard readLine()?.lowercased().hasPrefix("y") == true else {
print("Aborted.")
return
}
}
try newString.write(toFile: filePath, atomically: true, encoding: .utf8)
print("\(def.displayName) hooks installed at \(filePath)")
} else {
print("\(def.displayName) hooks already up to date at \(filePath)")
}
let oldAllowlist = fm.contents(atPath: allowlistPath)
let newAllowlist = try HermesAgentHookAllowlist.installing(events: events, in: oldAllowlist)
if oldAllowlist != newAllowlist {
try newAllowlist.write(to: URL(fileURLWithPath: allowlistPath), options: .atomic)
print("Approved \(def.displayName) cmux shell hooks in \(allowlistPath)")
}
}
func uninstallHermesAgentHooks(_ def: AgentHookDef) throws {
let fm = FileManager.default
let configDir = def.resolvedConfigDir()
let filePath = "\(configDir)/\(def.configFile)"
let allowlistPath = "\(configDir)/shell-hooks-allowlist.json"
let events = hermesAgentEvents(def: def)
if fm.fileExists(atPath: filePath) {
let oldString = try readAgentHookConfig(filePath: filePath, displayName: def.displayName)
let newString = HermesAgentHookConfig.uninstalling(from: oldString)
if oldString != newString {
try newString.write(toFile: filePath, atomically: true, encoding: .utf8)
print("Removed Hermes Agent cmux hooks from \(filePath)")
} else {
print("Removed 0 cmux hook(s) from \(filePath)")
}
} else {
print("No \(def.configFile) found at \(filePath)")
}
guard fm.fileExists(atPath: allowlistPath) else { return }
let oldAllowlist = fm.contents(atPath: allowlistPath)
let newAllowlist = try HermesAgentHookAllowlist.uninstalling(events: events, from: oldAllowlist)
if oldAllowlist != newAllowlist {
try newAllowlist.write(to: URL(fileURLWithPath: allowlistPath), options: .atomic)
print("Removed Hermes Agent cmux shell hook approvals from \(allowlistPath)")
}
}
}
+202
View File
@@ -0,0 +1,202 @@
import Darwin
import Foundation
extension CMUXCLI {
private enum AnsiStyle {
static func reset(_ tty: Bool) -> String { tty ? "\u{001B}[0m" : "" }
static func bold(_ tty: Bool) -> String { tty ? "\u{001B}[1m" : "" }
static func dim(_ tty: Bool) -> String { tty ? "\u{001B}[2m" : "" }
static func red(_ tty: Bool) -> String { tty ? "\u{001B}[31m" : "" }
static func green(_ tty: Bool) -> String { tty ? "\u{001B}[32m" : "" }
static func yellow(_ tty: Bool) -> String { tty ? "\u{001B}[33m" : "" }
static func magenta(_ tty: Bool) -> String { tty ? "\u{001B}[35m" : "" }
static func cyan(_ tty: Bool) -> String { tty ? "\u{001B}[36m" : "" }
}
/// Prints a colored diff preview framed with the target path both
/// above and below the diff. Every changed line is prefixed with
/// `+` (addition) or `-` (deletion) and colored accordingly so the
/// user can see at a glance what's being added/removed. A summary
/// line above the diff counts adds/deletes so users who pipe the
/// output into tee/less still see the shape of the change.
static func printInstallPreview(
path: String,
oldContent: String,
newContent: String,
fallbackContent: String
) {
let tty = isatty(fileno(stdout)) != 0
let verb = oldContent.isEmpty ? "create" : "update"
let header = AnsiStyle.bold(tty)
+ "─── Will \(verb) \(path) ───" + AnsiStyle.reset(tty)
let footer = AnsiStyle.bold(tty)
+ "─── The above will be written to \(path) ───" + AnsiStyle.reset(tty)
print("")
print(header)
if oldContent == newContent {
print(AnsiStyle.dim(tty) + "(no changes — file already matches target)" + AnsiStyle.reset(tty))
print(footer)
return
}
if let diff = unifiedDiff(old: oldContent, new: newContent), !diff.isEmpty {
let (adds, dels) = countAddDeleteLines(diff)
let summary = AnsiStyle.bold(tty)
+ AnsiStyle.green(tty) + "+\(adds) additions" + AnsiStyle.reset(tty)
+ AnsiStyle.bold(tty) + ", "
+ AnsiStyle.red(tty) + "-\(dels) deletions" + AnsiStyle.reset(tty)
print(summary)
print("")
print(colorizeDiff(diff, tty: tty))
} else {
// Diff unavailable (binary `/usr/bin/diff` missing, or temp-file
// write failed). Fall back to full pretty-printed content.
print(AnsiStyle.bold(tty) + "(diff unavailable — full content follows)" + AnsiStyle.reset(tty))
for line in fallbackContent.split(separator: "\n", omittingEmptySubsequences: false) {
let plus = AnsiStyle.green(tty) + AnsiStyle.bold(tty) + "+" + AnsiStyle.reset(tty)
print("\(plus) " + jsonHighlight(String(line), tty: tty))
}
}
print(footer)
}
/// Counts added and deleted lines in a unified-diff body, ignoring
/// file header lines (`+++ `, `--- `).
private static func countAddDeleteLines(_ diff: String) -> (adds: Int, dels: Int) {
var adds = 0
var dels = 0
for raw in diff.split(separator: "\n", omittingEmptySubsequences: false) {
let line = String(raw)
if line.hasPrefix("+++") || line.hasPrefix("---") { continue }
if line.hasPrefix("+") { adds += 1 }
if line.hasPrefix("-") { dels += 1 }
}
return (adds, dels)
}
/// Shells out to `/usr/bin/diff -u` against two temp files. Returns
/// nil if diff isn't available or both inputs are identical.
private static func unifiedDiff(old: String, new: String) -> String? {
if old == new { return "" }
let tempDir = FileManager.default.temporaryDirectory
let oldURL = tempDir.appendingPathComponent("cmux-old-\(UUID().uuidString)")
let newURL = tempDir.appendingPathComponent("cmux-new-\(UUID().uuidString)")
defer {
try? FileManager.default.removeItem(at: oldURL)
try? FileManager.default.removeItem(at: newURL)
}
do {
try old.write(to: oldURL, atomically: true, encoding: .utf8)
try new.write(to: newURL, atomically: true, encoding: .utf8)
} catch { return nil }
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/diff")
process.arguments = ["-u", oldURL.path, newURL.path]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
var output = Data()
let outputLock = NSLock()
pipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty else { return }
outputLock.lock()
output.append(data)
outputLock.unlock()
}
do {
try process.run()
} catch { return nil }
process.waitUntilExit()
pipe.fileHandleForReading.readabilityHandler = nil
let remaining = pipe.fileHandleForReading.readDataToEndOfFile()
if !remaining.isEmpty {
outputLock.lock()
output.append(remaining)
outputLock.unlock()
}
// diff exits with 1 on differences; that's fine.
outputLock.lock()
let data = output
outputLock.unlock()
return String(data: data, encoding: .utf8)
}
/// Colors a unified diff: file headers dim, hunks cyan, additions
/// bright-green-bold, deletions bright-red-bold, context uncolored.
/// Non-tty output still retains the leading +/- markers so the
/// change shape is obvious even when piped into a file or pager.
private static func colorizeDiff(_ diff: String, tty: Bool) -> String {
var out: [String] = []
for line in diff.split(separator: "\n", omittingEmptySubsequences: false) {
let s = String(line)
if s.hasPrefix("+++") || s.hasPrefix("---") {
out.append(AnsiStyle.dim(tty) + s + AnsiStyle.reset(tty))
} else if s.hasPrefix("@@") {
out.append(AnsiStyle.cyan(tty) + s + AnsiStyle.reset(tty))
} else if s.hasPrefix("+") {
out.append(AnsiStyle.bold(tty) + AnsiStyle.green(tty) + s + AnsiStyle.reset(tty))
} else if s.hasPrefix("-") {
out.append(AnsiStyle.bold(tty) + AnsiStyle.red(tty) + s + AnsiStyle.reset(tty))
} else {
out.append(s)
}
}
return out.joined(separator: "\n")
}
/// Tiny JSON syntax highlighter. Strings cyan; numbers yellow;
/// booleans/null magenta. Keys stay uncolored because we'd need a
/// lookahead parser to reliably distinguish them from string values.
private static func jsonHighlight(_ json: String, tty: Bool) -> String {
guard tty else { return json }
var out = ""
out.reserveCapacity(json.count + 32)
var i = json.startIndex
while i < json.endIndex {
let c = json[i]
if c == "\"" {
// Scan to closing quote, honoring escapes.
let start = i
i = json.index(after: i)
while i < json.endIndex {
let ch = json[i]
if ch == "\\", json.index(after: i) < json.endIndex {
i = json.index(i, offsetBy: 2)
continue
}
if ch == "\"" {
i = json.index(after: i)
break
}
i = json.index(after: i)
}
let lit = String(json[start..<i])
out += AnsiStyle.cyan(tty) + lit + AnsiStyle.reset(tty)
continue
}
if c.isNumber || (c == "-" && json.index(after: i) < json.endIndex && json[json.index(after: i)].isNumber) {
let start = i
while i < json.endIndex, let ch = Optional(json[i]),
ch.isNumber || ch == "." || ch == "-" || ch == "+" || ch == "e" || ch == "E" {
i = json.index(after: i)
}
out += AnsiStyle.yellow(tty) + String(json[start..<i]) + AnsiStyle.reset(tty)
continue
}
if json[i...].hasPrefix("true") || json[i...].hasPrefix("false") || json[i...].hasPrefix("null") {
let token = json[i...].hasPrefix("false") ? "false"
: (json[i...].hasPrefix("true") ? "true" : "null")
out += AnsiStyle.magenta(tty) + token + AnsiStyle.reset(tty)
i = json.index(i, offsetBy: token.count)
continue
}
out.append(c)
i = json.index(after: i)
}
return out
}
}
+98
View File
@@ -0,0 +1,98 @@
import Darwin
import Foundation
struct CLIProcessResult {
let status: Int32
let stdout: String
let stderr: String
let timedOut: Bool
}
enum CLIProcessRunner {
static func runProcess(
executablePath: String,
arguments: [String],
stdinText: String? = nil,
timeout: TimeInterval? = nil
) -> CLIProcessResult {
let process = Process()
process.executableURL = URL(fileURLWithPath: executablePath)
process.arguments = arguments
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
let stdinPipe: Pipe?
if stdinText != nil {
let pipe = Pipe()
process.standardInput = pipe
stdinPipe = pipe
} else {
stdinPipe = nil
}
let finished = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in
finished.signal()
}
do {
try process.run()
} catch {
return CLIProcessResult(status: 1, stdout: "", stderr: String(describing: error), timedOut: false)
}
if let stdinText, let stdinPipe {
if let data = stdinText.data(using: .utf8) {
stdinPipe.fileHandleForWriting.write(data)
}
stdinPipe.fileHandleForWriting.closeFile()
}
let timedOut: Bool
if let timeout {
switch finished.wait(timeout: .now() + timeout) {
case .success:
timedOut = false
case .timedOut:
timedOut = true
terminate(process: process, finished: finished)
}
} else {
finished.wait()
timedOut = false
}
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
var stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
if timedOut {
let timeoutMessage = "process timed out"
if stderr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
stderr = timeoutMessage
} else if !stderr.contains(timeoutMessage) {
stderr += "\n\(timeoutMessage)"
}
}
return CLIProcessResult(
status: timedOut ? 124 : process.terminationStatus,
stdout: stdout,
stderr: stderr,
timedOut: timedOut
)
}
private static func terminate(process: Process, finished: DispatchSemaphore) {
guard process.isRunning else { return }
process.terminate()
if finished.wait(timeout: .now() + 0.5) == .success {
return
}
if process.isRunning {
kill(process.processIdentifier, SIGKILL)
}
_ = finished.wait(timeout: .now() + 0.5)
}
}
+1862 -673
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-23
View File
@@ -1,23 +0,0 @@
{
"pins" : [
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
"version" : "1.7.0"
}
},
{
"identity" : "swiftterm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/migueldeicaza/SwiftTerm.git",
"state" : {
"revision" : "0b8d99bd19b694df44e1ccaa3891309719d34330",
"version" : "1.5.1"
}
}
],
"version" : 2
}
-23
View File
@@ -1,23 +0,0 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "cmux",
platforms: [
.macOS(.v13)
],
products: [
.executable(name: "cmux", targets: ["cmux"])
],
dependencies: [
.package(path: "Packages/CMUXRovoDevIndex"),
.package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0")
],
targets: [
.executableTarget(
name: "cmux",
dependencies: ["CMUXRovoDevIndex", "SwiftTerm"],
path: "Sources"
)
]
)
+5 -1
View File
@@ -12,9 +12,13 @@ let package = Package(
targets: ["CMUXAgentLaunch"]
),
],
dependencies: [
.package(path: "../CMUXAgentVault"),
],
targets: [
.target(
name: "CMUXAgentLaunch"
name: "CMUXAgentLaunch",
dependencies: ["CMUXAgentVault"]
),
.testTarget(
name: "CMUXAgentLaunchTests",
@@ -25,6 +25,12 @@ public enum ClaudeConfigDirectoryPath {
public enum AgentLaunchEnvironmentPolicy {
private static let safeEnvironmentKeys: Set<String> = [
// AMP_API_KEY is intentionally NOT allowlisted: it's a secret.
// Amp resolves auth from ~/.config/amp/settings.json on resume.
"AMP_LOG_FILE",
"AMP_LOG_LEVEL",
"AMP_SETTINGS_FILE",
"AMP_URL",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_MODEL",
"CLAUDE_CONFIG_DIR",
@@ -50,8 +56,15 @@ public enum AgentLaunchEnvironmentPolicy {
"COPILOT_PROVIDER_WIRE_MODEL",
"GEMINI_CLI_HOME",
"GH_HOST",
"HERMES_HOME",
"NODE_OPTIONS",
"OPENCODE_CONFIG_DIR",
"PI_CACHE_RETENTION",
"PI_CODING_AGENT_DIR",
"PI_CODING_AGENT_SESSION_DIR",
"PI_OFFLINE",
"PI_PACKAGE_DIR",
"PI_SKIP_VERSION_CHECK",
"QODER_CONFIG_DIR",
"USE_BUILTIN_RIPGREP"
]
@@ -57,6 +57,26 @@ public enum AgentLaunchSanitizer {
return preserveOptions(args, policy: claudePolicy)
case "codex":
return preserveOptions(args, policy: codexPolicy)
case "pi":
return preserveOptions(args, policy: piPolicy)
case "amp":
// Strip the `threads continue <id>` resume sub-subcommand if the
// captured launch already started by resuming a thread, so we
// don't double-add it. Supports the documented short aliases:
// `t`/`thread` for `threads`, and `c` for `continue`.
var tail = args
let threadsAliases: Set<String> = ["threads", "thread", "t"]
let continueAliases: Set<String> = ["continue", "c"]
if let first = tail.first, threadsAliases.contains(first) {
tail.removeFirst()
if let next = tail.first, continueAliases.contains(next) {
tail.removeFirst()
if let candidate = tail.first, !candidate.hasPrefix("-") {
tail.removeFirst()
}
}
}
return preserveOptions(tail, policy: ampPolicy)
case "cursor":
var tail = args
if tail.first == "agent" {
@@ -81,6 +101,15 @@ public enum AgentLaunchSanitizer {
return nil
}
return preserveOptions(tail, policy: rovoDevPolicy)
case "hermes-agent":
var tail = args
if tail.first == "chat" {
tail.removeFirst()
} else if let command = tail.first,
!command.hasPrefix("-") {
return nil
}
return preserveOptions(tail, policy: hermesAgentPolicy)
case "copilot":
return preserveOptions(args, policy: copilotPolicy)
case "codebuddy":
@@ -385,4 +385,68 @@ extension AgentLaunchSanitizer {
"-o"
]
)
static let hermesAgentPolicy = Policy(
// Boolean flags such as --tui pass through by default unless they are
// explicitly rejected or dropped below.
valueOptions: [
"--api-key",
"--base-url",
"--image",
"--max-turns",
"--model",
"-m",
"--profile",
"-p",
"--provider",
"--resume",
"-r",
"--skills",
"-s",
"--source",
"--toolsets",
"-t",
"--worktree",
"-w"
],
optionalValueOptions: [
"--continue",
"-c"
],
nonRestorableCommands: [],
droppedOptions: [
"--api-key",
"--continue",
"-c",
"--image",
"--resume",
"-r",
"--source",
"--verbose",
"-v",
"--worktree",
"-w"
],
droppedOptionPrefixes: [
"--api-key=",
"--continue=",
"-c=",
"--image=",
"--resume=",
"-r=",
"--source=",
"--worktree=",
"-w="
],
rejectOptions: [
"--oneshot",
"-z",
"--query",
"-q",
"--quiet",
"-Q",
"--list-tools",
"--list-toolsets"
]
)
}
@@ -10,6 +10,7 @@ extension AgentLaunchSanitizer {
"--allowed-tools",
"--append-system-prompt",
"--betas",
"--dangerously-load-development-channels",
"--debug-file",
"--disallowedTools",
"--disallowed-tools",
@@ -156,6 +157,115 @@ extension AgentLaunchSanitizer {
resumeSubcommand: "resume"
)
static let piPolicy = Policy(
valueOptions: [
"--append-system-prompt",
"--api-key",
"--extension",
"--fork",
"--model",
"--models",
"--prompt-template",
"--provider",
"--resume",
"--session",
"--session-dir",
"--skill",
"--system-prompt",
"--theme",
"--thinking",
"--tools",
"-e",
"-t"
],
nonRestorableCommands: [
"config",
"help",
"install",
"list",
"login",
"logout",
"remove",
"uninstall",
"update"
],
droppedOptions: [
"--api-key",
"--continue",
"--fork",
"--resume",
"--session",
"-c",
"-r"
],
droppedOptionPrefixes: [
"--api-key=",
"--fork=",
"--resume=",
"--session="
],
rejectOptions: [
"--export",
"--list-models",
"--mode",
"--no-session",
"--print",
"--prompt",
"--version",
"-h",
"-p",
"-v"
]
)
static let ampPolicy = Policy(
valueOptions: [
"--effort",
// --label takes a value; listed here AND in droppedOptions so the
// sanitizer consumes the value too (otherwise it slips through as
// a positional).
"--label",
"--log-file",
"--log-level",
"--mcp-config",
"--mode",
"--settings-file",
"--visibility",
"-l",
"-m"
],
nonRestorableCommands: [
"login",
"logout",
"mcp",
"permissions",
"permission",
"review",
"skill",
"skills",
"tool",
"tools",
"update",
"up",
"usage",
"version"
],
droppedOptions: [
"--archive",
"--label",
"-l",
"--stream-json",
"--stream-json-input",
"--stream-json-thinking"
],
rejectOptions: [
"--execute",
"--print",
"-V",
"-x"
]
)
static let geminiPolicy = Policy(
valueOptions: [
"--model",
@@ -0,0 +1,329 @@
import Foundation
public enum HermesAgentHookConfig {
public struct Event: Equatable, Sendable {
public var name: String
public var command: String
public var timeout: Int
public var matcher: String?
public init(name: String, command: String, timeout: Int = 5, matcher: String? = nil) {
self.name = name
self.command = command
self.timeout = timeout
self.matcher = matcher
}
}
private static let beginMarker = "# cmux hooks hermes-agent begin"
private static let endMarker = "# cmux hooks hermes-agent end"
private static let restoreLineMarkerPrefix = "\(beginMarker) restore-line-base64:"
public static func installing(events: [Event], in existing: String) -> String {
guard !events.isEmpty else {
return uninstalling(from: existing)
}
var lines = normalizedLines(existing)
lines = removingMarkedBlocks(lines)
if let hooksIndex = hooksLineIndex(in: lines) {
let hooksRestoreLine: String?
if inlineEmptyHooksLine(lines[hooksIndex]) {
hooksRestoreLine = lines[hooksIndex]
lines[hooksIndex] = "\(leadingWhitespace(lines[hooksIndex]))hooks:"
} else {
hooksRestoreLine = nil
}
let childIndent = leadingWhitespace(lines[hooksIndex]) + " "
let existingEvents = directEventLineIndexes(in: lines, hooksIndex: hooksIndex)
var missingEvents: [Event] = []
var matchedEvents: [(event: Event, eventIndex: Int)] = []
for event in events {
guard let eventIndex = existingEvents[event.name] else {
missingEvents.append(event)
continue
}
matchedEvents.append((event, eventIndex))
}
for (event, eventIndex) in matchedEvents.sorted(by: { $0.eventIndex > $1.eventIndex }) {
let eventRestoreLine: String?
if inlineEmptyEventLine(lines[eventIndex]) {
let originalLine = lines[eventIndex]
let headerLine = emptyEventHeaderLine(originalLine)
eventRestoreLine = originalLine == headerLine ? nil : originalLine
lines[eventIndex] = headerLine
} else {
eventRestoreLine = nil
}
let entryIndent = leadingWhitespace(lines[eventIndex]) + " "
let block = hookListBlock(events: [event], itemIndent: entryIndent, restoreLine: eventRestoreLine)
lines.insert(contentsOf: block, at: eventIndex + 1)
}
if !missingEvents.isEmpty {
let block = eventSectionsBlock(
events: missingEvents,
childIndent: childIndent,
restoreLine: hooksRestoreLine
)
lines.insert(contentsOf: block, at: hooksIndex + 1)
}
} else {
if !lines.isEmpty, lines.last?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
lines.append("")
}
lines.append(beginMarker)
lines.append("hooks:")
lines.append(contentsOf: eventSectionsBlock(events: events, childIndent: " ", includeMarkers: false))
lines.append(endMarker)
}
return serialized(lines)
}
public static func uninstalling(from existing: String) -> String {
serialized(removingMarkedBlocks(normalizedLines(existing)))
}
private static func normalizedLines(_ content: String) -> [String] {
var lines = content
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
.split(separator: "\n", omittingEmptySubsequences: false)
.map(String.init)
if lines.last == "" {
lines.removeLast()
}
return lines
}
private static func serialized(_ lines: [String]) -> String {
lines.isEmpty ? "" : lines.joined(separator: "\n") + "\n"
}
private static func eventSectionsBlock(
events: [Event],
childIndent: String,
includeMarkers: Bool = true,
restoreLine: String? = nil
) -> [String] {
var lines: [String] = []
if includeMarkers {
lines.append("\(childIndent)\(beginMarkerLine(restoreLine: restoreLine))")
}
for event in events {
lines.append("\(childIndent)\(event.name):")
lines.append(contentsOf: hookEntries(events: [event], itemIndent: childIndent + " "))
}
if includeMarkers {
lines.append("\(childIndent)\(endMarker)")
}
return lines
}
private static func hookListBlock(events: [Event], itemIndent: String, restoreLine: String? = nil) -> [String] {
var lines = ["\(itemIndent)\(beginMarkerLine(restoreLine: restoreLine))"]
lines.append(contentsOf: hookEntries(events: events, itemIndent: itemIndent))
lines.append("\(itemIndent)\(endMarker)")
return lines
}
private static func hookEntries(events: [Event], itemIndent: String) -> [String] {
var lines: [String] = []
for event in events {
lines.append("\(itemIndent)- command: \(yamlDoubleQuoted(event.command))")
if let matcher = event.matcher?.trimmingCharacters(in: .whitespacesAndNewlines), !matcher.isEmpty {
lines.append("\(itemIndent) matcher: \(yamlDoubleQuoted(matcher))")
}
lines.append("\(itemIndent) timeout: \(event.timeout)")
}
return lines
}
private static func removingMarkedBlocks(_ lines: [String]) -> [String] {
var result = lines
var index = 0
while index < result.count {
guard isBeginMarkerLine(result[index]) else {
index += 1
continue
}
guard let endIndex = result[(index + 1)...].firstIndex(where: {
$0.trimmingCharacters(in: .whitespaces) == endMarker
}) else {
index += 1
continue
}
if let restoreLine = restoreLine(fromBeginMarkerLine: result[index]),
result.indices.contains(index - 1) {
result[index - 1] = restoreLine
result.removeSubrange(index...endIndex)
continue
}
let removalStart = result.indices.contains(index - 1)
&& result[index - 1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? index - 1
: index
result.removeSubrange(removalStart...endIndex)
index = removalStart
}
return result
}
private static func beginMarkerLine(restoreLine: String?) -> String {
guard let restoreLine else { return beginMarker }
let encoded = Data(restoreLine.utf8).base64EncodedString()
return "\(restoreLineMarkerPrefix) \(encoded)"
}
private static func isBeginMarkerLine(_ line: String) -> Bool {
let trimmed = line.trimmingCharacters(in: .whitespaces)
return trimmed == beginMarker || trimmed.hasPrefix("\(restoreLineMarkerPrefix) ")
}
private static func restoreLine(fromBeginMarkerLine line: String) -> String? {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("\(restoreLineMarkerPrefix) ") else { return nil }
let encoded = trimmed.dropFirst(restoreLineMarkerPrefix.count)
.trimmingCharacters(in: .whitespaces)
guard let data = Data(base64Encoded: encoded) else { return nil }
return String(data: data, encoding: .utf8)
}
private static func hooksLineIndex(in lines: [String]) -> Int? {
lines.firstIndex { line in
leadingWhitespace(line).isEmpty
&& line.range(of: #"^hooks:\s*((\{\}|\[\])\s*)?(#.*)?$"#, options: .regularExpression) != nil
}
}
private static func inlineEmptyHooksLine(_ line: String) -> Bool {
line.range(of: #"^hooks:\s*(\{\}|\[\])\s*(#.*)?$"#, options: .regularExpression) != nil
}
private static func inlineEmptyEventLine(_ line: String) -> Bool {
guard let colon = line.firstIndex(of: ":") else { return false }
let suffix = line[line.index(after: colon)...]
return suffixIsInlineEmptyMapOrList(suffix)
}
private static func emptyEventHeaderLine(_ line: String) -> String {
guard let colon = line.firstIndex(of: ":") else { return line }
return String(line[...colon])
}
private static func suffixIsInlineEmptyMapOrList(_ suffix: Substring) -> Bool {
let uncommented = suffix.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first ?? ""
let trimmed = uncommented.trimmingCharacters(in: .whitespaces)
return trimmed.isEmpty || trimmed == "{}" || trimmed == "[]"
}
private static func directEventLineIndexes(in lines: [String], hooksIndex: Int) -> [String: Int] {
let hooksIndent = leadingWhitespace(lines[hooksIndex])
let childIndent = hooksIndent + " "
var indexes: [String: Int] = [:]
var index = hooksIndex + 1
while index < lines.count {
let line = lines[index]
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") {
index += 1
continue
}
guard line.hasPrefix(childIndent) else {
break
}
guard leadingWhitespace(line) == childIndent,
let colon = trimmed.firstIndex(of: ":") else {
index += 1
continue
}
let name = String(trimmed[..<colon])
let suffix = trimmed[trimmed.index(after: colon)...]
if suffixIsInlineEmptyMapOrList(suffix) {
indexes[name] = index
}
index += 1
}
return indexes
}
private static func leadingWhitespace(_ line: String) -> String {
String(line.prefix { $0 == " " || $0 == "\t" })
}
private static func yamlDoubleQuoted(_ value: String) -> String {
var escaped = value.replacingOccurrences(of: "\\", with: "\\\\")
escaped = escaped.replacingOccurrences(of: "\"", with: "\\\"")
escaped = escaped.replacingOccurrences(of: "\n", with: "\\n")
return "\"\(escaped)\""
}
}
public enum HermesAgentHookAllowlist {
enum Error: Swift.Error, Equatable {
case invalidRoot
}
public static func installing(events: [HermesAgentHookConfig.Event], in existing: Data?, approvedAt: Date = Date()) throws -> Data {
var object = try decode(existing)
let approvals = object["approvals"] as? [[String: Any]] ?? []
var keyed: [String: [String: Any]] = [:]
var passthrough: [[String: Any]] = []
for approval in approvals {
guard let event = approval["event"] as? String,
let command = approval["command"] as? String else {
passthrough.append(approval)
continue
}
keyed[key(event: event, command: command)] = approval
}
let iso = ISO8601DateFormatter().string(from: approvedAt)
for event in events {
keyed[key(event: event.name, command: event.command)] = [
"event": event.name,
"command": event.command,
"approved_at": iso,
]
}
let ownedApprovals = keyed.values.sorted {
(($0["event"] as? String) ?? "", ($0["command"] as? String) ?? "")
< ((($1["event"] as? String) ?? ""), (($1["command"] as? String) ?? ""))
}
object["approvals"] = passthrough + ownedApprovals
return try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
}
public static func uninstalling(events: [HermesAgentHookConfig.Event], from existing: Data?) throws -> Data {
var object = try decode(existing)
let ownedKeys = Set(events.map { key(event: $0.name, command: $0.command) })
let approvals = object["approvals"] as? [[String: Any]] ?? []
object["approvals"] = approvals.filter { approval in
guard let event = approval["event"] as? String,
let command = approval["command"] as? String else {
return true
}
return !ownedKeys.contains(key(event: event, command: command))
}
return try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
}
private static func decode(_ existing: Data?) throws -> [String: Any] {
guard let existing, !existing.isEmpty else {
return ["approvals": []]
}
guard let object = try JSONSerialization.jsonObject(with: existing) as? [String: Any] else {
throw Error.invalidRoot
}
return object
}
private static func key(event: String, command: String) -> String {
"\(event)\u{0}\(command)"
}
}
@@ -0,0 +1,38 @@
import Foundation
public enum HermesAgentSessionResolver {
public static func hermesHome(env: [String: String]) -> String {
if let home = normalized(env["HERMES_HOME"]) {
return expandedPath(home, env: env)
}
let baseHome = normalized(env["HOME"]) ?? NSHomeDirectory()
return (baseHome as NSString).appendingPathComponent(".hermes")
}
public static func configPath(env: [String: String]) -> String {
(hermesHome(env: env) as NSString).appendingPathComponent("config.yaml")
}
public static func stateDBPath(env: [String: String]) -> String {
(hermesHome(env: env) as NSString).appendingPathComponent("state.db")
}
public static func allowlistPath(env: [String: String]) -> String {
(hermesHome(env: env) as NSString).appendingPathComponent("shell-hooks-allowlist.json")
}
public static func expandedPath(_ path: String, env: [String: String]) -> String {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed == "~" || trimmed.hasPrefix("~/") else {
return NSString(string: trimmed).expandingTildeInPath
}
let home = normalized(env["HOME"]) ?? NSHomeDirectory()
guard trimmed != "~" else { return home }
return (home as NSString).appendingPathComponent(String(trimmed.dropFirst(2)))
}
private static func normalized(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}
@@ -1,6 +1,12 @@
import CMUXAgentVault
import Foundation
public enum RovoDevSessionResolver {
private struct RovoDevSessionCandidate {
let sessionId: String
let modified: Date
}
public static func inferredRovoDevSessionId(cwd: String?, env: [String: String]) -> String? {
let sessionsRoot = rovoDevSessionsRoot(env: env)
let rootURL = URL(fileURLWithPath: sessionsRoot, isDirectory: true)
@@ -13,10 +19,8 @@ public enum RovoDevSessionResolver {
return nil
}
let normalizedCwd = cwd.map {
URL(fileURLWithPath: NSString(string: $0).expandingTildeInPath).standardizedFileURL.path
}
var candidates: [String] = []
let normalizedCwd = RovoDevIndex.normalizedPath(cwd)
var candidates: [RovoDevSessionCandidate] = []
candidates.reserveCapacity(sessionURLs.count)
for sessionURL in sessionURLs {
guard (try? sessionURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else {
@@ -27,18 +31,30 @@ public enum RovoDevSessionResolver {
let metadata = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
continue
}
let workspace = (metadata["workspace_path"] as? String)
?? (metadata["workspacePath"] as? String)
let normalizedWorkspace = workspace.map {
URL(fileURLWithPath: NSString(string: $0).expandingTildeInPath).standardizedFileURL.path
}
let workspace = RovoDevMetadataFields.workspacePath(from: metadata)
let normalizedWorkspace = RovoDevIndex.normalizedPath(workspace)
guard rovoDevWorkspace(normalizedWorkspace, matches: normalizedCwd) else {
continue
}
candidates.append(sessionURL.lastPathComponent)
let sessionContextURL = sessionURL.appendingPathComponent("session_context.json", isDirectory: false)
let modified = max(
RovoDevIndex.contentModificationDate(ofRegularFile: metadataURL) ?? Date.distantPast,
RovoDevIndex.contentModificationDate(ofRegularFile: sessionContextURL) ?? Date.distantPast
)
candidates.append(RovoDevSessionCandidate(
sessionId: sessionURL.lastPathComponent,
modified: modified
))
}
guard candidates.count == 1 else { return nil }
return candidates.first
candidates.sort {
if $0.modified == $1.modified {
// Rovo session IDs are time-ordered, so descending sessionId
// keeps equal modified times stable while preferring newer sessions.
return $0.sessionId > $1.sessionId
}
return $0.modified > $1.modified
}
return candidates.first?.sessionId
}
public static func rovoDevSessionsRoot(env: [String: String]) -> String {
@@ -56,7 +72,7 @@ public enum RovoDevSessionResolver {
return rovoDevExpandedPath(persistenceDir, env: env)
}
public static func rovoDevWorkspace(_ workspace: String?, matches cwd: String?) -> Bool {
private static func rovoDevWorkspace(_ workspace: String?, matches cwd: String?) -> Bool {
guard let cwd, !cwd.isEmpty else { return false }
guard let workspace, !workspace.isEmpty else { return false }
return cwd == workspace
@@ -35,4 +35,181 @@ struct AgentLaunchSanitizerTests {
) == ["cursor-agent", "--model", "gpt-5.4", "--sandbox", "enabled"]
)
}
@Test("Drops Pi session selectors and prompt while preserving configuration")
func dropsPiSessionSelectorsAndPrompt() {
#expect(
AgentLaunchSanitizer.sanitizedLaunchArguments(
[
"pi", "--session", "old-session", "--model", "anthropic/claude-sonnet-4-5",
"--thinking", "high", "--api-key", "secret", "implement this",
],
launcher: "pi",
fallbackKind: "pi"
) == ["pi", "--model", "anthropic/claude-sonnet-4-5", "--thinking", "high"]
)
}
@Test("Preserves repeated Pi extension and skill flags without replaying prompt")
func preservesRepeatedPiExtensionAndSkillFlags() {
#expect(
AgentLaunchSanitizer.sanitizedLaunchArguments(
[
"pi", "--extension", "a.ts", "--extension", "b.ts",
"--skill", "review", "--skill", "swift", "initial prompt",
],
launcher: "pi",
fallbackKind: "pi"
) == [
"pi", "--extension", "a.ts", "--extension", "b.ts",
"--skill", "review", "--skill", "swift",
]
)
}
@Test("Rejects noninteractive Pi launches")
func rejectsNoninteractivePiLaunches() {
#expect(
AgentLaunchSanitizer.sanitizedLaunchArguments(
["pi", "--print", "summarize"],
launcher: "pi",
fallbackKind: "pi"
) == nil
)
#expect(
AgentLaunchSanitizer.sanitizedLaunchArguments(
["pi", "--prompt", "summarize"],
launcher: "pi",
fallbackKind: "pi"
) == nil
)
}
@Test("Preserves Hermes inherited flags without replaying startup-only input")
func preservesHermesInheritedFlagsWithoutReplayingStartupOnlyInput() {
#expect(
AgentLaunchSanitizer.sanitizedLaunchArguments(
[
"hermes",
"--profile",
"work",
"--tui",
"--skills",
"github-auth",
"-s",
"hermes-agent-dev",
"--api-key",
"secret",
"--image",
"/tmp/cat.png",
"--worktree",
"--resume",
"old-session",
"--source",
"cli",
"initial prompt should not replay",
],
launcher: "hermes-agent",
fallbackKind: "hermes-agent"
) == [
"hermes",
"--profile",
"work",
"--tui",
"--skills",
"github-auth",
"-s",
"hermes-agent-dev",
]
)
}
@Test("Drops Hermes worktree value before preserving later options")
func dropsHermesWorktreeValueBeforePreservingLaterOptions() {
#expect(
AgentLaunchSanitizer.sanitizedLaunchArguments(
["hermes", "--worktree", "/tmp/repo", "--model", "anthropic/claude-sonnet-4.6"],
launcher: "hermes-agent",
fallbackKind: "hermes-agent"
) == ["hermes", "--model", "anthropic/claude-sonnet-4.6"]
)
}
@Test("Allows only Hermes chat or default session launch")
func allowsOnlyHermesChatOrDefaultSessionLaunch() {
#expect(
AgentLaunchSanitizer.sanitizedLaunchArguments(
["hermes", "chat", "--tui", "--model", "anthropic/claude-sonnet-4.6", "initial prompt"],
launcher: "hermes-agent",
fallbackKind: "hermes-agent"
) == ["hermes", "--tui", "--model", "anthropic/claude-sonnet-4.6"]
)
#expect(
AgentLaunchSanitizer.sanitizedLaunchArguments(
["hermes", "fallback", "list"],
launcher: "hermes-agent",
fallbackKind: "hermes-agent"
) == nil
)
#expect(
AgentLaunchSanitizer.sanitizedLaunchArguments(
["hermes", "slack", "send"],
launcher: "hermes-agent",
fallbackKind: "hermes-agent"
) == nil
)
}
@Test("Treats Hermes skills as single value options")
func treatsHermesSkillsAsSingleValueOptions() {
#expect(
AgentLaunchSanitizer.sanitizedLaunchArguments(
["hermes", "--skills", "skill1", "skill2", "--model", "anthropic/claude-sonnet-4.6"],
launcher: "hermes-agent",
fallbackKind: "hermes-agent"
) == ["hermes", "--skills", "skill1"]
)
}
@Test("Drops Amp --label and its value while preserving later options")
func dropsAmpLabelValueAndPreservesLaterOptions() {
// --label takes a value. If --label isn't in valueOptions, the
// sanitizer drops only `--label` and `foo` slips through as a
// positional, breaking the resumed launch.
#expect(
AgentLaunchSanitizer.sanitizedLaunchArguments(
["amp", "--label", "foo", "--mode", "geppetto"],
launcher: "amp",
fallbackKind: "amp"
) == ["amp", "--mode", "geppetto"]
)
#expect(
AgentLaunchSanitizer.sanitizedLaunchArguments(
["amp", "-l", "bar", "--effort", "high"],
launcher: "amp",
fallbackKind: "amp"
) == ["amp", "--effort", "high"]
)
}
@Test("Rejects non-restorable Amp launches and strips resume preamble")
func rejectsNonRestorableAmpLaunchesAndStripsResumePreamble() {
// --execute / --print / -x are non-interactive runs; not restorable.
#expect(
AgentLaunchSanitizer.sanitizedLaunchArguments(
["amp", "--execute", "do this", "--mode", "geppetto"],
launcher: "amp",
fallbackKind: "amp"
) == nil
)
// A previously-resumed launch should have its `threads continue <id>`
// preamble stripped so a re-resume doesn't re-prepend it.
#expect(
AgentLaunchSanitizer.sanitizedLaunchArguments(
["amp", "threads", "continue", "T-old-id", "--mode", "geppetto"],
launcher: "amp",
fallbackKind: "amp"
) == ["amp", "--mode", "geppetto"]
)
}
}
@@ -0,0 +1,183 @@
import CMUXAgentLaunch
import Foundation
import Testing
@Suite("HermesAgentHookConfig")
struct HermesAgentHookConfigTests {
@Test("Installs hooks into empty config")
func installsHooksIntoEmptyConfig() {
let events = [
HermesAgentHookConfig.Event(name: "on_session_start", command: "sh -c 'cmux hooks hermes-agent session-start'"),
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'", timeout: 120),
]
let installed = HermesAgentHookConfig.installing(events: events, in: "")
#expect(installed.contains("# cmux hooks hermes-agent begin\nhooks:\n on_session_start:"))
#expect(installed.contains(" - command: \"sh -c 'cmux hooks hermes-agent session-start'\""))
#expect(installed.contains(" timeout: 120"))
#expect(HermesAgentHookConfig.uninstalling(from: installed) == "")
}
@Test("Preserves existing hook events without duplicating keys")
func preservesExistingHookEventsWithoutDuplicatingKeys() {
let existing = """
model: anthropic/claude-sonnet-4.6
hooks:
pre_tool_call:
- command: "echo user"
timeout: 10
post_llm_call:
- command: "echo done"
"""
let events = [
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'", timeout: 120),
HermesAgentHookConfig.Event(name: "on_session_end", command: "sh -c 'cmux hooks hermes-agent stop'"),
]
let installed = HermesAgentHookConfig.installing(events: events, in: existing)
#expect(installed.components(separatedBy: "\n pre_tool_call:").count == 2)
#expect(installed.contains(" pre_tool_call:\n # cmux hooks hermes-agent begin\n - command: \"sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'\""))
#expect(installed.contains(" - command: \"echo user\""))
#expect(installed.contains(" on_session_end:"))
#expect(HermesAgentHookConfig.uninstalling(from: installed) == existing)
}
@Test("Installs into multiple existing hook events without shifting later indexes")
func installsIntoMultipleExistingEventsWithoutShiftingLaterIndexes() {
let existing = """
hooks:
pre_tool_call:
- command: "echo pre"
post_tool_call:
- command: "echo post"
"""
let events = [
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'"),
HermesAgentHookConfig.Event(name: "post_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event post_tool_call'"),
]
let installed = HermesAgentHookConfig.installing(events: events, in: existing)
#expect(installed.contains(" pre_tool_call:\n # cmux hooks hermes-agent begin"))
#expect(installed.contains(" post_tool_call:\n # cmux hooks hermes-agent begin"))
#expect(installed.contains(" - command: \"echo pre\""))
#expect(installed.contains(" - command: \"echo post\""))
#expect(HermesAgentHookConfig.uninstalling(from: installed) == existing)
}
@Test("Installs into inline-empty hook events")
func installsIntoInlineEmptyHookEvents() {
let existing = """
hooks:
pre_tool_call: []
post_tool_call: {} # intentionally empty
"""
let events = [
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'"),
HermesAgentHookConfig.Event(name: "post_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event post_tool_call'"),
]
let installed = HermesAgentHookConfig.installing(events: events, in: existing)
#expect(installed.contains(" pre_tool_call:\n # cmux hooks hermes-agent begin"))
#expect(installed.contains(" post_tool_call:\n # cmux hooks hermes-agent begin"))
#expect(!installed.contains("pre_tool_call: []\n # cmux hooks hermes-agent begin"))
#expect(!installed.contains("post_tool_call: {} # intentionally empty\n # cmux hooks hermes-agent begin"))
#expect(HermesAgentHookConfig.uninstalling(from: installed) == existing)
}
@Test("Uninstalls inline-empty hooks root")
func uninstallsInlineEmptyHooksRoot() {
let existing = """
model: anthropic/claude-sonnet-4.6
hooks: [] # intentionally empty
"""
let events = [
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'"),
HermesAgentHookConfig.Event(name: "post_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event post_tool_call'"),
]
let installed = HermesAgentHookConfig.installing(events: events, in: existing)
#expect(installed.contains("hooks:\n # cmux hooks hermes-agent begin restore-line-base64:"))
#expect(installed.contains(" pre_tool_call:"))
#expect(HermesAgentHookConfig.uninstalling(from: installed) == existing)
}
@Test("Allowlist install and uninstall only touches cmux commands")
func allowlistInstallAndUninstallOnlyTouchesCmuxCommands() throws {
let existing = """
{
"approvals": [
{
"command": "echo user",
"event": "pre_tool_call"
}
]
}
""".data(using: .utf8)
let events = [
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'", timeout: 120),
]
let installed = try HermesAgentHookAllowlist.installing(
events: events,
in: existing,
approvedAt: Date(timeIntervalSince1970: 0)
)
let installedObject = try #require(JSONSerialization.jsonObject(with: installed) as? [String: Any])
let approvals = try #require(installedObject["approvals"] as? [[String: Any]])
#expect(approvals.count == 2)
let uninstalled = try HermesAgentHookAllowlist.uninstalling(events: events, from: installed)
let uninstalledObject = try #require(JSONSerialization.jsonObject(with: uninstalled) as? [String: Any])
let remaining = try #require(uninstalledObject["approvals"] as? [[String: Any]])
#expect(remaining.count == 1)
#expect(remaining.first?["command"] as? String == "echo user")
}
@Test("Allowlist install preserves non-conforming approvals")
func allowlistInstallPreservesNonConformingApprovals() throws {
let existing = """
{
"approvals": [
{
"event": "pre_tool_call",
"command": 12,
"scope": "third-party"
}
]
}
""".data(using: .utf8)
let events = [
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'"),
]
let installed = try HermesAgentHookAllowlist.installing(events: events, in: existing)
let installedObject = try #require(JSONSerialization.jsonObject(with: installed) as? [String: Any])
let approvals = try #require(installedObject["approvals"] as? [[String: Any]])
#expect(approvals.count == 2)
#expect(approvals.contains { $0["scope"] as? String == "third-party" })
#expect(approvals.contains { $0["command"] as? String == events[0].command })
}
@Test("Allowlist install rejects non-object JSON roots")
func allowlistInstallRejectsNonObjectJSONRoots() throws {
let existing = #"[]"#.data(using: .utf8)
let events = [
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'"),
]
do {
_ = try HermesAgentHookAllowlist.installing(events: events, in: existing)
Issue.record("expected non-object allowlist JSON to throw")
} catch {}
}
}
@@ -0,0 +1,30 @@
import CMUXAgentLaunch
import Testing
@Suite("HermesAgentSessionResolver")
struct HermesAgentSessionResolverTests {
@Test("Uses HERMES_HOME when set")
func usesHermesHomeWhenSet() {
let env = ["HOME": "/Users/example", "HERMES_HOME": "/tmp/hermes profile"]
#expect(HermesAgentSessionResolver.hermesHome(env: env) == "/tmp/hermes profile")
#expect(HermesAgentSessionResolver.configPath(env: env) == "/tmp/hermes profile/config.yaml")
#expect(HermesAgentSessionResolver.stateDBPath(env: env) == "/tmp/hermes profile/state.db")
#expect(HermesAgentSessionResolver.allowlistPath(env: env) == "/tmp/hermes profile/shell-hooks-allowlist.json")
}
@Test("Falls back to HOME dot hermes")
func fallsBackToHomeDotHermes() {
let env = ["HOME": "/Users/example"]
#expect(HermesAgentSessionResolver.hermesHome(env: env) == "/Users/example/.hermes")
#expect(HermesAgentSessionResolver.configPath(env: env) == "/Users/example/.hermes/config.yaml")
}
@Test("Expands tilde with supplied HOME")
func expandsTildeWithSuppliedHome() {
let env = ["HOME": "/Users/example", "HERMES_HOME": "~/profiles/coder"]
#expect(HermesAgentSessionResolver.hermesHome(env: env) == "/Users/example/profiles/coder")
}
}
@@ -38,4 +38,83 @@ struct RovoDevSessionResolverTests {
#expect(RovoDevSessionResolver.inferredRovoDevSessionId(cwd: "", env: env) == nil)
#expect(RovoDevSessionResolver.inferredRovoDevSessionId(cwd: root.path, env: env) == nil)
}
@Test("Infers newest matching workspace session")
func infersNewestMatchingWorkspaceSession() throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-rovo-resolver-newest-\(UUID().uuidString)", isDirectory: true)
let workspace = root.appendingPathComponent("repo", isDirectory: true)
let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true)
try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try writeSession(
sessionsRoot: sessionsRoot,
sessionId: "older-session",
workspacePath: workspace.path,
metadataModified: Date(timeIntervalSince1970: 300),
sessionContextModified: Date(timeIntervalSince1970: 100)
)
try writeSession(
sessionsRoot: sessionsRoot,
sessionId: "newer-session",
workspacePath: workspace.path,
metadataModified: Date(timeIntervalSince1970: 200),
sessionContextModified: Date(timeIntervalSince1970: 400),
workspaceKey: "workspacePath"
)
let env = ["CMUX_ROVODEV_SESSIONS_DIR": sessionsRoot.path]
#expect(RovoDevSessionResolver.inferredRovoDevSessionId(cwd: workspace.path, env: env) == "newer-session")
}
@Test("Matches symlinked workspace paths")
func matchesSymlinkedWorkspacePaths() throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-rovo-resolver-symlink-\(UUID().uuidString)", isDirectory: true)
let workspace = root.appendingPathComponent("repo-real", isDirectory: true)
let workspaceLink = root.appendingPathComponent("repo-link", isDirectory: true)
let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true)
try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true)
try FileManager.default.createSymbolicLink(at: workspaceLink, withDestinationURL: workspace)
defer { try? FileManager.default.removeItem(at: root) }
try writeSession(
sessionsRoot: sessionsRoot,
sessionId: "symlink-session",
workspacePath: workspaceLink.path,
metadataModified: Date(timeIntervalSince1970: 200),
sessionContextModified: Date(timeIntervalSince1970: 200)
)
let env = ["CMUX_ROVODEV_SESSIONS_DIR": sessionsRoot.path]
#expect(RovoDevSessionResolver.inferredRovoDevSessionId(cwd: workspace.path, env: env) == "symlink-session")
}
private func writeSession(
sessionsRoot: URL,
sessionId: String,
workspacePath: String,
metadataModified: Date,
sessionContextModified: Date,
workspaceKey: String = "workspace_path"
) throws {
let sessionURL = sessionsRoot.appendingPathComponent(sessionId, isDirectory: true)
try FileManager.default.createDirectory(at: sessionURL, withIntermediateDirectories: true)
let metadataURL = sessionURL.appendingPathComponent("metadata.json", isDirectory: false)
let metadata = [
"title": "Rovo Dev session",
workspaceKey: workspacePath,
]
let data = try JSONSerialization.data(withJSONObject: metadata)
try data.write(to: metadataURL)
try FileManager.default.setAttributes([.modificationDate: metadataModified], ofItemAtPath: metadataURL.path)
let sessionContextURL = sessionURL.appendingPathComponent("session_context.json", isDirectory: false)
try Data(#"{"message_history":[]}"#.utf8).write(to: sessionContextURL)
try FileManager.default.setAttributes(
[.modificationDate: sessionContextModified],
ofItemAtPath: sessionContextURL.path
)
}
}
+30
View File
@@ -0,0 +1,30 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "CMUXAgentVault",
platforms: [
.macOS(.v13),
],
products: [
.library(
name: "CMUXAgentVault",
targets: ["CMUXAgentVault"]
),
],
targets: [
.target(
name: "CMUXAgentVault",
linkerSettings: [
.linkedLibrary("sqlite3"),
]
),
.testTarget(
name: "CMUXAgentVaultTests",
dependencies: ["CMUXAgentVault"],
linkerSettings: [
.linkedLibrary("sqlite3"),
]
),
]
)
@@ -0,0 +1,412 @@
import Foundation
import SQLite3
/// Indexed Hermes sessions do not carry cwd metadata and cannot be filtered by working directory.
public struct HermesAgentIndexedSession: Equatable, Sendable {
public let sessionId: String
public let source: String
public let title: String
public let model: String?
public let modified: Date
public let preview: String?
public init(
sessionId: String,
source: String,
title: String,
model: String?,
modified: Date,
preview: String?
) {
self.sessionId = sessionId
self.source = source
self.title = title
self.model = model
self.modified = modified
self.preview = preview
}
}
public struct HermesAgentIndexResult: Equatable, Sendable {
public let sessions: [HermesAgentIndexedSession]
public let errors: [String]
public init(sessions: [HermesAgentIndexedSession], errors: [String]) {
self.sessions = sessions
self.errors = errors
}
}
public struct HermesAgentTranscriptTurn: Equatable, Sendable {
public let role: String
public let content: String
public let toolName: String?
public init(role: String, content: String, toolName: String?) {
self.role = role
self.content = content
self.toolName = toolName
}
}
public enum HermesAgentIndexError: Error, Equatable, Sendable {
case missingDatabase
case sqlite(String)
}
private struct HermesAgentDatabaseSnapshot {
let databaseURL: URL
private let directoryURL: URL
init(databaseURL: URL, directoryURL: URL) {
self.databaseURL = databaseURL
self.directoryURL = directoryURL
}
func remove() {
try? FileManager.default.removeItem(at: directoryURL)
}
}
public enum HermesAgentIndex {
private static let contentJSONPrefix = "\u{0}json:"
// Keep this list aligned with source kinds resumeCommand knows how to relaunch.
private static let knownSources = ["cli", "tui"]
public static func defaultStateDBPath(env: [String: String] = ProcessInfo.processInfo.environment) -> String {
if let rawHome = normalized(env["HERMES_HOME"]) {
return (expandedPath(rawHome, env: env) as NSString).appendingPathComponent("state.db")
}
let home = normalized(env["HOME"]) ?? NSHomeDirectory()
return ((home as NSString).appendingPathComponent(".hermes") as NSString)
.appendingPathComponent("state.db")
}
/// Loads Hermes sessions from state.db. Hermes does not store cwd metadata, so any non-nil cwdFilter returns no sessions and no errors.
public static func loadSessions(
needle: String,
cwdFilter: String?,
offset: Int,
limit: Int,
stateDBPath: String = Self.defaultStateDBPath()
) -> HermesAgentIndexResult {
guard limit > 0, offset >= 0 else {
return HermesAgentIndexResult(sessions: [], errors: [])
}
let (_, overflow) = offset.addingReportingOverflow(limit)
guard !overflow else {
return HermesAgentIndexResult(sessions: [], errors: [])
}
guard cwdFilter == nil else {
return HermesAgentIndexResult(sessions: [], errors: [])
}
let snapshot: HermesAgentDatabaseSnapshot
do {
guard let madeSnapshot = try makeSnapshot(stateDBPath: stateDBPath, prefix: "cmux-hermes-agent-search") else {
return HermesAgentIndexResult(sessions: [], errors: [])
}
snapshot = madeSnapshot
} catch {
return HermesAgentIndexResult(
sessions: [],
errors: ["Hermes Agent: cannot snapshot state.db (\(error.localizedDescription))"]
)
}
defer { snapshot.remove() }
do {
return try withDatabase(snapshot.databaseURL.path) { db in
try loadSessions(db: db, needle: needle, offset: offset, limit: limit)
}
} catch {
return HermesAgentIndexResult(
sessions: [],
errors: ["Hermes Agent: cannot read state.db (\(errorDescription(error)))"]
)
}
}
public static func loadTranscript(
sessionId: String,
limit: Int,
stateDBPath: String = Self.defaultStateDBPath()
) throws -> [HermesAgentTranscriptTurn] {
guard limit > 0 else { return [] }
guard let snapshot = try makeSnapshot(stateDBPath: stateDBPath, prefix: "cmux-hermes-agent-preview") else {
throw HermesAgentIndexError.missingDatabase
}
defer { snapshot.remove() }
return try withDatabase(snapshot.databaseURL.path) { db in
try loadTranscript(db: db, sessionId: sessionId, limit: limit)
}
}
private static func loadSessions(
db: OpaquePointer,
needle: String,
offset: Int,
limit: Int
) throws -> HermesAgentIndexResult {
let trimmedNeedle = needle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let hasNeedle = !trimmedNeedle.isEmpty
var sql = """
SELECT
s.id,
s.source,
COALESCE(s.title, '') AS title,
s.model,
COALESCE(MAX(m.timestamp), s.ended_at, s.started_at) AS last_active,
(
SELECT m2.content
FROM messages m2
WHERE m2.session_id = s.id
AND m2.role IN ('user', 'assistant')
AND COALESCE(m2.content, '') <> ''
ORDER BY m2.timestamp DESC, m2.id DESC
LIMIT 1
) AS preview
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
WHERE s.source IN (\(knownSources.map { "'\($0)'" }.joined(separator: ", ")))
"""
if hasNeedle {
sql += """
AND (
LOWER(s.id) LIKE ?
OR LOWER(COALESCE(s.title, '')) LIKE ?
OR LOWER(COALESCE(s.model, '')) LIKE ?
OR EXISTS (
SELECT 1
FROM messages needle_messages
WHERE needle_messages.session_id = s.id
AND (
LOWER(COALESCE(needle_messages.content, '')) LIKE ?
OR LOWER(COALESCE(needle_messages.tool_name, '')) LIKE ?
)
LIMIT 1
)
)
"""
}
sql += """
GROUP BY s.id
ORDER BY last_active DESC
LIMIT \(limit) OFFSET \(offset)
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else {
sqlite3_finalize(stmt)
throw HermesAgentIndexError.sqlite(sqliteMessage(db) ?? "prepare failed")
}
defer { sqlite3_finalize(stmt) }
if hasNeedle {
let likePattern = "%\(trimmedNeedle)%"
let destructor = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self)
for index in 1...5 {
guard sqlite3_bind_text(stmt, Int32(index), likePattern, -1, destructor) == SQLITE_OK else {
throw HermesAgentIndexError.sqlite(sqliteMessage(db) ?? "bind failed")
}
}
}
var sessions: [HermesAgentIndexedSession] = []
var stepResult = sqlite3_step(stmt)
while stepResult == SQLITE_ROW {
let sessionId = sqliteText(stmt, 0) ?? ""
guard !sessionId.isEmpty else {
stepResult = sqlite3_step(stmt)
continue
}
let source = sqliteText(stmt, 1) ?? "cli"
let rawTitle = sqliteText(stmt, 2) ?? ""
let model = sqliteText(stmt, 3)
let modified = Date(timeIntervalSince1970: sqlite3_column_double(stmt, 4))
let preview = decodedContentText(sqliteText(stmt, 5))
let title = normalized(rawTitle) ?? firstLine(preview) ?? sessionId
sessions.append(HermesAgentIndexedSession(
sessionId: sessionId,
source: source,
title: title,
model: model,
modified: modified,
preview: preview
))
stepResult = sqlite3_step(stmt)
}
guard stepResult == SQLITE_DONE else {
throw HermesAgentIndexError.sqlite(sqliteMessage(db) ?? "step failed")
}
return HermesAgentIndexResult(sessions: sessions, errors: [])
}
private static func loadTranscript(
db: OpaquePointer,
sessionId: String,
limit: Int
) throws -> [HermesAgentTranscriptTurn] {
let sql = """
SELECT role, content, tool_name, tool_calls
FROM messages
WHERE session_id = ?
ORDER BY timestamp, id
LIMIT \(limit)
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else {
sqlite3_finalize(stmt)
throw HermesAgentIndexError.sqlite(sqliteMessage(db) ?? "prepare failed")
}
defer { sqlite3_finalize(stmt) }
let destructor = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self)
guard sqlite3_bind_text(stmt, 1, sessionId, -1, destructor) == SQLITE_OK else {
throw HermesAgentIndexError.sqlite(sqliteMessage(db) ?? "bind failed")
}
var turns: [HermesAgentTranscriptTurn] = []
var stepResult = sqlite3_step(stmt)
while stepResult == SQLITE_ROW {
let role = sqliteText(stmt, 0) ?? "event"
let content = decodedContentText(sqliteText(stmt, 1))
let toolName = sqliteText(stmt, 2)
let toolCalls = decodedContentText(sqliteText(stmt, 3))
let text = [content, toolCalls]
.compactMap { normalized($0) }
.joined(separator: "\n\n")
if !text.isEmpty {
turns.append(HermesAgentTranscriptTurn(role: role, content: text, toolName: toolName))
}
stepResult = sqlite3_step(stmt)
}
guard stepResult == SQLITE_DONE else {
throw HermesAgentIndexError.sqlite(sqliteMessage(db) ?? "step failed")
}
return turns
}
private static func makeSnapshot(stateDBPath: String, prefix: String) throws -> HermesAgentDatabaseSnapshot? {
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: stateDBPath) else { return nil }
let snapshotDir = fileManager.temporaryDirectory
.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true)
try fileManager.createDirectory(at: snapshotDir, withIntermediateDirectories: true)
let snapshotDB = snapshotDir.appendingPathComponent("state.db", isDirectory: false)
do {
try fileManager.copyItem(atPath: stateDBPath, toPath: snapshotDB.path)
for sidecar in ["-wal", "-shm"] {
let source = stateDBPath + sidecar
let destination = snapshotDB.path + sidecar
if fileManager.fileExists(atPath: source) {
try fileManager.copyItem(atPath: source, toPath: destination)
}
}
} catch {
try? fileManager.removeItem(at: snapshotDir)
throw error
}
return HermesAgentDatabaseSnapshot(databaseURL: snapshotDB, directoryURL: snapshotDir)
}
private static func withDatabase<T>(_ path: String, _ body: (OpaquePointer) throws -> T) throws -> T {
var db: OpaquePointer?
let openResult = sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil)
guard openResult == SQLITE_OK, let db else {
let message = sqliteMessage(db) ?? "open failed with code \(openResult)"
sqlite3_close(db)
throw HermesAgentIndexError.sqlite(message)
}
defer { sqlite3_close(db) }
_ = sqlite3_busy_timeout(db, 50)
return try body(db)
}
private static func decodedContentText(_ value: String?) -> String? {
guard let value else { return nil }
if value.hasPrefix(contentJSONPrefix) {
let payload = String(value.dropFirst(contentJSONPrefix.count))
guard let data = payload.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) else {
return value
}
return renderedContent(object)
}
return value
}
private static func renderedContent(_ value: Any) -> String? {
if let string = value as? String {
return string
}
if let array = value as? [Any] {
let parts = array.compactMap(renderedContent)
return parts.isEmpty ? nil : parts.joined(separator: "\n\n")
}
guard let object = value as? [String: Any] else {
return nil
}
for key in ["text", "content", "output", "result", "message"] {
if let value = object[key], let rendered = renderedContent(value) {
return rendered
}
}
guard JSONSerialization.isValidJSONObject(object),
let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) else {
return nil
}
return String(data: data, encoding: .utf8)
}
private static func firstLine(_ value: String?) -> String? {
guard let value = normalized(value) else { return nil }
return value.components(separatedBy: .newlines).first.map { String($0.prefix(120)) }
}
private static func sqliteText(_ stmt: OpaquePointer, _ index: Int32) -> String? {
guard sqlite3_column_type(stmt, index) != SQLITE_NULL,
let bytes = sqlite3_column_text(stmt, index) else {
return nil
}
let count = Int(sqlite3_column_bytes(stmt, index))
return String(data: Data(bytes: bytes, count: count), encoding: .utf8)
}
private static func sqliteMessage(_ db: OpaquePointer?) -> String? {
guard let db, let cString = sqlite3_errmsg(db) else { return nil }
return String(cString: cString)
}
private static func errorDescription(_ error: Error) -> String {
if let error = error as? HermesAgentIndexError {
switch error {
case .missingDatabase:
return "missing database"
case let .sqlite(message):
return message
}
}
return error.localizedDescription
}
private static func normalized(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private static func expandedPath(_ path: String, env: [String: String]) -> String {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed == "~" || trimmed.hasPrefix("~/") else {
return NSString(string: trimmed).expandingTildeInPath
}
let home = normalized(env["HOME"]) ?? NSHomeDirectory()
guard trimmed != "~" else { return home }
return (home as NSString).appendingPathComponent(String(trimmed.dropFirst(2)))
}
}
@@ -32,21 +32,52 @@ public struct RovoDevIndexResult: Equatable, Sendable {
}
}
private struct RovoDevMetadata: Decodable {
let title: String?
let workspacePath: String?
public enum RovoDevMetadataFields {
public static let titleKeys: [String] = [
"title",
"name",
"summary",
]
private enum CodingKeys: String, CodingKey {
case title
case workspacePath = "workspace_path"
case workspacePathCamel = "workspacePath"
public static let workspacePathKeys: [String] = [
"workspace_path",
"workspacePath",
"workspace",
"cwd",
"working_directory",
"workingDirectory",
"project_path",
"projectPath",
]
public static func title(from metadata: [String: Any]) -> String? {
firstString(from: metadata, keys: titleKeys)
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
title = try container.decodeIfPresent(String.self, forKey: .title)
workspacePath = try container.decodeIfPresent(String.self, forKey: .workspacePath)
?? container.decodeIfPresent(String.self, forKey: .workspacePathCamel)
public static func workspacePath(from metadata: [String: Any]) -> String? {
firstString(from: metadata, keys: workspacePathKeys)
}
public static func firstString(from metadata: [String: Any], keys: [String]) -> String? {
for key in keys {
guard let value = metadata[key] as? String else { continue }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
return nil
}
}
private enum RovoDevMetadataError: LocalizedError {
case notObject
var errorDescription: String? {
switch self {
case .notObject:
return "metadata is not a JSON object"
}
}
}
@@ -127,11 +158,12 @@ public enum RovoDevIndex {
continue
}
let sessionContextURL = sessionURL.appendingPathComponent("session_context.json", isDirectory: false)
candidates.append(RovoDevSessionCandidate(
sessionId: sessionURL.lastPathComponent,
metadataURL: metadataURL,
sessionContextURL: sessionURL.appendingPathComponent("session_context.json", isDirectory: false),
mtime: mtime
sessionContextURL: sessionContextURL,
mtime: max(mtime, Self.contentModificationDate(ofRegularFile: sessionContextURL) ?? mtime)
))
}
candidates.sort { $0.mtime > $1.mtime }
@@ -139,24 +171,26 @@ public enum RovoDevIndex {
var matchedCount = 0
var sessions: [RovoDevIndexedSession] = []
sessions.reserveCapacity(limit)
let decoder = JSONDecoder()
for candidate in candidates {
if Task.isCancelled { break }
if matchedCount >= target { break }
let metadata: RovoDevMetadata
let metadata: [String: Any]
do {
let data = try Data(contentsOf: candidate.metadataURL)
metadata = try decoder.decode(RovoDevMetadata.self, from: data)
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw RovoDevMetadataError.notObject
}
metadata = object
} catch {
errors.append("Rovo Dev: cannot read metadata \(candidate.metadataURL.path) (\(error.localizedDescription))")
continue
}
let title = metadata.title ?? ""
let cwd = metadata.workspacePath
if let cwdFilter, cwd != cwdFilter {
let title = RovoDevMetadataFields.title(from: metadata) ?? ""
let cwd = RovoDevMetadataFields.workspacePath(from: metadata)
if !workspacePath(cwd, matchesFilter: cwdFilter) {
continue
}
if !normalizedNeedle.isEmpty {
@@ -188,4 +222,34 @@ public enum RovoDevIndex {
}
return url
}
public static func contentModificationDate(ofRegularFile url: URL) -> Date? {
guard let values = try? url.resourceValues(
forKeys: [.contentModificationDateKey, .isRegularFileKey]
),
values.isRegularFile == true else {
return nil
}
return values.contentModificationDate
}
private static func workspacePath(_ workspacePath: String?, matchesFilter filter: String?) -> Bool {
guard let filter else { return true }
guard let workspace = Self.normalizedPath(workspacePath),
let target = Self.normalizedPath(filter) else {
return false
}
return workspace == target
}
public static func normalizedPath(_ path: String?) -> String? {
guard let trimmed = path?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else {
return nil
}
return URL(fileURLWithPath: NSString(string: trimmed).expandingTildeInPath)
.resolvingSymlinksInPath()
.standardizedFileURL
.path
}
}
@@ -0,0 +1,178 @@
import CMUXAgentVault
import Foundation
import SQLite3
import Testing
@Suite("HermesAgentIndex")
struct HermesAgentIndexTests {
@Test("Loads CLI and TUI sessions from state database")
func loadsCliAndTUISessions() throws {
let root = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let dbURL = root.appendingPathComponent("state.db", isDirectory: false)
try makeHermesStateDB(at: dbURL)
try exec(dbURL, """
INSERT INTO sessions (id, source, model, started_at, title)
VALUES
('old', 'cli', 'model-a', 10, 'Old session'),
('new', 'tui', 'model-b', 20, NULL),
('tool-only', 'tool', 'model-c', 30, 'Hidden tool session');
INSERT INTO messages (session_id, role, content, timestamp)
VALUES
('old', 'user', 'older prompt', 11),
('new', 'user', 'new prompt first line', 21),
('new', 'assistant', 'new answer', 22),
('tool-only', 'user', 'hidden', 31);
""")
let result = HermesAgentIndex.loadSessions(
needle: "",
cwdFilter: nil,
offset: 0,
limit: 10,
stateDBPath: dbURL.path
)
#expect(result.errors.isEmpty)
#expect(result.sessions.map(\.sessionId) == ["new", "old"])
#expect(result.sessions.first?.source == "tui")
#expect(result.sessions.first?.title == "new answer")
#expect(result.sessions.first?.modified == Date(timeIntervalSince1970: 22))
}
@Test("Searches messages and skips directory scoped requests")
func searchesMessagesAndSkipsDirectoryScopedRequests() throws {
let root = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let dbURL = root.appendingPathComponent("state.db", isDirectory: false)
try makeHermesStateDB(at: dbURL)
try exec(dbURL, """
INSERT INTO sessions (id, source, model, started_at, title)
VALUES ('session-a', 'cli', 'model-a', 10, 'General');
INSERT INTO messages (session_id, role, content, timestamp)
VALUES ('session-a', 'assistant', 'Needle text', 11);
""")
let found = HermesAgentIndex.loadSessions(
needle: "needle",
cwdFilter: nil,
offset: 0,
limit: 10,
stateDBPath: dbURL.path
)
let scoped = HermesAgentIndex.loadSessions(
needle: "",
cwdFilter: "/tmp/repo",
offset: 0,
limit: 10,
stateDBPath: dbURL.path
)
#expect(found.sessions.map(\.sessionId) == ["session-a"])
#expect(scoped.sessions.isEmpty)
}
@Test("Loads transcript and decodes Hermes JSON content")
func loadsTranscriptAndDecodesHermesJSONContent() throws {
let root = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let dbURL = root.appendingPathComponent("state.db", isDirectory: false)
try makeHermesStateDB(at: dbURL)
try exec(dbURL, """
INSERT INTO sessions (id, source, model, started_at, title)
VALUES ('session-a', 'cli', 'model-a', 10, 'General');
INSERT INTO messages (session_id, role, content, tool_name, tool_calls, timestamp)
VALUES
('session-a', 'user', char(0) || 'json:[{"type":"text","text":"structured hello"}]', NULL, NULL, 11),
('session-a', 'tool', 'ran command', 'terminal', '{"command":"pwd"}', 12);
""")
let turns = try HermesAgentIndex.loadTranscript(
sessionId: "session-a",
limit: 10,
stateDBPath: dbURL.path
)
#expect(turns.count == 2)
#expect(turns[0].role == "user")
#expect(turns[0].content == "structured hello")
#expect(turns[1].toolName == "terminal")
#expect(turns[1].content.contains("ran command"))
#expect(turns[1].content.contains("pwd"))
}
private func temporaryDirectory() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-hermes-index-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
private func makeHermesStateDB(at url: URL) throws {
try exec(url, """
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
user_id TEXT,
model TEXT,
model_config TEXT,
system_prompt TEXT,
parent_session_id TEXT,
started_at REAL NOT NULL,
ended_at REAL,
end_reason TEXT,
message_count INTEGER DEFAULT 0,
tool_call_count INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cache_read_tokens INTEGER DEFAULT 0,
cache_write_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0,
billing_provider TEXT,
billing_base_url TEXT,
billing_mode TEXT,
estimated_cost_usd REAL,
actual_cost_usd REAL,
cost_status TEXT,
cost_source TEXT,
pricing_version TEXT,
title TEXT,
api_call_count INTEGER DEFAULT 0
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
tool_name TEXT,
timestamp REAL NOT NULL,
token_count INTEGER,
finish_reason TEXT,
reasoning TEXT,
reasoning_content TEXT,
reasoning_details TEXT,
codex_reasoning_items TEXT,
codex_message_items TEXT
);
""")
}
private func exec(_ dbURL: URL, _ sql: String) throws {
var db: OpaquePointer?
guard sqlite3_open(dbURL.path, &db) == SQLITE_OK, let db else {
throw HermesAgentIndexError.sqlite("open failed")
}
defer { sqlite3_close(db) }
var error: UnsafeMutablePointer<Int8>?
let result = sqlite3_exec(db, sql, nil, nil, &error)
guard result == SQLITE_OK else {
let message = error.map { String(cString: $0) } ?? "exec failed"
sqlite3_free(error)
throw HermesAgentIndexError.sqlite(message)
}
}
}
@@ -1,4 +1,4 @@
import CMUXRovoDevIndex
import CMUXAgentVault
import Foundation
import Testing
@@ -30,6 +30,70 @@ struct RovoDevIndexTests {
#expect(result.sessions.first?.sessionContextURL?.lastPathComponent == "session_context.json")
}
@Test("Sorts by session activity and accepts workspacePath metadata")
func sortsBySessionActivityAndAcceptsWorkspacePathMetadata() throws {
let fixture = try makeFixture()
defer { try? FileManager.default.removeItem(at: fixture.tempDir) }
try writeSession(
in: fixture.sessionsRoot,
id: "metadata-newer",
title: "Metadata changed",
cwd: "/tmp/rovo repo",
modified: Date(timeIntervalSince1970: 300),
sessionContextModified: Date(timeIntervalSince1970: 100)
)
try writeSession(
in: fixture.sessionsRoot,
id: "conversation-newer",
title: "Conversation changed",
cwd: "/tmp/rovo repo",
modified: Date(timeIntervalSince1970: 200),
sessionContextModified: Date(timeIntervalSince1970: 400),
workspaceKey: "workspacePath"
)
let result = RovoDevIndex.loadSessions(
needle: "",
cwdFilter: "/tmp/rovo repo",
offset: 0,
limit: 10,
sessionsRoot: fixture.sessionsRoot.path
)
#expect(result.errors == [])
#expect(result.sessions.map(\.sessionId) == ["conversation-newer", "metadata-newer"])
}
@Test("Matches cwd filter through symlinks")
func matchesCwdFilterThroughSymlinks() throws {
let fixture = try makeFixture()
let workspace = fixture.tempDir.appendingPathComponent("repo-real", isDirectory: true)
let workspaceLink = fixture.tempDir.appendingPathComponent("repo-link", isDirectory: true)
try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true)
try FileManager.default.createSymbolicLink(at: workspaceLink, withDestinationURL: workspace)
defer { try? FileManager.default.removeItem(at: fixture.tempDir) }
try writeSession(
in: fixture.sessionsRoot,
id: "symlink-session",
title: "Symlinked workspace",
cwd: workspaceLink.path,
modified: Date(timeIntervalSince1970: 200)
)
let result = RovoDevIndex.loadSessions(
needle: "",
cwdFilter: workspace.path,
offset: 0,
limit: 10,
sessionsRoot: fixture.sessionsRoot.path
)
#expect(result.errors == [])
#expect(result.sessions.map(\.sessionId) == ["symlink-session"])
}
@Test("Reports malformed metadata")
func reportsMalformedMetadata() throws {
let fixture = try makeFixture()
@@ -88,7 +152,7 @@ struct RovoDevIndexTests {
private func makeFixture() throws -> (tempDir: URL, sessionsRoot: URL) {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-rovodev-index-\(UUID().uuidString)", isDirectory: true)
.appendingPathComponent("cmux-agent-vault-rovodev-index-\(UUID().uuidString)", isDirectory: true)
let sessionsRoot = tempDir.appendingPathComponent("sessions", isDirectory: true)
try FileManager.default.createDirectory(at: sessionsRoot, withIntermediateDirectories: true)
return (tempDir, sessionsRoot)
@@ -99,7 +163,9 @@ struct RovoDevIndexTests {
id: String,
title: String,
cwd: String,
modified: Date
modified: Date,
sessionContextModified: Date? = nil,
workspaceKey: String = "workspace_path"
) throws {
let sessionDir = sessionsRoot.appendingPathComponent(id, isDirectory: true)
try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true)
@@ -108,7 +174,7 @@ struct RovoDevIndexTests {
let data = try JSONSerialization.data(
withJSONObject: [
"title": title,
"workspace_path": cwd,
workspaceKey: cwd,
],
options: [.sortedKeys]
)
@@ -118,7 +184,13 @@ struct RovoDevIndexTests {
ofItemAtPath: metadataURL.path
)
try Data(#"{"messages":[]}"#.utf8)
.write(to: sessionDir.appendingPathComponent("session_context.json"))
let sessionContextURL = sessionDir.appendingPathComponent("session_context.json")
try Data(#"{"messages":[]}"#.utf8).write(to: sessionContextURL)
if let sessionContextModified {
try FileManager.default.setAttributes(
[.modificationDate: sessionContextModified],
ofItemAtPath: sessionContextURL.path
)
}
}
}
@@ -21,6 +21,28 @@ public enum PasteboardTextFidelity {
(richTextHasLossySubstitution && richTextSubstitutionIsRelevant)
}
public static func shouldInspectRichTextForPlainTextLoss(_ plainText: String) -> Bool {
let metrics = textFidelityMetrics(plainText)
return metrics.replacementCharacters > 0 || metrics.questionMarks >= 2
}
public static func shouldPreferRichText(
_ richText: String,
overPlainText plainText: String
) -> Bool {
guard plainText != richText else { return false }
let plainMetrics = textFidelityMetrics(plainText)
let richMetrics = textFidelityMetrics(richText)
let plainTextHasLossySubstitution =
plainMetrics.replacementCharacters > richMetrics.replacementCharacters ||
plainMetrics.questionMarks > richMetrics.questionMarks
return plainTextHasLossySubstitution &&
richMetrics.nonASCII > plainMetrics.nonASCII
}
public static func htmlHasNoVisibleText(_ html: String) -> Bool {
var visibleCandidate = html.replacingOccurrences(
of: "<!--[\\s\\S]*?-->",
@@ -56,6 +56,31 @@ final class PasteboardTextFidelityTests: XCTestCase {
)
}
func testInspectsRichTextWhenPlainTextHasLossyMarkers() {
XCTAssertTrue(PasteboardTextFidelity.shouldInspectRichTextForPlainTextLoss("??~"))
XCTAssertTrue(PasteboardTextFidelity.shouldInspectRichTextForPlainTextLoss("\u{FFFD}~"))
XCTAssertFalse(PasteboardTextFidelity.shouldInspectRichTextForPlainTextLoss("您好~"))
XCTAssertFalse(PasteboardTextFidelity.shouldInspectRichTextForPlainTextLoss("Is this right?"))
}
func testPrefersRichTextWhenPlainTextReplacesNonASCIIWithQuestionMarks() {
XCTAssertTrue(
PasteboardTextFidelity.shouldPreferRichText(
"您好~",
overPlainText: "??~"
)
)
}
func testDoesNotPreferRichTextWhenQuestionMarkIsPreservedContent() {
XCTAssertFalse(
PasteboardTextFidelity.shouldPreferRichText(
"what? 您好",
overPlainText: "what?"
)
)
}
func testHTMLWithOnlyHiddenBlocksHasNoVisibleText() {
let html = """
<!-- comment -->
-24
View File
@@ -1,24 +0,0 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "CMUXRovoDevIndex",
platforms: [
.macOS(.v13),
],
products: [
.library(
name: "CMUXRovoDevIndex",
targets: ["CMUXRovoDevIndex"]
),
],
targets: [
.target(
name: "CMUXRovoDevIndex"
),
.testTarget(
name: "CMUXRovoDevIndexTests",
dependencies: ["CMUXRovoDevIndex"]
),
]
)
@@ -7,9 +7,12 @@ import Foundation
public enum WorkstreamSource: String, Codable, Sendable, CaseIterable, Equatable {
case claude
case codex
case pi
case amp
case cursor
case opencode
case gemini
case hermesAgent = "hermes-agent"
case copilot
case codebuddy
case factory
+5 -4
View File
@@ -157,8 +157,8 @@ For more info on how to configure cmux, [head over to our docs](https://cmux.com
| ⌘ ⇧ R | Rename workspace |
| ⌥ ⌘ E | Edit workspace description |
| ⌘ B | Toggle sidebar |
| ⌘ ⇧ E | Focus right sidebar |
| ⌃ 1 / ⌃ 2 / ⌃ 3 | Switch Files / Sessions / Feed when the right sidebar is focused |
| ⌥ ⌘ B | Toggle right sidebar |
| ⌘ ⇧ E | Toggle right sidebar focus |
### Surfaces
@@ -209,8 +209,9 @@ Command palette navigation shortcuts, including ⌃ P, are also customizable and
| Shortcut | Action |
|----------|--------|
| ⌘ F | Find |
| ⌘ G / ⌘G | Find next / previous |
| ⌘ ⇧ F | Hide find bar |
| ⌘ ⇧ F | Find in directory |
| ⌘ G / ⌥ ⌘ G | Find next / previous |
| ⌥ ⌘ ⇧ F | Hide find bar |
| ⌘ E | Use selection for find |
### Terminal
File diff suppressed because it is too large Load Diff
+130 -18
View File
@@ -85,16 +85,58 @@ case "${CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV:-}" in
esac
should_preserve_claude_auth_selection_key() {
[[ "$PRESERVE_CLAUDE_AUTH_SELECTION_ENV" == true ]] || return 1
if [[ "$PRESERVE_CLAUDE_AUTH_SELECTION_ENV" == true ]]; then
local configured_keys="${CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS:-}"
if [[ -z "$configured_keys" ]]; then
return 0
fi
configured_keys="${configured_keys//,/ }"
local configured_key
for configured_key in $configured_keys; do
[[ "$configured_key" == "$1" ]] && return 0
done
# Key not in the explicit list: do NOT early-return — fall through to
# the Vertex/Bedrock auto-preserve below. CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS
# is an additive allow-list, not an exclusion list, so an operator
# who opts in with a narrower key list still gets Vertex/Bedrock
# selection signals preserved when those backends are active.
fi
local configured_keys="${CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS:-}"
[[ -n "$configured_keys" ]] || return 0
# Auto-preserve Vertex/Bedrock auth selection so users with valid
# CLAUDE_CODE_USE_VERTEX=1 / CLAUDE_CODE_USE_BEDROCK=1 setups in their
# shell rc authenticate inside cmux the same way they do in plain
# Terminal. Without this, the wrapper silently drops the selection
# signal and claude reports "not logged in".
# Refs: https://github.com/manaflow-ai/cmux/issues/3641 (Vertex)
# https://github.com/manaflow-ai/cmux/issues/3638 (Bedrock)
local vertex_on=false bedrock_on=false
case "${CLAUDE_CODE_USE_VERTEX:-}" in
1|true|TRUE|yes|YES) vertex_on=true ;;
esac
case "${CLAUDE_CODE_USE_BEDROCK:-}" in
1|true|TRUE|yes|YES) bedrock_on=true ;;
esac
case "$1" in
CLAUDE_CODE_USE_VERTEX)
[[ "$vertex_on" == true ]] && return 0
;;
CLAUDE_CODE_USE_BEDROCK)
[[ "$bedrock_on" == true ]] && return 0
;;
ANTHROPIC_MODEL|ANTHROPIC_SMALL_FAST_MODEL)
# Vertex and Bedrock require backend-specific full model ids
# (e.g. "claude-sonnet-4-5@20250929" for Vertex,
# "us.anthropic.claude-sonnet-4-5-20250929-v1:0" for Bedrock).
# Preserve the user's selection only when one of those
# backends is active; otherwise stale ids would leak into the
# default Anthropic API path.
if [[ "$vertex_on" == true || "$bedrock_on" == true ]]; then
return 0
fi
;;
esac
configured_keys="${configured_keys//,/ }"
local configured_key
for configured_key in $configured_keys; do
[[ "$configured_key" == "$1" ]] && return 0
done
return 1
}
@@ -139,6 +181,17 @@ if [[ -n "$CMUX_SURFACE_ID" ]]; then
IN_CMUX=1
fi
exec_real_claude_passthrough() {
if [[ "$IN_CMUX" == "1" ]]; then
local cmux_key
for cmux_key in "${!CMUX_@}"; do
unset "$cmux_key"
done
unset TERMINFO
fi
exec "$REAL_CLAUDE" "$@"
}
if [[ "$IN_CMUX" == "0" || "$CMUX_CLAUDE_HOOKS_DISABLED" == "1" ]] || ! cmux_socket_available; then
# In cmux-launched shells, preserve old behavior and always clear nested
# Claude session markers, even when we must pass through due to stale socket.
@@ -147,7 +200,7 @@ if [[ "$IN_CMUX" == "0" || "$CMUX_CLAUDE_HOOKS_DISABLED" == "1" ]] || ! cmux_soc
fi
clear_inherited_claude_auth_selection_env
REAL_CLAUDE="$(find_real_claude)" || { echo "Error: claude not found in PATH" >&2; exit 127; }
exec "$REAL_CLAUDE" "$@"
exec_real_claude_passthrough "$@"
fi
# Find real claude.
@@ -261,13 +314,70 @@ encode_launch_argv() {
} | base64 | tr -d '\n'
}
# Pass through subcommands that don't support session/hook flags.
case "${1:-}" in
mcp|config|api-key|rc|remote-control)
clear_inherited_claude_auth_selection_env
exec "$REAL_CLAUDE" "$@"
;;
esac
claude_option_consumes_value() {
case "$1" in
--add-dir|--agent|--agents|--allowedTools|--allowed-tools|\
--append-system-prompt|--betas|--debug-file|--disallowedTools|\
--disallowed-tools|--effort|--fallback-model|--file|\
--input-format|--json-schema|--max-budget-usd|--mcp-config|\
--model|-m|-n|--name|--output-format|--permission-mode|--plugin-dir|\
--plugin-url|--remote-control-session-name-prefix|--setting-sources|\
--settings|--system-prompt|--tools)
return 0
;;
esac
return 1
}
claude_interactive_entry_flag() {
case "$1" in
--print|--print=*|-p|--resume|--resume=*|-r|--continue|-c|\
--session-id|--session-id=*|--remote-control|--remote-control=*|\
--from-pr|--from-pr=*|--worktree|--worktree=*|-w|-w=*)
return 0
;;
esac
return 1
}
should_inject_claude_hooks() {
(( $# == 0 )) && return 0
local arg
local skip_next=false
for arg in "$@"; do
if [[ "$skip_next" == true ]]; then
skip_next=false
continue
fi
case "$arg" in
--)
return 0
;;
-*)
if claude_interactive_entry_flag "$arg"; then
return 0
fi
if [[ "$arg" != *=* ]] && claude_option_consumes_value "$arg"; then
skip_next=true
fi
continue
;;
*)
return 1
;;
esac
done
return 0
}
# Only inject hooks for Claude session entrypoints. Other command-like
# invocations pass through so new Claude subcommands do not need cmux changes.
if ! should_inject_claude_hooks "$@"; then
clear_inherited_claude_auth_selection_env
exec_real_claude_passthrough "$@"
fi
# Unset CLAUDECODE to avoid "nested session" detection — cmux terminals are
# independent sessions even when the parent shell was launched from Claude Code.
@@ -312,12 +422,14 @@ fi
# - SessionStart/Stop/Notification: existing lifecycle hooks
# - SessionEnd: cleanup when Claude exits (covers Ctrl+C where Stop doesn't fire)
# - UserPromptSubmit: clears "Needs input" and sets "Running" on new prompt
# - PreToolUse: clears "Needs input" and captures AskUserQuestion text for notifications (async status only)
# - PreToolUse: rejects unsupported durable cron requests synchronously, then
# clears "Needs input" and captures AskUserQuestion text for notifications
# (async status only)
# - PermissionRequest: cmux hooks feed. SYNC with a 125s timeout.
# This is Claude Code's native blocking decision hook. It covers
# permissions, ExitPlanMode, and AskUserQuestion without using
# PreToolUse denial as a side channel.
HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}'
HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"CronCreate","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude cron-create-guard","timeout":5}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}'
if [[ "$SKIP_SESSION_ID" == true ]]; then
exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@"
+96
View File
@@ -1,4 +1,71 @@
import AppKit
import SwiftUI
final class MainWindowHostingView<Content: View>: NSHostingView<Content> {
private let zeroSafeAreaLayoutGuide = NSLayoutGuide()
override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero }
override var safeAreaRect: NSRect { bounds }
override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide }
required init(rootView: Content) {
super.init(rootView: rootView)
addLayoutGuide(zeroSafeAreaLayoutGuide)
NSLayoutConstraint.activate([
zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor),
zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor),
zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor),
zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
deinit {}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class FirstMouseGatedHostingView<Content: View>: NSHostingView<Content> {
override var intrinsicContentSize: NSSize {
NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric)
}
override func hitTest(_ point: NSPoint) -> NSView? {
if shouldCaptureInactiveFirstMouse(at: point) {
return self
}
return super.hitTest(point)
}
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
PaneFirstClickFocusSettings.isEnabled()
}
func shouldCaptureInactiveFirstMouse(at point: NSPoint) -> Bool {
let localPoint = superview.map { convert(point, from: $0) } ?? point
return window?.isKeyWindow != true &&
!PaneFirstClickFocusSettings.isEnabled() &&
bounds.contains(localPoint)
}
}
final class FirstMouseGatedPassThroughHostingView<Content: View>: FirstMouseGatedHostingView<Content> {
override func hitTest(_ point: NSPoint) -> NSView? {
shouldCaptureInactiveFirstMouse(at: point) ? self : nil
}
}
struct FirstMouseGatedHostingOverlay: NSViewRepresentable {
func makeNSView(context: Context) -> FirstMouseGatedPassThroughHostingView<AnyView> {
FirstMouseGatedPassThroughHostingView(rootView: AnyView(EmptyView()))
}
func updateNSView(_ nsView: FirstMouseGatedPassThroughHostingView<AnyView>, context: Context) {
nsView.rootView = AnyView(EmptyView())
}
}
@MainActor
final class CmuxMainWindow: NSWindow {
@@ -31,3 +98,32 @@ final class CmuxMainWindow: NSWindow {
super.flagsChanged(with: event)
}
}
extension CmuxMainWindow {
private static let defaultContentSize = NSSize(width: 1_000, height: 700)
/// Returns an unpositioned content rect clamped to the visible display; callers own final placement.
static func defaultContentRect(styleMask: NSWindow.StyleMask) -> NSRect {
let unpositionedContentRect = NSRect(origin: .zero, size: defaultContentSize)
guard let visibleFrame = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame else {
return unpositionedContentRect
}
let frameRect = NSWindow.frameRect(forContentRect: unpositionedContentRect, styleMask: styleMask)
let clampedFrameRect = clampedFrame(frameRect, within: visibleFrame)
return NSWindow.contentRect(forFrameRect: clampedFrameRect, styleMask: styleMask)
}
private static func clampedFrame(_ frame: NSRect, within visibleFrame: NSRect) -> NSRect {
guard visibleFrame.width > 0, visibleFrame.height > 0 else { return frame }
let width = min(max(frame.width, defaultContentSize.width), visibleFrame.width)
let height = min(max(frame.height, defaultContentSize.height), visibleFrame.height)
return NSRect(
x: min(max(frame.minX, visibleFrame.minX), visibleFrame.maxX - width),
y: min(max(frame.minY, visibleFrame.minY), visibleFrame.maxY - height),
width: width,
height: height
)
}
}
@@ -0,0 +1,18 @@
@MainActor
enum GhosttySurfaceConfigurationRefresh {
nonisolated static let forceRefreshReason = "appDelegate.refreshAfterGhosttyConfigReload"
static func applyAfterAppConfigReload(
to surface: ghostty_surface_t?,
source: String,
reloadSurfaceConfiguration: (ghostty_surface_t, Bool, String) -> Void,
refreshHostBackground: () -> Void,
forceRefresh: (String) -> Void
) {
if let surface {
reloadSurfaceConfiguration(surface, true, source)
}
refreshHostBackground()
forceRefresh(forceRefreshReason)
}
}
@@ -153,15 +153,17 @@ final class MainWindowVisibilityController {
dependencies.unhideApplication()
trace("focus.unhide.end", reason: reason, windows: [window])
}
let effectiveActivation = activationRequiringKeyTransfer(activation, makeKey: makeKey)
if activationTiming == .beforeWindowOrdering {
activate(activation)
activate(effectiveActivation)
}
let shouldActivateBeforeWindowOrdering = activationTiming == .afterWindowOrdering &&
deminiaturize &&
dependencies.windowOperations.isMiniaturized(window)
if shouldActivateBeforeWindowOrdering {
trace("focus.activate.beforeMiniaturize.begin", reason: reason, windows: [window])
activate(activation)
activate(effectiveActivation)
trace("focus.activate.beforeMiniaturize.end", reason: reason, windows: [window])
}
if deminiaturize, dependencies.windowOperations.isMiniaturized(window) {
@@ -181,7 +183,7 @@ final class MainWindowVisibilityController {
}
if activationTiming == .afterWindowOrdering && !shouldActivateBeforeWindowOrdering {
trace("focus.activate.begin", reason: reason, windows: [window])
activate(activation)
activate(effectiveActivation)
trace("focus.activate.end", reason: reason, windows: [window])
}
log("focus", reason: reason, windows: [window])
@@ -369,7 +371,9 @@ final class MainWindowVisibilityController {
activation: Activation = .runningApplication([.activateAllWindows]),
makeKey: Bool = true
) -> NSWindow? {
let windows = uniqueWindows(windows)
let windows = uniqueWindows(windows).filter { window in
makeKey || !dependencies.windowOperations.isMiniaturized(window)
}
guard !windows.isEmpty else {
log("reveal.empty", reason: reason, windows: [])
return nil
@@ -383,8 +387,9 @@ final class MainWindowVisibilityController {
for window in windows {
dependencies.windowOperations.softShow(window)
}
let effectiveActivation = activationRequiringKeyTransfer(activation, makeKey: makeKey)
trace("reveal.activate.begin", reason: reason, windows: windows)
activate(activation)
activate(effectiveActivation)
trace("reveal.activate.end", reason: reason, windows: windows)
let focusWindow = resolvedPreferredFocusWindow(preferredWindow: preferredWindow, in: windows)
@@ -459,6 +464,10 @@ final class MainWindowVisibilityController {
}
}
private func activationRequiringKeyTransfer(_ activation: Activation, makeKey: Bool) -> Activation {
makeKey ? activation : .none
}
private func uniqueWindows(_ windows: [NSWindow]) -> [NSWindow] {
var result: [NSWindow] = []
for window in windows where !result.contains(where: { $0 === window }) {
@@ -7,8 +7,7 @@ enum SessionSnapshotDebugBenchmark {
includeScrollback: Bool,
persist: Bool,
buildSnapshot: (Bool) -> AppSessionSnapshot?,
persistedGeometryData: (AppSessionSnapshot?) -> Data?,
persistSnapshot: (AppSessionSnapshot?, Data?) -> Void
persistSnapshot: (AppSessionSnapshot?) -> Void
) -> [String: Any] {
let buildStart = ProcessInfo.processInfo.systemUptime
let snapshot = buildSnapshot(includeScrollback)
@@ -16,9 +15,8 @@ enum SessionSnapshotDebugBenchmark {
var persistMs: Double?
if persist {
let geometryData = persistedGeometryData(snapshot)
let persistStart = ProcessInfo.processInfo.systemUptime
persistSnapshot(snapshot, geometryData)
persistSnapshot(snapshot)
persistMs = elapsedMs(since: persistStart)
}
+241
View File
@@ -0,0 +1,241 @@
import AppKit
@MainActor
enum SettingsWindowPresenter {
static let windowID = "settings"
static let windowIdentifier = "cmux.settings"
static let minimumSize = NSSize(width: 820, height: 540)
private static let visibleAreaInset: CGFloat = 18
private static var openWindow: (@MainActor () -> Void)?
private static var parentWindowProvider: (@MainActor () -> NSWindow?)?
private static weak var settingsWindow: NSWindow?
private static weak var observedParentWindow: NSWindow?
private static weak var observedSettingsWindow: NSWindow?
private static var parentCloseObserver: NSObjectProtocol?
private static var pendingNavigationTarget: SettingsNavigationTarget?
private static var pendingContentNavigationTarget: SettingsNavigationTarget?
private static var shouldOpenWhenConfigured = false
static func configure(
openWindow: @escaping @MainActor () -> Void,
parentWindowProvider: @escaping @MainActor () -> NSWindow? = { nil }
) {
self.openWindow = openWindow
self.parentWindowProvider = parentWindowProvider
if let settingsWindow {
attachToPreferredParent(settingsWindow)
}
if shouldOpenWhenConfigured {
shouldOpenWhenConfigured = false
openWindow()
}
}
static func configure(window: NSWindow) {
settingsWindow = window
window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier)
window.isRestorable = false
window.minSize = minimumSize
window.contentMinSize = minimumSize
clampToVisibleAreaIfNeeded(window)
attachToPreferredParent(window)
Task { @MainActor in
guard settingsWindow === window else { return }
focus(window)
}
}
static func show(
navigationTarget: SettingsNavigationTarget? = nil,
openWindowOverride: (@MainActor () -> Void)? = nil
) {
#if DEBUG
cmuxDebugLog("settings.window.show path=swiftuiWindow")
_ = CmuxUITestCapture.mutateJSONObjectIfConfigured(
envKey: "CMUX_UI_TEST_SETTINGS_OPEN_CAPTURE_PATH"
) { payload in
payload["opened"] = true
payload["target"] = navigationTarget?.rawValue ?? ""
payload["used_open_window_override"] = openWindowOverride != nil
}
#endif
pendingNavigationTarget = navigationTarget
pendingContentNavigationTarget = navigationTarget
if let window = existingWindow() {
pendingNavigationTarget = nil
pendingContentNavigationTarget = nil
focus(window)
if let navigationTarget {
SettingsNavigationRequest.post(navigationTarget)
}
return
}
if let openWindowOverride {
openWindowOverride()
return
}
guard let openWindow else {
shouldOpenWhenConfigured = true
return
}
openWindow()
}
static func consumePendingNavigationTarget() -> SettingsNavigationTarget? {
let target = pendingNavigationTarget
pendingNavigationTarget = nil
return target
}
static func consumePendingContentNavigationTarget() -> SettingsNavigationTarget? {
let target = pendingContentNavigationTarget
pendingContentNavigationTarget = nil
return target
}
static func refocusIfVisible() {
guard let window = existingWindow() else { return }
focus(window)
}
#if DEBUG
static func resetForTests() {
if let settingsWindow {
detachFromCurrentParent(settingsWindow)
} else {
removeParentCloseObserver()
}
openWindow = nil
parentWindowProvider = nil
settingsWindow = nil
pendingNavigationTarget = nil
pendingContentNavigationTarget = nil
shouldOpenWhenConfigured = false
}
#endif
private static func existingWindow() -> NSWindow? {
if let settingsWindow, settingsWindow.isVisible || settingsWindow.isMiniaturized {
return settingsWindow
}
return NSApp.windows.first {
$0.identifier?.rawValue == windowIdentifier && ($0.isVisible || $0.isMiniaturized)
}
}
private static func focus(_ window: NSWindow) {
if window.isMiniaturized {
window.deminiaturize(nil)
}
clampToVisibleAreaIfNeeded(window)
if let parentWindow = attachToPreferredParent(window) {
orderParentBehindSettings(parentWindow)
}
NSRunningApplication.current.activate(options: [.activateAllWindows])
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
}
@discardableResult
private static func attachToPreferredParent(_ window: NSWindow) -> NSWindow? {
guard let parentWindow = parentWindowProvider?(),
parentWindow !== window else {
detachFromCurrentParent(window)
return nil
}
if window.parent !== parentWindow {
detachFromCurrentParent(window)
parentWindow.addChildWindow(window, ordered: .above)
}
observeParentWillClose(parentWindow, settingsWindow: window)
return parentWindow
}
private static func detachFromCurrentParent(_ window: NSWindow) {
removeParentCloseObserver()
guard let parentWindow = window.parent else { return }
parentWindow.removeChildWindow(window)
}
private static func observeParentWillClose(_ parentWindow: NSWindow, settingsWindow: NSWindow) {
guard observedParentWindow !== parentWindow || observedSettingsWindow !== settingsWindow else {
return
}
removeParentCloseObserver()
observedParentWindow = parentWindow
observedSettingsWindow = settingsWindow
// Run synchronously for normal AppKit window-close notifications so
// Settings detaches before AppKit orders out child windows.
parentCloseObserver = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: parentWindow,
queue: nil
) { [weak parentWindow, weak settingsWindow] _ in
guard Thread.isMainThread else {
assertionFailure("NSWindow.willCloseNotification should be delivered on the main thread")
return
}
MainActor.assumeIsolated {
detachFromClosingParent(parentWindow: parentWindow, settingsWindow: settingsWindow)
}
}
}
private static func detachFromClosingParent(parentWindow: NSWindow?, settingsWindow: NSWindow?) {
guard let settingsWindow, settingsWindow.parent === parentWindow else {
removeParentCloseObserver()
return
}
detachFromCurrentParent(settingsWindow)
}
private static func removeParentCloseObserver() {
if let parentCloseObserver {
NotificationCenter.default.removeObserver(parentCloseObserver)
}
parentCloseObserver = nil
observedParentWindow = nil
observedSettingsWindow = nil
}
private static func orderParentBehindSettings(_ window: NSWindow) {
if window.isMiniaturized {
window.deminiaturize(nil)
}
window.orderFront(nil)
}
private static func clampToVisibleAreaIfNeeded(_ window: NSWindow) {
guard let screen = window.screen ?? NSScreen.main else { return }
var frame = window.frame
let originalFrame = frame
let visibleFrame = screen.visibleFrame
let minimumFrameSize = NSSize(
width: max(window.minSize.width, window.contentMinSize.width),
height: max(window.minSize.height, window.contentMinSize.height)
)
let maxVisibleSize = NSSize(
width: max(minimumFrameSize.width, visibleFrame.width - 2 * visibleAreaInset),
height: max(minimumFrameSize.height, visibleFrame.height - 2 * visibleAreaInset)
)
frame.size.width = min(frame.size.width, maxVisibleSize.width)
frame.size.height = min(frame.size.height, maxVisibleSize.height)
let minX = visibleFrame.minX + visibleAreaInset
let minY = visibleFrame.minY + visibleAreaInset
let maxX = max(minX, visibleFrame.maxX - visibleAreaInset - frame.width)
let maxY = max(minY, visibleFrame.maxY - visibleAreaInset - frame.height)
frame.origin = NSPoint(
x: min(max(frame.origin.x, minX), maxX),
y: min(max(frame.origin.y, minY), maxY)
)
guard frame != originalFrame else { return }
window.setFrame(frame, display: true)
}
}
+46 -50
View File
@@ -58,6 +58,10 @@ func browserResponderHasMarkedText(_ responder: NSResponder?) -> Bool {
return false
}
func isBrowserReturnOrEnterKeyCode(_ keyCode: UInt16) -> Bool {
keyCode == 36 || keyCode == 76
}
func shouldDispatchBrowserReturnViaFirstResponderKeyDown(
keyCode: UInt16,
firstResponderIsBrowser: Bool,
@@ -66,7 +70,7 @@ func shouldDispatchBrowserReturnViaFirstResponderKeyDown(
) -> Bool {
guard firstResponderIsBrowser else { return false }
guard !firstResponderHasMarkedText else { return false }
guard keyCode == 36 || keyCode == 76 else { return false }
guard isBrowserReturnOrEnterKeyCode(keyCode) else { return false }
// Keep browser Return forwarding narrow: only plain/Shift Return should be
// treated as submit-intent. Command-modified Return is reserved for app shortcuts
// like Toggle Pane Zoom (Cmd+Shift+Enter).
@@ -81,10 +85,10 @@ func shouldDispatchBrowserArrowViaFirstResponderKeyDown(
) -> Bool {
guard firstResponderIsBrowser else { return false }
guard !firstResponderHasMarkedText else { return false }
guard keyCode == 125 || keyCode == 126 else { return false }
guard (123...126).contains(keyCode) else { return false }
// Keep this narrow to avoid stealing app/browser shortcuts that layer onto
// modified arrow keys. Plain up/down should always flow through keyDown so
// modified arrow keys. Plain arrows should always flow through keyDown so
// web content such as Google Docs receives the event directly.
let normalizedFlags = flags
.intersection(.deviceIndependentFlagsMask)
@@ -92,6 +96,27 @@ func shouldDispatchBrowserArrowViaFirstResponderKeyDown(
return normalizedFlags.isEmpty
}
func shouldDispatchCommandPaletteHorizontalArrowViaFirstResponderKeyDown(
keyCode: UInt16,
firstResponderIsCommandPaletteFieldEditor: Bool,
firstResponderHasMarkedText: Bool = false,
flags: NSEvent.ModifierFlags
) -> Bool {
guard firstResponderIsCommandPaletteFieldEditor else { return false }
guard !firstResponderHasMarkedText else { return false }
guard keyCode == 123 || keyCode == 124 else { return false }
let normalizedFlags = flags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
switch normalizedFlags {
case [], [.shift], [.option], [.option, .shift], [.command], [.command, .shift]:
return true
default:
return false
}
}
func shouldToggleMainWindowFullScreenForCommandControlFShortcut(
flags: NSEvent.ModifierFlags,
chars: String,
@@ -384,7 +409,7 @@ func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool {
return true
}
private enum BrowserFindCommandEquivalent {
private enum BrowserFindCommandEquivalent: CaseIterable {
case find
case findInDirectory
case findNext
@@ -392,6 +417,17 @@ private enum BrowserFindCommandEquivalent {
case hideFind
case useSelection
var action: KeyboardShortcutSettings.Action {
switch self {
case .find: return .find
case .findInDirectory: return .findInDirectory
case .findNext: return .findNext
case .findPrevious: return .findPrevious
case .hideFind: return .hideFind
case .useSelection: return .useSelectionForFind
}
}
var keepsCmuxBrowserFindBarOwnershipWhenVisible: Bool {
switch self {
case .find, .findNext, .findPrevious, .hideFind:
@@ -421,52 +457,12 @@ func cmuxIsLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool {
return false
}
private func browserFindCommandEquivalent(for event: NSEvent) -> BrowserFindCommandEquivalent? {
let flags = event.modifierFlags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
let normalizedChars = KeyboardLayout.normalizedCharacters(for: event).lowercased()
let hasSingleASCIIShortcutChar =
normalizedChars.count == 1 && normalizedChars.allSatisfy(\.isASCII)
let producedAnyASCIIShortcutChar = normalizedChars.contains(where: \.isASCII)
func matches(_ chars: String, keyCode: UInt16) -> Bool {
if hasSingleASCIIShortcutChar {
return normalizedChars == chars
}
if !producedAnyASCIIShortcutChar {
return event.keyCode == keyCode
}
return false
}
switch flags {
case [.command]:
if matches("e", keyCode: 14) { // kVK_ANSI_E
return .useSelection
}
if matches("f", keyCode: 3) { // kVK_ANSI_F
return .find
}
if matches("g", keyCode: 5) { // kVK_ANSI_G
return .findNext
}
return nil
case [.command, .shift]:
if matches("f", keyCode: 3) { // kVK_ANSI_F
return .findInDirectory
}
if matches("g", keyCode: 5) { // kVK_ANSI_G
return .findPrevious
}
return nil
case [.command, .option, .shift]:
if matches("f", keyCode: 3) { // kVK_ANSI_F
return .hideFind
}
return nil
default:
return nil
private func browserFindCommandEquivalent(
for event: NSEvent,
shortcutForAction: (KeyboardShortcutSettings.Action) -> StoredShortcut = KeyboardShortcutSettings.shortcut(for:)
) -> BrowserFindCommandEquivalent? {
BrowserFindCommandEquivalent.allCases.first { command in
shortcutForAction(command.action).matches(event: event)
}
}
+43 -7
View File
@@ -109,18 +109,54 @@ enum TerminalScrollBarSettings {
}
}
enum AgentSessionAutoResumeSettings {
static let autoResumeAgentSessionsKey = "terminal.autoResumeAgentSessions"
static let defaultAutoResumeAgentSessions = true
static let didChangeNotification = Notification.Name("cmux.agentSessionAutoResumeSettingsDidChange")
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
guard defaults.object(forKey: autoResumeAgentSessionsKey) != nil else {
return defaultAutoResumeAgentSessions
}
return defaults.bool(forKey: autoResumeAgentSessionsKey)
}
static func setEnabled(
_ enabled: Bool,
defaults: UserDefaults = .standard,
notificationCenter: NotificationCenter = .default
) {
let wasEnabled = isEnabled(defaults: defaults)
defaults.set(enabled, forKey: autoResumeAgentSessionsKey)
if wasEnabled != enabled {
notifyDidChange(notificationCenter: notificationCenter)
}
}
@discardableResult
static func reset(
defaults: UserDefaults = .standard,
notificationCenter: NotificationCenter = .default
) -> Bool {
let wasEnabled = isEnabled(defaults: defaults)
defaults.removeObject(forKey: autoResumeAgentSessionsKey)
let didChange = wasEnabled != isEnabled(defaults: defaults)
if didChange {
notifyDidChange(notificationCenter: notificationCenter)
}
return didChange
}
static func notifyDidChange(notificationCenter: NotificationCenter = .default) {
notificationCenter.post(name: didChangeNotification, object: nil)
}
}
enum RightSidebarBetaFeatureSettings {
static let feedEnabledKey = "rightSidebar.beta.feed.enabled"
static let dockEnabledKey = "rightSidebar.beta.dock.enabled"
static let defaultFeedEnabled = false
static let defaultDockEnabled = false
nonisolated static func isFeedEnabled(defaults: UserDefaults = .standard) -> Bool {
guard defaults.object(forKey: feedEnabledKey) != nil else { return defaultFeedEnabled }
return defaults.bool(forKey: feedEnabledKey)
}
nonisolated static func isDockEnabled(defaults: UserDefaults = .standard) -> Bool {
guard defaults.object(forKey: dockEnabledKey) != nil else { return defaultDockEnabled }
return defaults.bool(forKey: dockEnabledKey)
+432
View File
@@ -0,0 +1,432 @@
import AppKit
import Foundation
@MainActor
final class CmuxSSHURLProcessLauncher {
static let shared = CmuxSSHURLProcessLauncher()
private var processes: [Int32: Process] = [:]
private var isShuttingDown = false
private init() {}
func terminateAll() {
isShuttingDown = true
for process in processes.values where process.isRunning {
process.terminate()
}
processes.removeAll()
}
@discardableResult
func start(request: CmuxSSHURLRequest, preferredWindow: NSWindow?) -> Bool {
let cliURL = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux")
guard let cliURL,
FileManager.default.isExecutableFile(atPath: cliURL.path) else {
presentLaunchFailure(
summary: String(
localized: "dialog.sshURL.launchFailed.missingCLI",
defaultValue: "The bundled cmux CLI is missing from this app build."
),
output: "",
preferredWindow: preferredWindow
)
return false
}
let socketPath = resolvedSocketPath()
let process = Process()
process.executableURL = cliURL
process.arguments = ["--socket", socketPath] + request.cliArguments
var environment = ProcessInfo.processInfo.environment
environment["CMUX_SOCKET_PATH"] = socketPath
environment["CMUX_BUNDLED_CLI_PATH"] = cliURL.path
environment.removeValue(forKey: "CMUX_SOCKET")
process.environment = environment
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
let outputCollector = ProcessOutputCollector(stdout: outputPipe, stderr: errorPipe)
outputCollector.start()
process.terminationHandler = { [weak preferredWindow] terminatedProcess in
let output = outputCollector.finish()
let processIdentifier = terminatedProcess.processIdentifier
let terminationStatus = terminatedProcess.terminationStatus
Task { @MainActor in
Self.shared.processes.removeValue(forKey: processIdentifier)
guard terminationStatus != 0, !Self.shared.isShuttingDown else { return }
let format = String(
localized: "dialog.sshURL.launchFailed.exit",
defaultValue: "cmux ssh exited with status %d."
)
Self.shared.presentLaunchFailure(
summary: String(format: format, Int(terminationStatus)),
output: output,
preferredWindow: preferredWindow
)
}
}
do {
try process.run()
processes[process.processIdentifier] = process
#if DEBUG
cmuxDebugLog("sshURL.launchCLI pid=\(process.processIdentifier) socket=\(socketPath) targetLength=\(request.destination.count)")
#endif
return true
} catch {
outputCollector.cancel()
presentLaunchFailure(
summary: String(
localized: "dialog.sshURL.launchFailed.launch",
defaultValue: "cmux ssh could not be launched."
),
output: error.localizedDescription,
preferredWindow: preferredWindow
)
return false
}
}
func resolvedSocketPath() -> String {
TerminalController.shared.activeSocketPath(
preferredPath: SocketControlSettings.socketPath()
)
}
private func presentLaunchFailure(summary: String, output: String, preferredWindow: NSWindow?) {
let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines)
let limitedOutput = String(trimmedOutput.prefix(2000))
let informativeText = limitedOutput.isEmpty
? summary
: "\(summary)\n\n\(limitedOutput)"
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = String(
localized: "dialog.sshURL.launchFailed.title",
defaultValue: "Couldn't Open SSH Link"
)
alert.informativeText = informativeText
alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
if let preferredWindow {
alert.beginSheetModal(for: preferredWindow, completionHandler: nil)
} else if let window = NSApp.keyWindow ?? NSApp.mainWindow {
alert.beginSheetModal(for: window, completionHandler: nil)
} else {
alert.runModal()
}
}
}
@MainActor
private final class CmuxSSHURLConfirmationGate: NSObject {
weak var connectButton: NSButton?
deinit {}
@objc func checkboxChanged(_ sender: NSButton) {
connectButton?.isEnabled = sender.state == .on
}
}
extension AppDelegate {
func deferInitialMainWindowBootstrapForExternalConfirmation() {
guard !didAttemptStartupSessionRestore, !didHandleExplicitOpenIntentAtStartup else { return }
shouldDeferInitialMainWindowBootstrapForExternalConfirmation = true
}
func resumeInitialMainWindowBootstrapAfterExternalConfirmation(debugSource: String) {
guard shouldDeferInitialMainWindowBootstrapForExternalConfirmation else { return }
shouldDeferInitialMainWindowBootstrapForExternalConfirmation = false
scheduleInitialMainWindowBootstrap(debugSource: debugSource)
}
func bootstrapInitialMainWindowAfterAcceptedExternalOpen(debugSource: String) {
shouldDeferInitialMainWindowBootstrapForExternalConfirmation = false
_ = bootstrapInitialMainWindowIfNeeded(debugSource: debugSource)
}
func claimAuthCallbackURLSchemes() {
// Pin the current build's callback scheme so auth and SSH deeplinks
// route back to this app instead of an unrelated LaunchServices entry.
let bundleURL = Bundle.main.bundleURL
NSWorkspace.shared.setDefaultApplication(
at: bundleURL,
toOpenURLsWithScheme: AuthEnvironment.callbackScheme
) { _ in }
}
@discardableResult
func handleCmuxSSHURLs(from urls: [URL]) -> Bool {
var sshURLRequests: [CmuxSSHURLRequest] = []
var sshURLParseErrors: [CmuxSSHURLParseError] = []
for url in urls {
switch CmuxSSHURLRequest.parse(url) {
case .success(.some(let request)):
sshURLRequests.append(request)
case .success(nil):
break
case .failure(let error):
sshURLParseErrors.append(error)
}
}
let sshURLIntentCount = sshURLRequests.count + sshURLParseErrors.count
guard sshURLIntentCount > 0 else { return false }
if urls.count > 1 || sshURLIntentCount > 1 {
showCmuxSSHURLParseError(.multipleLinks)
} else {
for error in sshURLParseErrors {
showCmuxSSHURLParseError(error)
}
if let request = sshURLRequests.first {
handleCmuxSSHURLRequest(request)
}
}
return true
}
private func handleCmuxSSHURLRequest(_ request: CmuxSSHURLRequest) {
#if DEBUG
let target = request.originalURL.host ?? request.originalURL.path
cmuxDebugLog("sshURL.prompt target=\(target) destinationLength=\(request.destination.count) hasPort=\(request.port != nil)")
#endif
deferInitialMainWindowBootstrapForExternalConfirmation()
guard confirmCmuxSSHURLRequest(request) else {
resumeInitialMainWindowBootstrapAfterExternalConfirmation(debugSource: "sshURL.cancelled")
#if DEBUG
cmuxDebugLog("sshURL.cancelled")
#endif
return
}
prepareForExplicitOpenIntentAtStartup()
bootstrapInitialMainWindowAfterAcceptedExternalOpen(debugSource: "sshURL.confirmed")
NSApp.activate(ignoringOtherApps: true)
_ = CmuxSSHURLProcessLauncher.shared.start(
request: request,
preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow
)
}
private func confirmCmuxSSHURLRequest(_ request: CmuxSSHURLRequest) -> Bool {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = String(
localized: "dialog.sshURL.title",
defaultValue: "Open an SSH Connection From an External Link?"
)
let scheme = request.originalURL.scheme ?? AuthEnvironment.callbackScheme
alert.informativeText = String(
format: String(
localized: "dialog.sshURL.message",
defaultValue: "A %@:// link is asking cmux to open an SSH workspace. cmux cannot verify which website or app opened this link.\n\nSSH may use your local SSH config, keys, agent settings, ProxyCommand, LocalCommand, and forwarding rules for this target. External links cannot supply arbitrary SSH options. Only continue if you trust this SSH target."
),
scheme
)
let cancelTitle = String(localized: "dialog.sshURL.cancel", defaultValue: "Cancel")
let runTitle = String(localized: "dialog.sshURL.run", defaultValue: "Connect")
alert.addButton(withTitle: cancelTitle)
alert.addButton(withTitle: runTitle)
let cancelButton = alert.buttons[0]
cancelButton.keyEquivalent = "\r"
if alert.buttons.count > 1 {
let connectButton = alert.buttons[1]
connectButton.keyEquivalent = ""
connectButton.isEnabled = false
if #available(macOS 11.0, *) {
connectButton.hasDestructiveAction = true
}
}
let gate = CmuxSSHURLConfirmationGate()
if alert.buttons.count > 1 {
gate.connectButton = alert.buttons[1]
}
alert.accessoryView = cmuxSSHURLAccessoryView(request: request, gate: gate)
let response: NSApplication.ModalResponse = withExtendedLifetime(gate) {
alert.runModal()
}
return response == .alertSecondButtonReturn
}
private func cmuxSSHURLAccessoryView(
request: CmuxSSHURLRequest,
gate: CmuxSSHURLConfirmationGate
) -> NSView {
let stack = NSStackView()
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 8
stack.translatesAutoresizingMaskIntoConstraints = false
let targetLabel = NSTextField(labelWithString: String(
format: String(localized: "dialog.sshURL.targetLabel", defaultValue: "SSH target: %@"),
request.displayTarget
))
targetLabel.lineBreakMode = .byTruncatingMiddle
targetLabel.maximumNumberOfLines = 1
let commandLabel = NSTextField(labelWithString: String(
localized: "dialog.sshURL.commandLabel",
defaultValue: "Command preview:"
))
commandLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold)
let socketPath = CmuxSSHURLProcessLauncher.shared.resolvedSocketPath()
let commandScrollView = cmuxSSHURLTextPreview(request.cliPreview(socketPath: socketPath), height: 80)
stack.addArrangedSubview(targetLabel)
stack.addArrangedSubview(commandLabel)
stack.addArrangedSubview(commandScrollView)
let checkbox = NSButton(
checkboxWithTitle: String(
localized: "dialog.sshURL.checkbox",
defaultValue: "I trust this SSH target and want cmux to connect."
),
target: gate,
action: #selector(CmuxSSHURLConfirmationGate.checkboxChanged(_:))
)
checkbox.lineBreakMode = .byWordWrapping
stack.addArrangedSubview(checkbox)
let container = NSView(frame: NSRect(x: 0, y: 0, width: 560, height: 156))
container.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
stack.topAnchor.constraint(equalTo: container.topAnchor),
stack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
targetLabel.widthAnchor.constraint(equalTo: container.widthAnchor),
commandScrollView.widthAnchor.constraint(equalTo: container.widthAnchor),
checkbox.widthAnchor.constraint(equalTo: container.widthAnchor)
])
return container
}
private func cmuxSSHURLTextPreview(_ text: String, height: CGFloat) -> NSScrollView {
let textView = NSTextView(frame: .zero)
textView.string = text
textView.isEditable = false
textView.isSelectable = true
textView.drawsBackground = true
textView.backgroundColor = NSColor.textBackgroundColor
textView.textColor = NSColor.labelColor
textView.font = NSFont.monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
textView.textContainerInset = NSSize(width: 8, height: 8)
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.textContainer?.widthTracksTextView = true
let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 560, height: height))
scrollView.borderType = .bezelBorder
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.documentView = textView
scrollView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
scrollView.heightAnchor.constraint(equalToConstant: height)
])
return scrollView
}
private func showCmuxSSHURLParseError(_ error: CmuxSSHURLParseError) {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = String(
localized: "dialog.sshURL.blocked.title",
defaultValue: "cmux SSH Link Blocked"
)
alert.informativeText = cmuxSSHURLParseErrorMessage(error)
alert.addButton(withTitle: String(localized: "dialog.sshURL.blocked.ok", defaultValue: "OK"))
alert.runModal()
}
private func cmuxSSHURLParseErrorMessage(_ error: CmuxSSHURLParseError) -> String {
switch error {
case .missingDestination:
return String(
localized: "dialog.sshURL.error.missingDestination",
defaultValue: "The link did not include an SSH host."
)
case .destinationTooLong(let maxLength):
return String(
format: String(localized: "dialog.sshURL.error.destinationTooLong", defaultValue: "The SSH target is too long. The maximum length is %lld characters."),
maxLength
)
case .destinationContainsUnsafeCharacters:
return String(
localized: "dialog.sshURL.error.destinationContainsUnsafeCharacters",
defaultValue: "The SSH host or user contains unsupported or hidden characters, so cmux refused to use it."
)
case .destinationStartsWithDash:
return String(
localized: "dialog.sshURL.error.destinationStartsWithDash",
defaultValue: "The SSH host or user cannot start with a dash."
)
case .titleTooLong(let maxLength):
return String(
format: String(localized: "dialog.sshURL.error.titleTooLong", defaultValue: "The workspace title is too long. The maximum length is %lld characters."),
maxLength
)
case .titleContainsUnsafeCharacters:
return String(
localized: "dialog.sshURL.error.titleContainsControlCharacters",
defaultValue: "The workspace title contains hidden control or formatting characters, so cmux refused to use it."
)
case .invalidPort:
return String(
localized: "dialog.sshURL.error.invalidPort",
defaultValue: "The SSH port must be between 1 and 65535."
)
case .invalidIntegerParameter(let parameter):
return String(
format: String(localized: "dialog.sshURL.error.invalidIntegerParameter", defaultValue: "The SSH link included an invalid integer value for parameter: %@"),
parameter
)
case .invalidHostKeyPolicy(let parameter):
return String(
format: String(localized: "dialog.sshURL.error.invalidHostKeyPolicy", defaultValue: "The SSH link included an invalid host key policy for parameter: %@"),
parameter
)
case .invalidBooleanParameter(let parameter):
return String(
format: String(localized: "dialog.sshURL.error.invalidBooleanParameter", defaultValue: "The SSH link included an invalid boolean value for parameter: %@"),
parameter
)
case .conflictingDestinationParameters:
return String(
localized: "dialog.sshURL.error.conflictingDestinationParameters",
defaultValue: "The link included conflicting SSH target fields."
)
case .conflictingTitleParameters:
return String(
localized: "dialog.sshURL.error.conflictingTitleParameters",
defaultValue: "The link included both title and name. Use only one workspace title field."
)
case .duplicateParameter(let parameter):
return String(
format: String(localized: "dialog.sshURL.error.duplicateParameter", defaultValue: "The SSH link repeated a parameter: %@"),
parameter
)
case .unsupportedParameter(let parameter):
return String(
format: String(localized: "dialog.sshURL.error.unsupportedParameter", defaultValue: "The SSH link included an unsupported parameter: %@"),
parameter
)
case .multipleLinks:
return String(
localized: "dialog.sshURL.error.multipleLinks",
defaultValue: "Only one SSH link can be opened at a time."
)
}
}
}
+597 -245
View File
File diff suppressed because it is too large Load Diff
+36 -12
View File
@@ -40,6 +40,14 @@ final class CmuxDockTilePlugin: NSObject, NSDockTilePlugIn {
}
func setDockTile(_ dockTile: NSDockTile?) {
Self.performOnMain { [self] in
setDockTileOnMain(dockTile)
}
}
private func setDockTileOnMain(_ dockTile: NSDockTile?) {
Self.assertMainQueue()
if let iconChangeObserver {
DistributedNotificationCenter.default().removeObserver(iconChangeObserver)
self.iconChangeObserver = nil
@@ -53,7 +61,7 @@ final class CmuxDockTilePlugin: NSObject, NSDockTilePlugIn {
iconChangeObserver = DistributedNotificationCenter.default().addObserver(
forName: cmuxAppIconDidChangeNotification,
object: nil,
queue: nil
queue: .main
) { [weak self] _ in
guard let self else { return }
self.updateDockTile(dockTile)
@@ -91,6 +99,8 @@ final class CmuxDockTilePlugin: NSObject, NSDockTilePlugIn {
}
private func updateDockTile(_ dockTile: NSDockTile) {
Self.assertMainQueue()
let mode = DockTileAppIconMode(defaultsValue: appDefaults?.string(forKey: cmuxAppIconModeKey))
let isDarkAppearance = NSApp?.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
guard let appBundleURL else {
@@ -117,6 +127,20 @@ final class CmuxDockTilePlugin: NSObject, NSDockTilePlugIn {
dockTile.showIcon(icon)
}
private static func performOnMain(_ work: @escaping () -> Void) {
if Thread.isMainThread {
work()
} else {
DispatchQueue.main.async(execute: work)
}
}
fileprivate static func assertMainQueue() {
#if DEBUG
dispatchPrecondition(condition: .onQueue(.main))
#endif
}
/// Determine the enclosing app bundle for the dock tile plugin bundle.
static func appBundleURL(for pluginBundleURL: URL) -> URL? {
var url = pluginBundleURL
@@ -137,20 +161,20 @@ final class CmuxDockTilePlugin: NSObject, NSDockTilePlugIn {
private extension NSDockTile {
func showDefaultAppIcon() {
DispatchQueue.main.async {
self.contentView = nil
self.display()
}
CmuxDockTilePlugin.assertMainQueue()
contentView = nil
display()
}
func showIcon(_ newIcon: NSImage) {
DispatchQueue.main.async {
let iconView = NSImageView(frame: CGRect(origin: .zero, size: self.size))
iconView.wantsLayer = true
iconView.image = newIcon
self.contentView = iconView
self.display()
}
CmuxDockTilePlugin.assertMainQueue()
let iconView = NSImageView(frame: CGRect(origin: .zero, size: size))
iconView.wantsLayer = true
iconView.image = newIcon
contentView = iconView
display()
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ enum AuthCallbackRouter {
private static func isAllowedScheme(_ scheme: String?) -> Bool {
guard let normalized = scheme?.lowercased() else { return false }
if normalized == "cmux" || normalized == "cmux-dev" {
if normalized == "cmux" || normalized == "cmux-nightly" || normalized == "cmux-dev" {
return true
}
// Honor the runtime override so any AuthEnvironment.callbackScheme
+5 -4
View File
@@ -13,13 +13,14 @@ enum AuthEnvironment {
!overridden.isEmpty {
return overridden
}
// Match the Info.plist CFBundleURLSchemes $(CMUX_AUTH_CALLBACK_SCHEME)
// expansion: cmux-dev in Debug builds, cmux in Release. Without this
// Debug split, beginSignIn() would start an ASWebAuthenticationSession
// listening on "cmux" while the OS routes cmux-dev:// this app.
#if DEBUG
// Debug and tagged dev builds register cmux-dev:// so they can coexist
// with the installed stable app.
return "cmux-dev"
#else
if Bundle.main.bundleIdentifier == "com.cmuxterm.app.nightly" {
return "cmux-nightly"
}
return "cmux"
#endif
}
+74 -12
View File
@@ -2,11 +2,17 @@ import AppKit
import AuthenticationServices
import CMUXAuthCore
import Foundation
import os
import StackAuth
#if canImport(Security)
import Security
#endif
nonisolated private let authLogger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.cmuxterm.app",
category: "AuthManager"
)
private final class AuthPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding {
static let shared = AuthPresentationContext()
@@ -145,6 +151,7 @@ final class AuthManager: ObservableObject {
private let tokenStore: any StackAuthTokenStoreProtocol
private let settingsStore: AuthSettingsStore
private let urlOpener: (URL) -> Void
private let usesSystemWebAuthenticationSession: () -> Bool
/// Resolves when the on-launch session restoration finishes (success or failure).
/// Any probe that needs a definitive `isAuthenticated` value must `await` this
@@ -157,12 +164,16 @@ final class AuthManager: ObservableObject {
client: (any AuthClientProtocol)? = nil,
tokenStore: any StackAuthTokenStoreProtocol = KeychainStackTokenStore(),
settingsStore: AuthSettingsStore = AuthSettingsStore(),
urlOpener: ((URL) -> Void)? = nil
urlOpener: ((URL) -> Void)? = nil,
usesSystemWebAuthenticationSession: (() -> Bool)? = nil
) {
self.tokenStore = tokenStore
self.settingsStore = settingsStore
self.client = client ?? Self.makeDefaultClient(tokenStore: tokenStore)
self.urlOpener = urlOpener ?? Self.defaultURLOpener
self.usesSystemWebAuthenticationSession = usesSystemWebAuthenticationSession ?? {
Self.shouldUseSystemWebAuthenticationSession()
}
let cachedUser = settingsStore.cachedUser()
self.currentUser = cachedUser
self.selectedTeamID = settingsStore.selectedTeamID
@@ -182,15 +193,52 @@ final class AuthManager: ObservableObject {
await bootstrapTask.value
}
private var loginPollTask: Task<Void, Never>?
private var webAuthSession: ASWebAuthenticationSession?
func beginSignIn() {
loginPollTask?.cancel()
beginSignIn(keepLoadingForExternalBrowser: false)
}
private func beginSignIn(keepLoadingForExternalBrowser: Bool) {
webAuthSession?.cancel()
webAuthSession = nil
isLoading = true
if usesSystemWebAuthenticationSession() {
isLoading = true
beginSystemWebAuthenticationSession()
return
}
let signInURL = AuthEnvironment.signInURL()
authLog("beginSignIn: opening external browser url=\(signInURL.absoluteString)")
urlOpener(signInURL)
if keepLoadingForExternalBrowser {
isLoading = true
} else if isLoading {
isLoading = false
}
}
static func shouldUseSystemWebAuthenticationSession(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Bool {
let value = environment["CMUX_AUTH_USE_ASWEB_AUTH_SESSION"]?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
switch value {
case "0", "false", "no":
return false
case "1", "true", "yes":
return true
default:
// ASWebAuthenticationSession scopes callbacks to the initiating
// session even when parallel debug apps share the same URL scheme.
return true
}
}
private func beginSystemWebAuthenticationSession() {
let signInURL = AuthEnvironment.signInURL()
let callbackScheme = AuthEnvironment.callbackScheme
@@ -205,14 +253,14 @@ final class AuthManager: ObservableObject {
self.webAuthSession = nil
}
if let error {
NSLog("auth.webauth failed: %@", "\(error)")
authLogger.error("ASWebAuthenticationSession failed: \(String(describing: error), privacy: .private)")
return
}
guard let callbackURL else { return }
do {
try await self.handleCallbackURL(callbackURL)
} catch {
NSLog("auth.webauth callback failed: %@", "\(error)")
authLogger.error("ASWebAuthenticationSession callback failed: \(String(describing: error), privacy: .private)")
}
}
}
@@ -222,20 +270,24 @@ final class AuthManager: ObservableObject {
if session.start() {
webAuthSession = session
} else {
NSLog("auth.webauth: session.start() returned false")
authLogger.warning("ASWebAuthenticationSession start returned false")
isLoading = false
}
}
/// Starts the ASWebAuthenticationSession popup and awaits the user's
/// Starts sign-in and awaits the user's
/// completion by observing isAuthenticated AND isLoading. Resolves when
/// authenticated, when the sign-in attempt settles unsuccessfully (popup
/// dismissed/cancelled/error), or when the deadline elapses. No polling
/// authenticated, when the sign-in attempt settles unsuccessfully
/// (popup dismissed/cancelled/error), or when the deadline elapses. No polling
/// the $isAuthenticated / $isLoading AsyncPublishers drive the wait.
func beginSignInAndAwait(timeout: TimeInterval) async -> Bool {
if isAuthenticated { return true }
beginSignIn()
return await waitForSignInSettled(timeout: timeout)
beginSignIn(keepLoadingForExternalBrowser: true)
let signedIn = await waitForSignInSettled(timeout: timeout)
if !signedIn && isLoading && !isAuthenticated {
isLoading = false
}
return signedIn
}
/// Signs out and awaits the state to flip. signOut() is already async and
@@ -398,6 +450,11 @@ final class AuthManager: ObservableObject {
throw AuthManagerError.invalidCallback
}
// System web-auth callbacks arrive from the session completion handler,
// so there is no active presentation to cancel on this shared callback path.
// The external-browser path never creates an ASWebAuthenticationSession.
webAuthSession = nil
lastKnownAccessToken = nil
isLoading = true
defer { isLoading = false }
@@ -406,6 +463,7 @@ final class AuthManager: ObservableObject {
refreshToken: payload.refreshToken
)
try await refreshSession()
lastKnownAccessToken = payload.accessToken
didCompleteBrowserSignIn = true
}
@@ -573,6 +631,10 @@ final class AuthManager: ObservableObject {
}
func signOut() async {
webAuthSession?.cancel()
webAuthSession = nil
isLoading = false
lastKnownAccessToken = nil
try? await client.signOut()
await tokenStore.clear()
clearSessionState(clearSelectedTeam: true)
@@ -0,0 +1,300 @@
import Foundation
import Combine
@MainActor
final class BackgroundWorkspacePrimeCoordinator {
private nonisolated enum PrimeCompletionReason: String {
case alreadyCleared = "already_cleared"
case cancelled
case surfaceReady = "surface_ready"
case timeout
case workspaceRemoved = "workspace_removed"
}
private nonisolated enum PrimeState {
case pending
case completed(reason: PrimeCompletionReason)
}
private nonisolated enum Policy {
static let timeoutSeconds: TimeInterval = 2.0
}
private nonisolated final class Waiter: @unchecked Sendable {
// Cancellation handlers cannot await an actor hop; this lock keeps continuation
// and cleanup state synchronous across task cancellation and readiness callbacks.
private let lock = NSLock()
private var continuation: CheckedContinuation<PrimeCompletionReason, Never>?
private var cleanupActions: [() -> Void] = []
private var resolvedReason: PrimeCompletionReason?
var isResolved: Bool {
lock.lock()
defer { lock.unlock() }
return resolvedReason != nil
}
deinit {
finish(reason: .cancelled)
}
func start(continuation: CheckedContinuation<PrimeCompletionReason, Never>) {
let reason: PrimeCompletionReason?
lock.lock()
reason = resolvedReason
if reason == nil {
self.continuation = continuation
}
lock.unlock()
if let reason {
continuation.resume(returning: reason)
}
}
func addObserver(_ observer: NSObjectProtocol) {
addCleanup { NotificationCenter.default.removeObserver(observer) }
}
func addCancellable(_ cancellable: AnyCancellable) {
addCleanup { cancellable.cancel() }
}
func addTask(_ task: Task<Void, Never>) {
addCleanup { task.cancel() }
}
func finish(reason: PrimeCompletionReason) {
let drained: (CheckedContinuation<PrimeCompletionReason, Never>?, [() -> Void])?
lock.lock()
if resolvedReason == nil {
resolvedReason = reason
drained = (continuation, cleanupActions)
continuation = nil
cleanupActions.removeAll()
} else {
drained = nil
}
lock.unlock()
guard let (continuation, cleanupActions) = drained else { return }
cleanupActions.forEach { $0() }
continuation?.resume(returning: reason)
}
private func addCleanup(_ action: @escaping () -> Void) {
lock.lock()
guard resolvedReason == nil else {
lock.unlock()
action()
return
}
cleanupActions.append(action)
lock.unlock()
}
}
deinit {
// Explicit for the required_deinit lint; per-prime resources live on Waiter.
}
func taskKey(for tabManager: TabManager) -> [String] {
tabManager.pendingBackgroundWorkspaceLoadIds
.map(\.uuidString)
.sorted()
}
func primePendingBackgroundWorkspaces(tabManager: TabManager) async {
while !Task.isCancelled {
let workspaceIds = tabManager.pendingBackgroundWorkspaceLoadIds.sorted { $0.uuidString < $1.uuidString }
guard !workspaceIds.isEmpty else { return }
for workspaceId in workspaceIds {
guard !Task.isCancelled else { return }
let reason = await primeBackgroundWorkspaceIfNeeded(workspaceId: workspaceId, tabManager: tabManager)
guard !Task.isCancelled else { return }
switch reason {
case .timeout:
// Keep the hidden mount retained; pending background initial commands
// must stay eligible to start until the surface is actually ready.
continue
case .cancelled:
continue
case .alreadyCleared, .surfaceReady, .workspaceRemoved:
continue
}
}
}
}
private func primeBackgroundWorkspaceIfNeeded(
workspaceId: UUID,
tabManager: TabManager
) async -> PrimeCompletionReason {
guard tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else {
tabManager.releaseBackgroundWorkspaceMount(for: workspaceId)
return .alreadyCleared
}
tabManager.retainBackgroundWorkspaceMount(for: workspaceId)
#if DEBUG
let startedAt = ProcessInfo.processInfo.systemUptime
cmuxDebugLog("workspace.backgroundPrime.start workspace=\(workspaceId.uuidString.prefix(5))")
#endif
let completionReason: PrimeCompletionReason
switch stepBackgroundWorkspacePrime(workspaceId: workspaceId, tabManager: tabManager) {
case .completed(let reason):
completionReason = reason
case .pending:
completionReason = await waitForBackgroundWorkspacePrimeCompletion(
workspaceId: workspaceId,
timeoutSeconds: Policy.timeoutSeconds,
tabManager: tabManager
)
}
#if DEBUG
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000
cmuxDebugLog(
"workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " +
"reason=\(completionReason.rawValue) ms=\(String(format: "%.2f", elapsedMs))"
)
#endif
return completionReason
}
private func stepBackgroundWorkspacePrime(workspaceId: UUID, tabManager: TabManager) -> PrimeState {
guard tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else {
tabManager.releaseBackgroundWorkspaceMount(for: workspaceId)
return .completed(reason: .alreadyCleared)
}
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else {
tabManager.completeBackgroundWorkspaceLoad(for: workspaceId)
return .completed(reason: .workspaceRemoved)
}
workspace.requestBackgroundPrimeTerminalSurfaceStartIfNeeded()
guard workspace.hasLoadedBackgroundPrimeTerminalSurface() else {
return .pending
}
tabManager.completeBackgroundWorkspaceLoad(for: workspaceId)
return .completed(reason: .surfaceReady)
}
private func waitForBackgroundWorkspacePrimeCompletion(
workspaceId: UUID,
timeoutSeconds: TimeInterval,
tabManager: TabManager
) async -> PrimeCompletionReason {
let waiter = Waiter()
return await withTaskCancellationHandler {
await withCheckedContinuation { (continuation: CheckedContinuation<PrimeCompletionReason, Never>) in
waiter.start(continuation: continuation)
guard !waiter.isResolved else { return }
installReadinessObservers(
waiter: waiter,
workspaceId: workspaceId,
tabManager: tabManager
)
let timeoutNanoseconds = UInt64(timeoutSeconds * 1_000_000_000)
let timeoutTask = Task { @MainActor [weak self, weak waiter, weak tabManager] in
do {
try await Task.sleep(nanoseconds: timeoutNanoseconds)
} catch {
return
}
guard !Task.isCancelled, let self, let waiter, let tabManager else { return }
if case .completed(let reason) = self.stepBackgroundWorkspacePrime(
workspaceId: workspaceId,
tabManager: tabManager
) {
waiter.finish(reason: reason)
} else {
waiter.finish(reason: .timeout)
}
}
waiter.addTask(timeoutTask)
evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager)
}
} onCancel: {
waiter.finish(reason: .cancelled)
}
}
private func installReadinessObservers(
waiter: Waiter,
workspaceId: UUID,
tabManager: TabManager
) {
let readyObserver = NotificationCenter.default.addObserver(
forName: .terminalSurfaceDidBecomeReady,
object: nil,
queue: .main
) { [weak self, weak waiter, weak tabManager] notification in
guard let readyWorkspaceId = notification.userInfo?["workspaceId"] as? UUID,
readyWorkspaceId == workspaceId,
let self,
let waiter,
let tabManager else { return }
Task { @MainActor in
self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager)
}
}
waiter.addObserver(readyObserver)
let hostedViewObserver = NotificationCenter.default.addObserver(
forName: .terminalSurfaceHostedViewDidMoveToWindow,
object: nil,
queue: .main
) { [weak self, weak waiter, weak tabManager] notification in
guard let readyWorkspaceId = notification.userInfo?["workspaceId"] as? UUID,
readyWorkspaceId == workspaceId,
let self,
let waiter,
let tabManager else { return }
Task { @MainActor in
self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager)
}
}
waiter.addObserver(hostedViewObserver)
let pendingObserver = tabManager.$pendingBackgroundWorkspaceLoadIds
.dropFirst()
.sink { [weak self, weak waiter, weak tabManager] pendingIds in
guard !pendingIds.contains(workspaceId),
let self,
let waiter,
let tabManager else { return }
Task { @MainActor in
self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager)
}
}
waiter.addCancellable(pendingObserver)
let tabsObserver = tabManager.$tabs
.dropFirst()
.sink { [weak self, weak waiter, weak tabManager] tabs in
guard !tabs.contains(where: { $0.id == workspaceId }),
let self,
let waiter,
let tabManager else { return }
Task { @MainActor in
self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager)
}
}
waiter.addCancellable(tabsObserver)
}
private func evaluate(waiter: Waiter, workspaceId: UUID, tabManager: TabManager) {
switch stepBackgroundWorkspacePrime(workspaceId: workspaceId, tabManager: tabManager) {
case .pending:
break
case .completed(let reason):
waiter.finish(reason: reason)
}
}
}
+1 -32
View File
@@ -1,22 +1,8 @@
import SwiftUI
struct BetaFeaturesSettingsView: View {
@Binding var feedEnabled: Bool
@Binding var dockEnabled: Bool
private var feedSubtitle: String {
if feedEnabled {
return String(
localized: "settings.betaFeatures.feed.subtitleOn",
defaultValue: "Shows Feed in the right sidebar mode switcher for inline agent decisions."
)
}
return String(
localized: "settings.betaFeatures.feed.subtitleOff",
defaultValue: "Hides Feed from the right sidebar until you enable it here."
)
}
private var dockSubtitle: String {
if dockEnabled {
return String(
@@ -37,29 +23,12 @@ struct BetaFeaturesSettingsView: View {
BetaFeaturesWarningNote(
String(
localized: "settings.betaFeatures.warning",
defaultValue: "These features are unstable and may change or break. Enable them only when you are testing them."
defaultValue: "Dock is unstable and may change or break. Enable it only when you are testing it."
)
)
SettingsCardDivider()
SettingsCardRow(
configurationReview: .settingsOnly,
String(localized: "settings.betaFeatures.feed", defaultValue: "Feed"),
subtitle: feedSubtitle,
searchAnchorID: SettingsSearchIndex.settingID(for: .betaFeatures, idSuffix: "feed")
) {
Toggle("", isOn: $feedEnabled)
.labelsHidden()
.controlSize(.small)
.accessibilityIdentifier("SettingsBetaFeedToggle")
.accessibilityLabel(
String(localized: "settings.betaFeatures.feed", defaultValue: "Feed")
)
}
SettingsCardDivider()
SettingsCardRow(
configurationReview: .settingsOnly,
String(localized: "settings.betaFeatures.dock", defaultValue: "Dock"),
+430
View File
@@ -0,0 +1,430 @@
import AppKit
import Bonsplit
import Foundation
import WebKit
final class BrowserPaneDropTargetView: NSView {
weak var slotView: WindowBrowserSlotView?
var dropContext: BrowserPaneDropContext?
private var activeZone: DropZone?
private weak var activeFileDropWebView: NSView?
private weak var preparedFileDropWebView: NSView?
private weak var performedFileDropWebView: NSView?
#if DEBUG
private var lastHitTestSignature: String?
#endif
override var acceptsFirstResponder: Bool { false }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
registerForDraggedTypes(Array(Set([
DragOverlayRoutingPolicy.filePreviewTransferType,
DragOverlayRoutingPolicy.bonsplitTabTransferType,
]).union(PasteboardFileURLReader.fileURLPasteboardTypes)))
}
@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}
deinit {}
@MainActor
static func shouldCaptureHitTesting(
pasteboardTypes: [NSPasteboard.PasteboardType]?,
eventType: NSEvent.EventType?
) -> Bool {
let hasFileURL = DragOverlayRoutingPolicy.hasFileURL(pasteboardTypes)
let fileDropBehavior = DragOverlayRoutingPolicy.resolvedFileDropBehavior(
pasteboardTypes: pasteboardTypes,
modifierFlags: DragOverlayRoutingPolicy.currentModifierFlags,
canDropAsText: true
)
let fileDropWantsPreview = fileDropBehavior == .preview
let shouldCaptureFileDrop = fileDropBehavior != nil
let hasFilePreviewTransfer = DragOverlayRoutingPolicy.hasFilePreviewTransfer(pasteboardTypes)
let hasBonsplitTransfer = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes)
let shouldCaptureFilePreviewTransfer = hasFilePreviewTransfer && (!hasFileURL || fileDropWantsPreview)
let shouldCaptureBonsplitTransfer = hasBonsplitTransfer && !hasFilePreviewTransfer
guard shouldCaptureBonsplitTransfer || shouldCaptureFilePreviewTransfer || shouldCaptureFileDrop else { return false }
guard let eventType else { return false }
switch eventType {
case .cursorUpdate,
.mouseEntered,
.mouseExited,
.mouseMoved,
.leftMouseDragged,
.rightMouseDragged,
.otherMouseDragged,
.leftMouseUp,
.rightMouseUp,
.otherMouseUp,
.appKitDefined,
.applicationDefined,
.systemDefined,
.periodic:
return true
default:
return false
}
}
override func hitTest(_ point: NSPoint) -> NSView? {
guard bounds.contains(point), dropContext != nil else { return nil }
if shouldDeferToPaneTabBar(at: point) {
return nil
}
let pasteboardTypes = NSPasteboard(name: .drag).types
let eventType = NSApp.currentEvent?.type
let capture = Self.shouldCaptureHitTesting(
pasteboardTypes: pasteboardTypes,
eventType: eventType
)
#if DEBUG
logHitTestDecision(capture: capture, pasteboardTypes: pasteboardTypes, eventType: eventType)
#endif
return capture ? self : nil
}
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
updateDragState(sender, phase: "entered")
}
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
updateDragState(sender, phase: "updated")
}
override func draggingExited(_ sender: (any NSDraggingInfo)?) {
exitActiveFileDropWebView(sender)
clearDragState(phase: "exited")
}
override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool {
guard let dropContext else {
#if DEBUG
cmuxDebugLog("browser.paneDrop.prepare allowed=0 reason=missingContext")
#endif
return false
}
let location = convert(sender.draggingLocation, from: nil)
if shouldRouteFileDropToHostedWebView(sender, at: location) {
clearDragState(phase: "prepare.text")
let webView = activeFileDropWebView ?? slotView?.hostedWebViewForFileDrop(at: location)
let accepted = webView?.prepareForDragOperation(sender) ?? false
preparedFileDropWebView = accepted ? webView : nil
#if DEBUG
cmuxDebugLog(
"browser.paneDrop.prepareAsWebView panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"accepted=\(accepted ? 1 : 0)"
)
#endif
return accepted
}
return true
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
defer {
clearDragState(phase: "perform.clear")
}
guard let dropContext else {
#if DEBUG
cmuxDebugLog("browser.paneDrop.perform allowed=0 reason=missingContext")
#endif
return false
}
let location = convert(sender.draggingLocation, from: nil)
let zone = BrowserPaneDropRouting.zone(
for: location,
in: bounds.size,
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
)
if shouldRouteFileDropToHostedWebView(sender, at: location) {
let webView = preparedFileDropWebView ?? activeFileDropWebView ?? slotView?.hostedWebViewForFileDrop(at: location)
let handled = webView?.performDragOperation(sender) ?? false
if handled {
performedFileDropWebView = webView
focusBrowserPanelAfterSuccessfulFileDrop(context: dropContext)
} else {
preparedFileDropWebView = nil
performedFileDropWebView = nil
}
#if DEBUG
cmuxDebugLog(
"browser.paneDrop.performAsWebView panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"handled=\(handled ? 1 : 0)"
)
#endif
return handled
}
if let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard),
transfer.isFromCurrentProcess {
if transfer.isFilePreview {
guard let entry = FilePreviewDragRegistry.shared.consume(id: transfer.tabId),
let workspace = AppDelegate.shared?.workspaceFor(tabId: dropContext.workspaceId) else {
#if DEBUG
cmuxDebugLog(
"browser.paneDrop.perform allowed=0 panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"reason=missingFilePreviewEntry tab=\(transfer.tabId.uuidString.prefix(5))"
)
#endif
return false
}
let handled = workspace.handleFilePreviewDrop(
entry: entry,
destination: BrowserPaneDropRouting.filePreviewDestination(
target: dropContext,
zone: zone
)
)
#if DEBUG
cmuxDebugLog(
"browser.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone) filePreview=1 handled=\(handled ? 1 : 0)"
)
#endif
return handled
}
guard let action = BrowserPaneDropRouting.action(
for: transfer,
target: dropContext,
zone: zone
) else {
#if DEBUG
cmuxDebugLog(
"browser.paneDrop.perform allowed=0 panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"reason=noAction zone=\(zone)"
)
#endif
return false
}
switch action {
case .noOp:
#if DEBUG
cmuxDebugLog(
"browser.paneDrop.perform allowed=1 panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"tab=\(transfer.tabId.uuidString.prefix(5)) action=noop"
)
#endif
return true
case .move(let tabId, let workspaceId, let targetPane, let splitTarget):
let moved = AppDelegate.shared?.moveBonsplitTab(
tabId: tabId,
toWorkspace: workspaceId,
targetPane: targetPane,
splitTarget: splitTarget.map { ($0.orientation, $0.insertFirst) },
focus: true,
focusWindow: true
) ?? false
#if DEBUG
let splitLabel = splitTarget.map {
"\($0.orientation.rawValue):\($0.insertFirst ? 1 : 0)"
} ?? "none"
cmuxDebugLog(
"browser.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"tab=\(tabId.uuidString.prefix(5)) zone=\(zone) pane=\(targetPane.id.uuidString.prefix(5)) " +
"split=\(splitLabel) moved=\(moved ? 1 : 0)"
)
#endif
return moved
}
}
let urls = DragOverlayRoutingPolicy.fileURLs(from: sender.draggingPasteboard)
guard !urls.isEmpty,
let workspace = AppDelegate.shared?.workspaceFor(tabId: dropContext.workspaceId) else {
#if DEBUG
cmuxDebugLog(
"browser.paneDrop.perform allowed=0 panel=\(dropContext.panelId.uuidString.prefix(5)) reason=missingTransferAndFiles"
)
#endif
return false
}
let handled = workspace.handleExternalFileDrop(BonsplitController.ExternalFileDropRequest(
urls: urls,
destination: PaneDropRouting.filePreviewDestination(
targetPane: dropContext.paneId,
zone: zone
)
))
#if DEBUG
cmuxDebugLog(
"browser.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"fileURLs=\(urls.count) zone=\(zone) handled=\(handled ? 1 : 0)"
)
#endif
return handled
}
override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) {
defer {
activeFileDropWebView = nil
preparedFileDropWebView = nil
performedFileDropWebView = nil
clearDragState(phase: "conclude.clear")
}
guard let sender else { return }
if let webView = performedFileDropWebView ?? preparedFileDropWebView ?? activeFileDropWebView {
webView.concludeDragOperation(sender)
}
}
private func updateDragState(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation {
let location = convert(sender.draggingLocation, from: nil)
if shouldDeferToPaneTabBar(at: location) {
exitActiveFileDropWebView(sender)
clearDragState(phase: "\(phase).tabBar")
return []
}
guard let dropContext else {
exitActiveFileDropWebView(sender)
clearDragState(phase: "\(phase).reject")
return []
}
let zone = BrowserPaneDropRouting.zone(
for: location,
in: bounds.size,
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
)
if shouldRouteFileDropToHostedWebView(sender, at: location) {
clearDragState(phase: "\(phase).text")
return updateHostedWebViewDragState(sender, at: location)
}
exitActiveFileDropWebView(sender)
if let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard) {
guard transfer.isFromCurrentProcess,
(!transfer.isFilePreview || FilePreviewDragRegistry.shared.contains(id: transfer.tabId)) else {
clearDragState(phase: "\(phase).reject")
return []
}
activeZone = zone
slotView?.setPortalDragDropZone(zone)
#if DEBUG
cmuxDebugLog(
"browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone)"
)
#endif
return .move
}
guard DragOverlayRoutingPolicy.hasFileURL(sender.draggingPasteboard.types) else {
clearDragState(phase: "\(phase).reject")
return []
}
activeZone = zone
slotView?.setPortalDragDropZone(zone)
#if DEBUG
cmuxDebugLog(
"browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) fileURL=1 zone=\(zone)"
)
#endif
return .copy
}
private func shouldRouteFileDropToHostedWebView(_ sender: any NSDraggingInfo, at location: NSPoint) -> Bool {
guard DragOverlayRoutingPolicy.hasFileURL(sender.draggingPasteboard.types) else { return false }
let canDropIntoHostedWebView = slotView?.hostedWebViewForFileDrop(at: location) != nil
return DragOverlayRoutingPolicy.shouldRouteFileDropToTextDestination(
pasteboardTypes: sender.draggingPasteboard.types,
modifierFlags: DragOverlayRoutingPolicy.currentModifierFlags,
canDropAsText: canDropIntoHostedWebView
)
}
private func updateHostedWebViewDragState(_ sender: any NSDraggingInfo, at location: NSPoint) -> NSDragOperation {
guard let webView = slotView?.hostedWebViewForFileDrop(at: location) else {
exitActiveFileDropWebView(sender)
return []
}
if activeFileDropWebView !== webView {
exitActiveFileDropWebView(sender)
activeFileDropWebView = webView
return webView.draggingEntered(sender)
}
return webView.draggingUpdated(sender)
}
private func exitActiveFileDropWebView(_ sender: (any NSDraggingInfo)?) {
if let webView = activeFileDropWebView {
webView.draggingExited(sender)
activeFileDropWebView = nil
}
}
private func focusBrowserPanelAfterSuccessfulFileDrop(context: BrowserPaneDropContext) {
guard let workspace = AppDelegate.shared?.workspaceFor(tabId: context.workspaceId) else { return }
FileDropTextDropController.focusPanelAfterSuccessfulTextDrop(
workspace: workspace,
panelId: context.panelId,
focusIntent: .browser(.webView),
window: window ?? slotView?.window
)
}
func shouldDeferToPaneTabBar(at point: NSPoint) -> Bool {
let windowPoint = convert(point, to: nil)
return BonsplitTabBarPassThrough
.shouldPassThroughToPaneTabBar(windowPoint: windowPoint, below: self)
.result
}
private func clearDragState(phase: String) {
guard activeZone != nil else { return }
activeZone = nil
slotView?.setPortalDragDropZone(nil)
#if DEBUG
if let dropContext {
cmuxDebugLog(
"browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) zone=none"
)
}
#endif
}
#if DEBUG
private func logHitTestDecision(
capture: Bool,
pasteboardTypes: [NSPasteboard.PasteboardType]?,
eventType: NSEvent.EventType?
) {
let hasTransferType = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes)
let hasFileURL = DragOverlayRoutingPolicy.hasFileURL(pasteboardTypes)
guard hasTransferType || hasFileURL || capture else { return }
let signature = [
capture ? "1" : "0",
hasTransferType ? "1" : "0",
hasFileURL ? "1" : "0",
String(describing: dropContext != nil),
eventType.map { String($0.rawValue) } ?? "nil",
].joined(separator: "|")
guard lastHitTestSignature != signature else { return }
lastHitTestSignature = signature
let types = pasteboardTypes?.map(\.rawValue).joined(separator: ",") ?? "-"
cmuxDebugLog(
"browser.paneDrop.hitTest capture=\(capture ? 1 : 0) " +
"hasTransfer=\(hasTransferType ? 1 : 0) hasFileURL=\(hasFileURL ? 1 : 0) context=\(dropContext != nil ? 1 : 0) " +
"event=\(eventType.map { String($0.rawValue) } ?? "nil") types=\(types)"
)
}
#endif
}
+41 -323
View File
@@ -1368,70 +1368,12 @@ enum BrowserPaneDropAction: Equatable {
}
enum BrowserPaneDropRouting {
private static let padding: CGFloat = 4
private static func fullPaneSize(for slotSize: CGSize, topChromeHeight: CGFloat) -> CGSize {
CGSize(width: slotSize.width, height: slotSize.height + max(0, topChromeHeight))
}
static func zone(for location: CGPoint, in size: CGSize, topChromeHeight: CGFloat = 0) -> DropZone {
let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight)
let edgeRatio: CGFloat = 0.25
let horizontalEdge = max(80, fullPaneSize.width * edgeRatio)
let verticalEdge = max(80, fullPaneSize.height * edgeRatio)
if location.x < horizontalEdge {
return .left
} else if location.x > fullPaneSize.width - horizontalEdge {
return .right
} else if location.y > fullPaneSize.height - verticalEdge {
return .top
} else if location.y < verticalEdge {
return .bottom
} else {
return .center
}
PaneDropRouting.zone(for: location, in: size, topChromeHeight: topChromeHeight)
}
static func overlayFrame(for zone: DropZone, in size: CGSize, topChromeHeight: CGFloat = 0) -> CGRect {
let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight)
switch zone {
case .center:
return CGRect(
x: padding,
y: padding,
width: fullPaneSize.width - padding * 2,
height: fullPaneSize.height - padding * 2
)
case .left:
return CGRect(
x: padding,
y: padding,
width: fullPaneSize.width / 2 - padding,
height: fullPaneSize.height - padding * 2
)
case .right:
return CGRect(
x: fullPaneSize.width / 2,
y: padding,
width: fullPaneSize.width / 2 - padding,
height: fullPaneSize.height - padding * 2
)
case .top:
return CGRect(
x: padding,
y: fullPaneSize.height / 2,
width: fullPaneSize.width - padding * 2,
height: fullPaneSize.height / 2 - padding
)
case .bottom:
return CGRect(
x: padding,
y: padding,
width: fullPaneSize.width - padding * 2,
height: fullPaneSize.height / 2 - padding
)
}
PaneDropRouting.compactOverlayFrame(for: zone, in: size, topChromeHeight: topChromeHeight)
}
static func action(
@@ -1469,272 +1411,10 @@ enum BrowserPaneDropRouting {
target: BrowserPaneDropContext,
zone: DropZone
) -> BonsplitController.ExternalTabDropRequest.Destination {
switch zone {
case .center:
return .insert(targetPane: target.paneId, targetIndex: nil)
case .left:
return .split(targetPane: target.paneId, orientation: .horizontal, insertFirst: true)
case .right:
return .split(targetPane: target.paneId, orientation: .horizontal, insertFirst: false)
case .top:
return .split(targetPane: target.paneId, orientation: .vertical, insertFirst: true)
case .bottom:
return .split(targetPane: target.paneId, orientation: .vertical, insertFirst: false)
}
PaneDropRouting.filePreviewDestination(targetPane: target.paneId, zone: zone)
}
}
final class BrowserPaneDropTargetView: NSView {
weak var slotView: WindowBrowserSlotView?
var dropContext: BrowserPaneDropContext?
private var activeZone: DropZone?
#if DEBUG
private var lastHitTestSignature: String?
#endif
override var acceptsFirstResponder: Bool { false }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
registerForDraggedTypes([
DragOverlayRoutingPolicy.filePreviewTransferType,
DragOverlayRoutingPolicy.bonsplitTabTransferType
])
}
@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}
static func shouldCaptureHitTesting(
pasteboardTypes: [NSPasteboard.PasteboardType]?,
eventType: NSEvent.EventType?
) -> Bool {
guard DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) else { return false }
guard let eventType else { return false }
switch eventType {
case .cursorUpdate,
.mouseEntered,
.mouseExited,
.mouseMoved,
.leftMouseDragged,
.rightMouseDragged,
.otherMouseDragged,
.appKitDefined,
.applicationDefined,
.systemDefined,
.periodic:
return true
default:
return false
}
}
override func hitTest(_ point: NSPoint) -> NSView? {
guard bounds.contains(point), dropContext != nil else { return nil }
if shouldDeferToPaneTabBar(at: point) {
return nil
}
let pasteboardTypes = NSPasteboard(name: .drag).types
let eventType = NSApp.currentEvent?.type
let capture = Self.shouldCaptureHitTesting(
pasteboardTypes: pasteboardTypes,
eventType: eventType
)
#if DEBUG
logHitTestDecision(capture: capture, pasteboardTypes: pasteboardTypes, eventType: eventType)
#endif
return capture ? self : nil
}
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
updateDragState(sender, phase: "entered")
}
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
updateDragState(sender, phase: "updated")
}
override func draggingExited(_ sender: (any NSDraggingInfo)?) {
clearDragState(phase: "exited")
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
defer {
clearDragState(phase: "perform.clear")
}
guard let dropContext,
let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard),
transfer.isFromCurrentProcess else {
#if DEBUG
cmuxDebugLog("browser.paneDrop.perform allowed=0 reason=missingTransfer")
#endif
return false
}
let location = convert(sender.draggingLocation, from: nil)
let zone = BrowserPaneDropRouting.zone(
for: location,
in: bounds.size,
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
)
if transfer.isFilePreview {
guard let entry = FilePreviewDragRegistry.shared.consume(id: transfer.tabId),
let workspace = AppDelegate.shared?.workspaceFor(tabId: dropContext.workspaceId) else {
#if DEBUG
cmuxDebugLog(
"browser.paneDrop.perform allowed=0 panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"reason=missingFilePreviewEntry tab=\(transfer.tabId.uuidString.prefix(5))"
)
#endif
return false
}
let handled = workspace.handleFilePreviewDrop(
entry: entry,
destination: BrowserPaneDropRouting.filePreviewDestination(
target: dropContext,
zone: zone
)
)
#if DEBUG
cmuxDebugLog(
"browser.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone) filePreview=1 handled=\(handled ? 1 : 0)"
)
#endif
return handled
}
guard let action = BrowserPaneDropRouting.action(
for: transfer,
target: dropContext,
zone: zone
) else {
#if DEBUG
cmuxDebugLog(
"browser.paneDrop.perform allowed=0 panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"reason=noAction zone=\(zone)"
)
#endif
return false
}
switch action {
case .noOp:
#if DEBUG
cmuxDebugLog(
"browser.paneDrop.perform allowed=1 panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"tab=\(transfer.tabId.uuidString.prefix(5)) action=noop"
)
#endif
return true
case .move(let tabId, let workspaceId, let targetPane, let splitTarget):
let moved = AppDelegate.shared?.moveBonsplitTab(
tabId: tabId,
toWorkspace: workspaceId,
targetPane: targetPane,
splitTarget: splitTarget.map { ($0.orientation, $0.insertFirst) },
focus: true,
focusWindow: true
) ?? false
#if DEBUG
let splitLabel = splitTarget.map {
"\($0.orientation.rawValue):\($0.insertFirst ? 1 : 0)"
} ?? "none"
cmuxDebugLog(
"browser.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"tab=\(tabId.uuidString.prefix(5)) zone=\(zone) pane=\(targetPane.id.uuidString.prefix(5)) " +
"split=\(splitLabel) moved=\(moved ? 1 : 0)"
)
#endif
return moved
}
}
private func updateDragState(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation {
let location = convert(sender.draggingLocation, from: nil)
if shouldDeferToPaneTabBar(at: location) {
clearDragState(phase: "\(phase).tabBar")
return []
}
guard let dropContext,
let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard),
transfer.isFromCurrentProcess,
(!transfer.isFilePreview || FilePreviewDragRegistry.shared.contains(id: transfer.tabId)) else {
clearDragState(phase: "\(phase).reject")
return []
}
let zone = BrowserPaneDropRouting.zone(
for: location,
in: bounds.size,
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
)
activeZone = zone
slotView?.setPortalDragDropZone(zone)
#if DEBUG
cmuxDebugLog(
"browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone)"
)
#endif
return .move
}
func shouldDeferToPaneTabBar(at point: NSPoint) -> Bool {
let windowPoint = convert(point, to: nil)
return BonsplitTabBarPassThrough
.shouldPassThroughToPaneTabBar(windowPoint: windowPoint, below: self)
.result
}
private func clearDragState(phase: String) {
guard activeZone != nil else { return }
activeZone = nil
slotView?.setPortalDragDropZone(nil)
#if DEBUG
if let dropContext {
cmuxDebugLog(
"browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) zone=none"
)
}
#endif
}
#if DEBUG
private func logHitTestDecision(
capture: Bool,
pasteboardTypes: [NSPasteboard.PasteboardType]?,
eventType: NSEvent.EventType?
) {
let hasTransferType = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes)
guard hasTransferType || capture else { return }
let signature = [
capture ? "1" : "0",
hasTransferType ? "1" : "0",
String(describing: dropContext != nil),
eventType.map { String($0.rawValue) } ?? "nil",
].joined(separator: "|")
guard lastHitTestSignature != signature else { return }
lastHitTestSignature = signature
let types = pasteboardTypes?.map(\.rawValue).joined(separator: ",") ?? "-"
cmuxDebugLog(
"browser.paneDrop.hitTest capture=\(capture ? 1 : 0) " +
"hasTransfer=\(hasTransferType ? 1 : 0) context=\(dropContext != nil ? 1 : 0) " +
"event=\(eventType.map { String($0.rawValue) } ?? "nil") types=\(types)"
)
}
#endif
}
final class WindowBrowserSlotView: NSView {
override var isOpaque: Bool { false }
override var isHidden: Bool {
@@ -1845,6 +1525,22 @@ final class WindowBrowserSlotView: NSView {
paneDropTargetView.dropContext = context
}
func paneDropTargetForDrop(at localPoint: NSPoint) -> BrowserPaneDropTargetView? {
guard paneDropTargetView.dropContext != nil else { return nil }
guard bounds.contains(localPoint) else { return nil }
let pointInTarget = paneDropTargetView.convert(localPoint, from: self)
guard paneDropTargetView.bounds.contains(pointInTarget) else { return nil }
guard !paneDropTargetView.shouldDeferToPaneTabBar(at: pointInTarget) else { return nil }
return paneDropTargetView
}
func hostedWebViewForFileDrop(at localPoint: NSPoint) -> WKWebView? {
guard let hostedWebView else { return nil }
let webPoint = hostedWebView.convert(localPoint, from: self)
guard hostedWebView.bounds.contains(webPoint) else { return nil }
return hostedWebView
}
func setPaneTopChromeHeight(_ height: CGFloat) {
let resolvedHeight = max(0, height)
guard abs(paneTopChromeHeight - resolvedHeight) > 0.5 else { return }
@@ -4069,6 +3765,19 @@ final class WindowBrowserPortal: NSObject {
}
return nil
}
func browserPaneDropTargetAtWindowPoint(_ windowPoint: NSPoint) -> BrowserPaneDropTargetView? {
guard ensureInstalled() else { return nil }
let point = hostView.convert(windowPoint, from: nil)
for subview in hostView.subviews.reversed() {
guard let container = subview as? WindowBrowserSlotView else { continue }
guard !container.isHidden else { continue }
guard container.frame.contains(point) else { continue }
let pointInContainer = container.convert(point, from: hostView)
return container.paneDropTargetForDrop(at: pointInContainer)
}
return nil
}
}
@MainActor
@@ -4282,6 +3991,15 @@ enum BrowserWindowPortalRegistry {
return portal.webViewAtWindowPoint(windowPoint)
}
static func browserPaneDropTargetAtWindowPoint(
_ windowPoint: NSPoint,
in window: NSWindow
) -> BrowserPaneDropTargetView? {
let windowId = ObjectIdentifier(window)
guard let portal = portalsByWindowId[windowId] else { return nil }
return portal.browserPaneDropTargetAtWindowPoint(windowPoint)
}
static func refresh(webView: WKWebView, reason: String) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],
+1 -1
View File
@@ -113,7 +113,7 @@ final class CloudVMActionLauncher {
}
}
private final class ProcessOutputCollector: @unchecked Sendable {
final class ProcessOutputCollector: @unchecked Sendable {
private enum Stream {
case stdout
case stderr
+6 -6
View File
@@ -13,13 +13,10 @@ struct CmuxConfigFile: Codable, Sendable {
var newWorkspaceCommand: String?
var surfaceTabBarButtons: [CmuxSurfaceTabBarButton]?
var commands: [CmuxCommandDefinition]
var vault: CmuxVaultConfigDefinition?
private enum CodingKeys: String, CodingKey {
case actions
case ui
case newWorkspaceCommand
case surfaceTabBarButtons
case commands
case actions, ui, newWorkspaceCommand, surfaceTabBarButtons, commands, vault
}
init(
@@ -27,13 +24,15 @@ struct CmuxConfigFile: Codable, Sendable {
ui: CmuxConfigUIDefinition? = nil,
newWorkspaceCommand: String? = nil,
surfaceTabBarButtons: [CmuxSurfaceTabBarButton]? = nil,
commands: [CmuxCommandDefinition] = []
commands: [CmuxCommandDefinition] = [],
vault: CmuxVaultConfigDefinition? = nil
) {
self.actions = actions
self.ui = ui
self.newWorkspaceCommand = newWorkspaceCommand
self.surfaceTabBarButtons = surfaceTabBarButtons
self.commands = commands
self.vault = vault
}
init(from decoder: Decoder) throws {
@@ -79,6 +78,7 @@ struct CmuxConfigFile: Codable, Sendable {
surfaceTabBarButtons = nil
}
commands = try container.decodeIfPresent([CmuxCommandDefinition].self, forKey: .commands) ?? []
vault = try container.decodeIfPresent(CmuxVaultConfigDefinition.self, forKey: .vault)
}
private static func normalizedActions(
+470
View File
@@ -0,0 +1,470 @@
import Foundation
struct CmuxEventSubscriptionSnapshot {
let subscription: CmuxEventSubscription
let replay: [[String: Any]]
let ack: [String: Any]
}
// Sendable safety: every mutable field is protected by `lock`; `semaphore` only wakes `next(timeout:)`.
final class CmuxEventSubscription: @unchecked Sendable {
let id: UUID
let names: Set<String>
let categories: Set<String>
let maxPendingEvents: Int
private let lock = NSLock()
private let semaphore = DispatchSemaphore(value: 0)
private var queue: [[String: Any]] = []
private var closed = false
private var closedReason: String?
init(id: UUID = UUID(), names: Set<String>, categories: Set<String>, maxPendingEvents: Int) {
self.id = id
self.names = names
self.categories = categories
self.maxPendingEvents = max(1, maxPendingEvents)
}
func accepts(_ event: [String: Any]) -> Bool {
if !names.isEmpty {
guard let name = event["name"] as? String, names.contains(name) else { return false }
}
if !categories.isEmpty {
guard let category = event["category"] as? String, categories.contains(category) else { return false }
}
return true
}
var isClosed: Bool {
lock.lock()
defer { lock.unlock() }
return closed
}
var closeReason: String? {
lock.lock()
defer { lock.unlock() }
return closedReason
}
func enqueue(_ event: [String: Any]) -> Bool {
lock.lock()
let shouldSignal: Bool
let accepted: Bool
if closed {
shouldSignal = false
accepted = false
} else if queue.count >= maxPendingEvents {
closed = true
closedReason = "pending event buffer exceeded \(maxPendingEvents) events"
queue.removeAll()
shouldSignal = true
accepted = false
} else {
queue.append(event)
shouldSignal = true
accepted = true
}
lock.unlock()
if shouldSignal {
semaphore.signal()
}
return accepted
}
func next(timeout: TimeInterval) -> [String: Any]? {
lock.lock()
if !queue.isEmpty {
let event = queue.removeFirst()
lock.unlock()
return event
}
if closed {
lock.unlock()
return nil
}
lock.unlock()
let result = semaphore.wait(timeout: .now() + timeout)
guard result == .success else { return nil }
lock.lock()
defer { lock.unlock() }
guard !queue.isEmpty else { return nil }
return queue.removeFirst()
}
func close(reason: String? = nil) {
lock.lock()
closed = true
if let reason {
closedReason = reason
}
queue.removeAll()
lock.unlock()
semaphore.signal()
}
}
// Sendable safety: event state is protected by `lock`; disk appends are delegated to `CmuxEventLogWriter`.
final class CmuxEventBus: @unchecked Sendable {
static let shared = CmuxEventBus(eventLogURL: defaultEventLogURL())
static let protocolName = "cmux-events"
static let protocolVersion = 1
static let defaultHeartbeatIntervalSeconds: TimeInterval = 15
static let defaultRetainedEventLimit = 4_096
static let defaultMaxEventLineBytes = 16 * 1024
static let defaultMaxEventLogBytes: UInt64 = 16 * 1024 * 1024
static let defaultMaxPendingEventLogLines = CmuxEventLogWriter.defaultMaxPendingLines
static let defaultMaxPendingEventsPerSubscription = 1_024
static let maxSanitizedStringBytes = 8 * 1024
static let maxSanitizedArrayItems = 256
static let maxSanitizedObjectEntries = 256
static let maxSanitizedDepth = 12
private static let isoFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter(); formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]; return formatter }()
private static let isoFormatterLock = NSLock()
private let lock = NSLock()
private let retainedEventLimit: Int
private let maxEventLineBytes: Int
private let maxPendingEventsPerSubscription: Int
private let eventLogWriter: CmuxEventLogWriter?
private let bootId = UUID().uuidString
private var nextSequence: Int64 = 1
private var retained: [[String: Any]] = []
private var subscriptions: [UUID: CmuxEventSubscription] = [:]
init(
retainedEventLimit: Int = CmuxEventBus.defaultRetainedEventLimit,
eventLogURL: URL? = nil,
maxEventLogBytes: UInt64 = CmuxEventBus.defaultMaxEventLogBytes,
maxEventLineBytes: Int = CmuxEventBus.defaultMaxEventLineBytes,
maxPendingEventLogLines: Int = CmuxEventBus.defaultMaxPendingEventLogLines,
maxPendingEventsPerSubscription: Int = CmuxEventBus.defaultMaxPendingEventsPerSubscription
) {
self.retainedEventLimit = max(1, retainedEventLimit)
self.maxEventLineBytes = max(1, maxEventLineBytes)
self.maxPendingEventsPerSubscription = max(1, maxPendingEventsPerSubscription)
self.eventLogWriter = eventLogURL.map {
CmuxEventLogWriter(
eventLogURL: $0,
maxEventLogBytes: maxEventLogBytes,
maxPendingLines: maxPendingEventLogLines
)
}
}
var latestSequence: Int64 {
lock.lock()
defer { lock.unlock() }
return nextSequence - 1
}
func publish(
name: String,
category: String,
source: String,
workspaceId: String? = nil,
surfaceId: String? = nil,
paneId: String? = nil,
windowId: String? = nil,
payload: [String: Any] = [:]
) {
let occurredAt = Self.isoTimestamp(Date())
let cleanPayload = Self.sanitizedJSONValue(payload)
lock.lock()
let sequence = nextSequence
nextSequence += 1
var event: [String: Any] = [
"type": "event",
"protocol": Self.protocolName,
"version": Self.protocolVersion,
"boot_id": bootId,
"seq": sequence,
"id": "\(bootId)-\(sequence)",
"name": name,
"category": category,
"source": source,
"occurred_at": occurredAt,
"workspace_id": workspaceId ?? NSNull(),
"surface_id": surfaceId ?? NSNull(),
"pane_id": paneId ?? NSNull(),
"window_id": windowId ?? NSNull(),
"payload": cleanPayload
]
event = Self.eventByApplyingEncodedByteLimit(event, maxBytes: maxEventLineBytes)
retained.append(event)
if retained.count > retainedEventLimit {
retained.removeFirst(retained.count - retainedEventLimit)
}
let encodedLine = Self.encodeLine(event)
let liveSubscriptions = Array(subscriptions.values)
lock.unlock()
if let encodedLine { eventLogWriter?.enqueue(encodedLine) }
for subscription in liveSubscriptions where subscription.accepts(event) {
if !subscription.enqueue(event) {
removeSubscriptionIfStillActive(subscription)
}
}
}
func subscribe(
afterSequence: Int64?,
names: Set<String>,
categories: Set<String>
) -> CmuxEventSubscriptionSnapshot {
let subscription = CmuxEventSubscription(
names: names,
categories: categories,
maxPendingEvents: maxPendingEventsPerSubscription
)
lock.lock()
let oldestSequence = Self.int64(retained.first?["seq"]) ?? nextSequence
let latestSequence = nextSequence - 1
let replay = retained.filter { event in
let seq = Self.int64(event["seq"]) ?? 0
let after = afterSequence ?? latestSequence
return seq > after && subscription.accepts(event)
}
let requestedAfter = afterSequence ?? latestSequence
let gapReason: String? = afterSequence.flatMap { after in
if !retained.isEmpty, after < oldestSequence - 1 {
return "requested sequence is older than the retained in-memory event log"
}
if after > latestSequence {
return "requested sequence is newer than this cmux process; cmux probably restarted"
}
return nil
}
let gap = gapReason != nil
subscriptions[subscription.id] = subscription
lock.unlock()
var resume: [String: Any] = [
"after_seq": afterSequence.map { NSNumber(value: $0) } ?? NSNull(),
"requested_after_seq": NSNumber(value: requestedAfter),
"oldest_seq": NSNumber(value: oldestSequence),
"latest_seq": NSNumber(value: latestSequence),
"next_seq": NSNumber(value: latestSequence + 1),
"gap": gap
]
if let gapReason {
resume["gap_reason"] = gapReason
}
let ack: [String: Any] = [
"type": "ack",
"protocol": Self.protocolName,
"version": Self.protocolVersion,
"boot_id": bootId,
"subscription_id": subscription.id.uuidString,
"heartbeat_interval_seconds": NSNumber(value: Self.defaultHeartbeatIntervalSeconds),
"replay_count": replay.count,
"resume": resume,
"filters": [
"names": Array(names).sorted(),
"categories": Array(categories).sorted()
]
]
return CmuxEventSubscriptionSnapshot(subscription: subscription, replay: replay, ack: ack)
}
func unsubscribe(_ subscription: CmuxEventSubscription) {
lock.lock()
subscriptions.removeValue(forKey: subscription.id)
lock.unlock()
subscription.close()
}
private func removeSubscriptionIfStillActive(_ subscription: CmuxEventSubscription) {
lock.lock()
if subscriptions[subscription.id] === subscription {
subscriptions.removeValue(forKey: subscription.id)
}
lock.unlock()
}
func heartbeat(subscription: CmuxEventSubscription) -> [String: Any] {
[
"type": "heartbeat",
"protocol": Self.protocolName,
"version": Self.protocolVersion,
"boot_id": bootId,
"subscription_id": subscription.id.uuidString,
"latest_seq": NSNumber(value: latestSequence),
"occurred_at": Self.isoTimestamp(Date())
]
}
func retainedSnapshot() -> [[String: Any]] {
lock.lock()
defer { lock.unlock() }
return retained
}
#if DEBUG
func resetForTesting() {
lock.lock()
nextSequence = 1
retained.removeAll()
let active = Array(subscriptions.values)
subscriptions.removeAll()
lock.unlock()
active.forEach { $0.close() }
eventLogWriter?.resetForTesting()
}
func flushEventLogForTesting() {
eventLogWriter?.flushForTesting()
}
func setEventLogFlushSuspendedForTesting(_ suspended: Bool) {
eventLogWriter?.setFlushSuspendedForTesting(suspended)
}
func eventLogBacklogSnapshotForTesting() -> (pending: Int, dropped: Int) {
eventLogWriter?.backlogSnapshotForTesting() ?? (0, 0)
}
#endif
static func defaultEventLogURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".cmuxterm", isDirectory: true)
.appendingPathComponent("events.jsonl")
}
static func encodeLine(_ object: [String: Any]) -> String? {
let clean = sanitizedJSONValue(object)
guard JSONSerialization.isValidJSONObject(clean),
let data = try? JSONSerialization.data(withJSONObject: clean, options: [.sortedKeys]),
let string = String(data: data, encoding: .utf8) else {
return nil
}
return string.replacingOccurrences(of: "\n", with: "\\n")
}
static func int64(_ value: Any?) -> Int64? {
if let string = value as? String { return Int64(string) }
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
let type = String(cString: number.objCType)
guard ["c", "C", "s", "S", "i", "I", "l", "L", "q", "Q"].contains(type) else { return nil }
let int64 = number.int64Value
return number.compare(NSNumber(value: int64)) == .orderedSame ? int64 : nil
}
static func sanitizedJSONValue(_ value: Any) -> Any {
sanitizedJSONValue(value, depth: 0)
}
private static func sanitizedJSONValue(_ value: Any, depth: Int) -> Any {
guard depth <= maxSanitizedDepth else {
return "[truncated: max depth]"
}
let mirror = Mirror(reflecting: value)
if mirror.displayStyle == .optional {
guard let child = mirror.children.first else { return NSNull() }
return sanitizedJSONValue(child.value, depth: depth + 1)
}
switch value {
case let value as NSNull:
return value
case let value as UUID:
return value.uuidString
case let value as Date:
return isoTimestamp(value)
case let value as String:
return truncatedString(value, maxUTF8Bytes: maxSanitizedStringBytes)
case let value as NSNumber:
if CFGetTypeID(value) == CFBooleanGetTypeID() {
return value.boolValue
}
return value
case let value as Bool:
return value
case let value as Int:
return value
case let value as Int64:
return NSNumber(value: value)
case let value as UInt64:
return NSNumber(value: min(value, UInt64(Int64.max)))
case let value as Double:
return value.isFinite ? value : NSNull()
case let value as Float:
return value.isFinite ? Double(value) : NSNull()
case let value as [String: Any]:
var result: [String: Any] = [:]
for key in value.keys.sorted().prefix(maxSanitizedObjectEntries) {
result[truncatedString(key, maxUTF8Bytes: 256)] = sanitizedJSONValue(value[key] as Any, depth: depth + 1)
}
if value.count > maxSanitizedObjectEntries {
result["__cmux_truncated_entries"] = value.count - maxSanitizedObjectEntries
}
return result
case let value as [Any]:
var result = value.prefix(maxSanitizedArrayItems).map { sanitizedJSONValue($0, depth: depth + 1) }
if value.count > maxSanitizedArrayItems {
result.append(["__cmux_truncated_items": value.count - maxSanitizedArrayItems])
}
return result
default:
return truncatedString(String(describing: value), maxUTF8Bytes: maxSanitizedStringBytes)
}
}
private static func eventByApplyingEncodedByteLimit(_ event: [String: Any], maxBytes: Int) -> [String: Any] {
guard maxBytes > 0,
let line = encodeLine(event),
line.utf8.count > maxBytes else {
return event
}
var compact = event
let payload = event["payload"] as? [String: Any] ?? [:]
compact["payload_truncated"] = true
compact["payload"] = [
"truncated": true,
"reason": "event exceeded max encoded byte limit",
"max_bytes": maxBytes,
"original_payload_keys": Array(payload.keys.sorted().prefix(64))
]
if let line = encodeLine(compact), line.utf8.count <= maxBytes {
return compact
}
compact["payload"] = [
"truncated": true,
"reason": "event exceeded max encoded byte limit",
"max_bytes": maxBytes
]
return compact
}
private static func truncatedString(_ value: String, maxUTF8Bytes: Int) -> String {
guard value.utf8.count > maxUTF8Bytes else { return value }
let suffix = "..."
let budget = max(0, maxUTF8Bytes - suffix.utf8.count)
var result = ""
var used = 0
for scalar in value.unicodeScalars {
let scalarText = String(scalar)
let scalarBytes = scalarText.utf8.count
guard used + scalarBytes <= budget else { break }
result.append(scalarText)
used += scalarBytes
}
return result + suffix
}
static func isoTimestamp(_ date: Date) -> String { isoFormatterLock.lock(); defer { isoFormatterLock.unlock() }; return isoFormatter.string(from: date) }
}
+186
View File
@@ -0,0 +1,186 @@
import Foundation
import os
nonisolated private let cmuxEventLogLogger = Logger(subsystem: "com.cmuxterm.app", category: "event-log")
// Sendable safety: pending state is protected by `lock`; file IO runs on `queue`.
final class CmuxEventLogWriter: @unchecked Sendable {
static let defaultMaxPendingLines = 1_024
private static let queue = DispatchQueue(label: "com.cmuxterm.event-log", qos: .utility)
private let eventLogURL: URL
private let maxEventLogBytes: UInt64
private let maxPendingLines: Int
private let lock = NSLock()
private var pendingLines: [String] = []
private var flushScheduled = false
private var droppedLineCount = 0
#if DEBUG
private var flushSuspendedForTesting = false
#endif
init(eventLogURL: URL, maxEventLogBytes: UInt64, maxPendingLines: Int) {
self.eventLogURL = eventLogURL
self.maxEventLogBytes = max(1, maxEventLogBytes)
self.maxPendingLines = max(1, maxPendingLines)
}
func enqueue(_ line: String) {
var shouldSchedule = false
lock.lock()
if pendingLines.count >= maxPendingLines {
let removedCount = pendingLines.count - maxPendingLines + 1
pendingLines.removeFirst(removedCount)
droppedLineCount += removedCount
}
pendingLines.append(line)
#if DEBUG
if flushSuspendedForTesting {
lock.unlock()
return
}
#endif
if !flushScheduled {
flushScheduled = true
shouldSchedule = true
}
lock.unlock()
if shouldSchedule {
Self.queue.async { [self] in flushPendingLines() }
}
}
#if DEBUG
func flushForTesting() {
scheduleFlushIfNeeded()
Self.queue.sync {}
}
func setFlushSuspendedForTesting(_ suspended: Bool) {
lock.lock()
flushSuspendedForTesting = suspended
lock.unlock()
if !suspended {
scheduleFlushIfNeeded()
}
}
func backlogSnapshotForTesting() -> (pending: Int, dropped: Int) {
lock.lock()
defer { lock.unlock() }
return (pendingLines.count, droppedLineCount)
}
func resetForTesting() {
lock.lock()
pendingLines.removeAll()
flushScheduled = false
droppedLineCount = 0
flushSuspendedForTesting = false
lock.unlock()
}
#endif
private func scheduleFlushIfNeeded() {
var shouldSchedule = false
lock.lock()
#if DEBUG
guard !flushSuspendedForTesting else {
lock.unlock()
return
}
#endif
if !pendingLines.isEmpty, !flushScheduled {
flushScheduled = true
shouldSchedule = true
}
lock.unlock()
if shouldSchedule {
Self.queue.async { [self] in flushPendingLines() }
}
}
private func flushPendingLines() {
while true {
let lines: [String]
let droppedCount: Int
lock.lock()
if pendingLines.isEmpty {
flushScheduled = false
droppedCount = droppedLineCount
droppedLineCount = 0
lock.unlock()
if droppedCount > 0 {
cmuxEventLogLogger.warning("Dropped \(droppedCount, privacy: .public) cmux event log line(s) under disk backpressure")
}
return
}
lines = pendingLines
pendingLines.removeAll(keepingCapacity: true)
lock.unlock()
append(lines)
}
}
private func append(_ lines: [String]) {
guard !lines.isEmpty else { return }
do {
try FileManager.default.createDirectory(
at: eventLogURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let fileManager = FileManager.default
if !fileManager.fileExists(atPath: eventLogURL.path) {
_ = fileManager.createFile(atPath: eventLogURL.path, contents: nil)
}
var handle = try FileHandle(forWritingTo: eventLogURL)
defer { try? handle.close() }
try handle.seekToEnd()
var currentSize = Self.fileSize(at: eventLogURL, fileManager: fileManager)
for line in lines {
let data = Data((line + "\n").utf8)
if currentSize + UInt64(data.count) > maxEventLogBytes {
try handle.close()
try rotate(fileManager: fileManager)
handle = try FileHandle(forWritingTo: eventLogURL)
currentSize = 0
}
try handle.write(contentsOf: data)
currentSize += UInt64(data.count)
}
} catch {
cmuxEventLogLogger.error("Failed to append cmux event log: \(String(describing: error), privacy: .private)")
}
}
private func rotate(fileManager: FileManager) throws {
let currentSize = Self.fileSize(at: eventLogURL, fileManager: fileManager)
let rotatedURL = eventLogURL.appendingPathExtension("1")
if Self.fileSize(at: rotatedURL, fileManager: fileManager) > maxEventLogBytes {
try fileManager.removeItem(at: rotatedURL)
}
if currentSize > maxEventLogBytes {
try fileManager.removeItem(at: eventLogURL)
_ = fileManager.createFile(atPath: eventLogURL.path, contents: nil)
return
}
if fileManager.fileExists(atPath: rotatedURL.path) {
try fileManager.removeItem(at: rotatedURL)
}
if fileManager.fileExists(atPath: eventLogURL.path) {
try fileManager.moveItem(at: eventLogURL, to: rotatedURL)
}
_ = fileManager.createFile(atPath: eventLogURL.path, contents: nil)
}
private static func fileSize(at url: URL, fileManager: FileManager) -> UInt64 {
guard let size = try? fileManager.attributesOfItem(atPath: url.path)[.size] as? NSNumber else {
return 0
}
return size.uint64Value
}
}
+443
View File
@@ -0,0 +1,443 @@
import Foundation
import CMUXWorkstream
extension CmuxEventBus {
func publishWorkspaceCreated(
workspaceId: UUID,
title: String,
customTitle: String?,
currentDirectory: String,
selected: Bool,
index: Int?,
tabCount: Int?
) {
publish(
name: "workspace.created",
category: "workspace",
source: "workspace.lifecycle",
workspaceId: workspaceId.uuidString,
payload: workspacePayload(
workspaceId: workspaceId,
title: title,
customTitle: customTitle,
currentDirectory: currentDirectory,
selected: selected,
index: index,
tabCount: tabCount
)
)
}
func publishWorkspaceClosed(
workspaceId: UUID,
title: String,
customTitle: String?,
currentDirectory: String,
remainingTabCount: Int?
) {
publish(
name: "workspace.closed",
category: "workspace",
source: "workspace.lifecycle",
workspaceId: workspaceId.uuidString,
payload: workspacePayload(
workspaceId: workspaceId,
title: title,
customTitle: customTitle,
currentDirectory: currentDirectory,
selected: false,
index: nil,
tabCount: remainingTabCount
)
)
}
func publishWorkspaceSelected(
workspaceId: UUID,
title: String,
customTitle: String?,
currentDirectory: String,
previousWorkspaceId: UUID?,
index: Int?,
tabCount: Int?
) {
publish(
name: "workspace.selected",
category: "workspace",
source: "workspace.lifecycle",
workspaceId: workspaceId.uuidString,
payload: workspacePayload(
workspaceId: workspaceId,
title: title,
customTitle: customTitle,
currentDirectory: currentDirectory,
selected: true,
previousWorkspaceId: previousWorkspaceId,
index: index,
tabCount: tabCount
)
)
}
func publishWindowLifecycle(
name: String,
windowId: UUID,
workspaceId: UUID?,
workspaceCount: Int?,
selectedWorkspaceIndex: Int?,
isKeyWindow: Bool?,
isMainWindow: Bool?,
origin: String
) {
publish(
name: name,
category: "window",
source: "window.lifecycle",
workspaceId: workspaceId?.uuidString,
windowId: windowId.uuidString,
payload: [
"window_id": windowId.uuidString,
"workspace_id": workspaceId?.uuidString ?? NSNull(),
"workspace_count": workspaceCount ?? NSNull(),
"selected_workspace_index": selectedWorkspaceIndex ?? NSNull(),
"is_key_window": isKeyWindow ?? NSNull(),
"is_main_window": isMainWindow ?? NSNull(),
"origin": origin
]
)
}
func publishPaneCreated(
workspaceId: UUID,
paneId: UUID,
sourcePaneId: UUID?,
orientation: String,
surfaceId: UUID?,
origin: String
) {
publish(
name: "pane.created",
category: "pane",
source: "workspace.lifecycle",
workspaceId: workspaceId.uuidString,
surfaceId: surfaceId?.uuidString,
paneId: paneId.uuidString,
payload: [
"pane_id": paneId.uuidString,
"source_pane_id": sourcePaneId?.uuidString ?? NSNull(),
"orientation": orientation,
"surface_id": surfaceId?.uuidString ?? NSNull(),
"origin": origin
]
)
}
func publishSurfaceCreated(
workspaceId: UUID,
surfaceId: UUID,
paneId: UUID?,
kind: String,
origin: String,
focused: Bool
) {
publish(
name: "surface.created",
category: "surface",
source: "workspace.lifecycle",
workspaceId: workspaceId.uuidString,
surfaceId: surfaceId.uuidString,
paneId: paneId?.uuidString,
payload: [
"surface_id": surfaceId.uuidString,
"pane_id": paneId?.uuidString ?? NSNull(),
"kind": kind,
"origin": origin,
"focused": focused
]
)
}
func publishSurfaceSelected(
workspaceId: UUID,
surfaceId: UUID,
paneId: UUID?,
kind: String?,
previousSurfaceId: UUID?,
focused: Bool,
origin: String
) {
publish(
name: "surface.selected",
category: "surface",
source: "workspace.lifecycle",
workspaceId: workspaceId.uuidString,
surfaceId: surfaceId.uuidString,
paneId: paneId?.uuidString,
payload: [
"surface_id": surfaceId.uuidString,
"pane_id": paneId?.uuidString ?? NSNull(),
"kind": kind ?? NSNull(),
"previous_surface_id": previousSurfaceId?.uuidString ?? NSNull(),
"focused": focused,
"origin": origin
]
)
}
func publishSurfaceFocused(workspaceId: UUID, surfaceId: UUID, paneId: UUID?, kind: String?, origin: String) {
publish(
name: "surface.focused",
category: "surface",
source: "workspace.lifecycle",
workspaceId: workspaceId.uuidString,
surfaceId: surfaceId.uuidString,
paneId: paneId?.uuidString,
payload: [
"surface_id": surfaceId.uuidString,
"pane_id": paneId?.uuidString ?? NSNull(),
"kind": kind ?? NSNull(),
"origin": origin
]
)
}
func publishSurfaceClosed(workspaceId: UUID, surfaceId: UUID, paneId: UUID?, kind: String?, origin: String) {
publish(
name: "surface.closed",
category: "surface",
source: "workspace.lifecycle",
workspaceId: workspaceId.uuidString,
surfaceId: surfaceId.uuidString,
paneId: paneId?.uuidString,
payload: [
"surface_id": surfaceId.uuidString,
"pane_id": paneId?.uuidString ?? NSNull(),
"kind": kind ?? NSNull(),
"origin": origin
]
)
}
func publishPaneClosed(workspaceId: UUID, paneId: UUID, closedSurfaceIds: [UUID], origin: String) {
publish(
name: "pane.closed",
category: "pane",
source: "workspace.lifecycle",
workspaceId: workspaceId.uuidString,
paneId: paneId.uuidString,
payload: [
"pane_id": paneId.uuidString,
"closed_surface_ids": closedSurfaceIds.map(\.uuidString),
"origin": origin
]
)
}
func publishPaneFocused(workspaceId: UUID, paneId: UUID, selectedSurfaceId: UUID?, origin: String) {
publish(
name: "pane.focused",
category: "pane",
source: "workspace.lifecycle",
workspaceId: workspaceId.uuidString,
surfaceId: selectedSurfaceId?.uuidString,
paneId: paneId.uuidString,
payload: [
"pane_id": paneId.uuidString,
"selected_surface_id": selectedSurfaceId?.uuidString ?? NSNull(),
"origin": origin
]
)
}
func publishNotificationChanges(oldValue: [TerminalNotification], newValue: [TerminalNotification]) {
let oldById = Dictionary(uniqueKeysWithValues: oldValue.map { ($0.id, $0) })
let newIds = Set(newValue.map(\.id))
let removed = oldValue.filter { !newIds.contains($0.id) }
for notification in removed {
publishNotificationRemoved(notification)
}
for notification in newValue {
if let old = oldById[notification.id] {
if !old.isRead, notification.isRead {
publishNotificationRead(
ids: [notification.id.uuidString],
workspaceId: notification.tabId,
surfaceId: notification.surfaceId
)
}
} else {
let replacedIds = removed
.filter { $0.tabId == notification.tabId && $0.surfaceId == notification.surfaceId }
.map { $0.id.uuidString }
publishNotificationCreated(notification, delivery: "store", replacedNotificationIds: replacedIds)
}
}
}
func publishNotificationCreated(
_ notification: TerminalNotification,
delivery: String,
replacedNotificationIds: [String]
) {
publishNotificationLifecycle(
name: "notification.created",
notification: notification,
payload: [
"delivery": delivery,
"replaced_notification_ids": replacedNotificationIds
]
)
}
func publishNotificationRead(ids: [String], workspaceId: UUID?, surfaceId: UUID?) {
guard !ids.isEmpty else { return }
publish(
name: "notification.read",
category: "notification",
source: "notification.store",
workspaceId: workspaceId?.uuidString,
surfaceId: surfaceId?.uuidString,
payload: [
"notification_ids": ids,
"count": ids.count
]
)
}
func publishNotificationRemoved(_ notification: TerminalNotification) {
publishNotificationLifecycle(
name: "notification.removed",
notification: notification
)
}
func publishNotificationCleared(ids: [String], workspaceId: UUID?, surfaceId: UUID?) {
guard !ids.isEmpty else { return }
publish(
name: "notification.cleared",
category: "notification",
source: "notification.store",
workspaceId: workspaceId?.uuidString,
surfaceId: surfaceId?.uuidString,
payload: [
"notification_ids": ids,
"count": ids.count
]
)
}
private func publishNotificationLifecycle(
name: String,
notification: TerminalNotification,
payload extraPayload: [String: Any] = [:]
) {
var payload = CmuxSocketEventMapper.redactedNotificationParams([
"notification_id": notification.id.uuidString,
"workspace_id": notification.tabId.uuidString,
"surface_id": notification.surfaceId?.uuidString ?? NSNull(),
"title": notification.title,
"subtitle": notification.subtitle,
"body": notification.body,
"created_at": notification.createdAt,
"is_read": notification.isRead
])
extraPayload.forEach { payload[$0.key] = $0.value }
publish(
name: name,
category: "notification",
source: "notification.store",
workspaceId: notification.tabId.uuidString,
surfaceId: notification.surfaceId?.uuidString,
payload: payload
)
}
// swiftlint:disable:next discouraged_optional_collection
func publishWorkstreamEvent(_ event: WorkstreamEvent, phase: String, result: [String: Any]? = nil) {
var payload = Self.workstreamPayload(event)
payload["phase"] = phase
if let result {
payload["result"] = result
}
publish(
name: "agent.hook.\(event.hookEventName.rawValue)",
category: "agent",
source: event.source,
workspaceId: event.workspaceId,
payload: payload
)
publish(
name: "feed.item.\(phase)",
category: "feed",
source: event.source,
workspaceId: event.workspaceId,
payload: payload
)
}
static func workstreamPayload(_ event: WorkstreamEvent) -> [String: Any] {
var payload: [String: Any] = [
"session_id": event.sessionId,
"hook_event_name": event.hookEventName.rawValue,
"_source": event.source,
"workspace_id": event.workspaceId ?? NSNull(),
"cwd": event.cwd ?? NSNull(),
"tool_name": event.toolName ?? NSNull(),
"_opencode_request_id": event.requestId ?? NSNull(),
"_ppid": event.ppid ?? NSNull(),
"_received_at": Self.isoTimestamp(event.receivedAt)
]
var redactedFields: [String] = []
if let toolInputJSON = event.toolInputJSON {
payload["tool_input"] = NSNull()
payload["tool_input_length"] = toolInputJSON.count
redactedFields.append("tool_input")
}
if let context = event.context, !context.isEmpty {
payload["context"] = NSNull()
if let contextLength = encodedByteCount(context) {
payload["context_length"] = contextLength
}
redactedFields.append("context")
}
if let extraFieldsJSON = event.extraFieldsJSON {
payload["extra_fields"] = NSNull()
payload["extra_fields_length"] = extraFieldsJSON.count
redactedFields.append("extra_fields")
}
if !redactedFields.isEmpty {
payload["redacted_fields"] = redactedFields
}
return payload
}
private static func encodedByteCount<T: Encodable>(_ value: T) -> Int? {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return try? encoder.encode(value).count
}
private func workspacePayload(
workspaceId: UUID,
title: String,
customTitle: String?,
currentDirectory: String,
selected: Bool,
previousWorkspaceId: UUID? = nil,
index: Int?,
tabCount: Int?
) -> [String: Any] {
[
"workspace_id": workspaceId.uuidString,
"title": title,
"custom_title": customTitle ?? NSNull(),
"cwd": currentDirectory,
"selected": selected,
"previous_workspace_id": previousWorkspaceId?.uuidString ?? NSNull(),
"index": index ?? NSNull(),
"tab_count": tabCount ?? NSNull()
]
}
}
+119
View File
@@ -0,0 +1,119 @@
import Darwin
import Foundation
extension TerminalController {
nonisolated func isEventsStreamRequest(_ line: String) -> Bool {
guard line.hasPrefix("{"),
let data = line.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let method = object["method"] as? String else {
return false
}
return method == "events.stream"
}
nonisolated func handleEventsStreamRequest(_ line: String, socket: Int32) {
guard let data = line.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
_ = writeEventsStreamLine([
"type": "error",
"ok": false,
"error": ["code": "invalid_request", "message": "events.stream requires a JSON object"]
], socket: socket)
return
}
let params = object["params"] as? [String: Any] ?? [:]
let afterSequence = CmuxEventBus.int64(params["after_seq"] ?? params["after"])
let names = Self.stringSet(params["names"] ?? params["name"])
let categories = Self.stringSet(params["categories"] ?? params["category"])
let includeHeartbeats = Self.boolParam(params["include_heartbeats"] ?? params["include_heartbeat"]) ?? true
let snapshot = CmuxEventBus.shared.subscribe(
afterSequence: afterSequence,
names: names,
categories: categories
)
defer { CmuxEventBus.shared.unsubscribe(snapshot.subscription) }
guard writeEventsStreamLine(snapshot.ack, socket: socket) else { return }
for event in snapshot.replay {
guard writeEventsStreamLine(event, socket: socket) else { return }
}
while true {
if let event = snapshot.subscription.next(timeout: CmuxEventBus.defaultHeartbeatIntervalSeconds) {
guard writeEventsStreamLine(event, socket: socket) else { return }
} else if snapshot.subscription.isClosed {
if let reason = snapshot.subscription.closeReason {
_ = writeEventsStreamLine([
"type": "error",
"ok": false,
"error": [
"code": "slow_consumer",
"message": reason,
"latest_seq": NSNumber(value: CmuxEventBus.shared.latestSequence)
]
], socket: socket)
}
return
} else if includeHeartbeats {
let heartbeat = CmuxEventBus.shared.heartbeat(subscription: snapshot.subscription)
guard writeEventsStreamLine(heartbeat, socket: socket) else { return }
} else if Self.socketPeerClosed(socket) {
return
}
}
}
nonisolated func publishSocketEvents(command: String, response: String) {
CmuxSocketEventMapper.publish(command: command, response: response)
}
private nonisolated func writeEventsStreamLine(_ object: [String: Any], socket: Int32) -> Bool {
guard let line = CmuxEventBus.encodeLine(object) else { return false }
return Self.writeAllToSocket(Data((line + "\n").utf8), to: socket)
}
private nonisolated static func stringSet(_ value: Any?) -> Set<String> {
if let string = value as? String {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? [] : [trimmed]
}
if let values = value as? [String] {
return Set(values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty })
}
if let values = value as? [Any] {
return Set(values.compactMap { ($0 as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty })
}
return []
}
private nonisolated static func boolParam(_ value: Any?) -> Bool? {
if let number = value as? NSNumber {
if CFGetTypeID(number) == CFBooleanGetTypeID() { return number.boolValue }
if number.compare(NSNumber(value: 0)) == .orderedSame { return false }
if number.compare(NSNumber(value: 1)) == .orderedSame { return true }
return nil
}
guard let string = value as? String else { return nil }
switch string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "true", "1": return true
case "false", "0": return false
default: return nil
}
}
private nonisolated static func socketPeerClosed(_ socket: Int32) -> Bool {
var byte: UInt8 = 0
let result = recv(socket, &byte, 1, MSG_PEEK | MSG_DONTWAIT)
if result == 0 {
return true
}
if result > 0 {
return false
}
let errorCode = errno
return errorCode != EAGAIN && errorCode != EWOULDBLOCK && errorCode != EINTR
}
}
+286
View File
@@ -0,0 +1,286 @@
import Foundation
import AppKit
import Bonsplit
@MainActor
private enum CmuxSelectionEventState {
static var selectedSurfaceByWorkspacePane: [String: UUID] = [:]
static var focusedPaneByWorkspace: [UUID: UUID] = [:]
static var focusedSurfaceByWorkspace: [UUID: UUID] = [:]
static func paneKey(workspaceId: UUID, paneId: UUID) -> String {
"\(workspaceId.uuidString):\(paneId.uuidString)"
}
static func clearWorkspace(_ workspaceId: UUID) {
selectedSurfaceByWorkspacePane = selectedSurfaceByWorkspacePane.filter {
!$0.key.hasPrefix("\(workspaceId.uuidString):")
}
focusedPaneByWorkspace.removeValue(forKey: workspaceId)
focusedSurfaceByWorkspace.removeValue(forKey: workspaceId)
}
static func clearPane(workspaceId: UUID, paneId: UUID) {
selectedSurfaceByWorkspacePane.removeValue(forKey: paneKey(workspaceId: workspaceId, paneId: paneId))
if focusedPaneByWorkspace[workspaceId] == paneId {
focusedPaneByWorkspace.removeValue(forKey: workspaceId)
}
}
static func clearSurface(workspaceId: UUID, surfaceId: UUID) {
selectedSurfaceByWorkspacePane = selectedSurfaceByWorkspacePane.filter { $0.value != surfaceId }
if focusedSurfaceByWorkspace[workspaceId] == surfaceId {
focusedSurfaceByWorkspace.removeValue(forKey: workspaceId)
}
}
}
extension TabManager {
func publishCmuxWorkspaceCreated(_ workspace: Workspace, selected: Bool) {
CmuxEventBus.shared.publishWorkspaceCreated(
workspaceId: workspace.id,
title: workspace.cmuxEventWorkspaceTitle,
customTitle: workspace.customTitle,
currentDirectory: workspace.currentDirectory,
selected: selected,
index: tabs.firstIndex(where: { $0.id == workspace.id }),
tabCount: tabs.count
)
}
func publishCmuxInitialSurfaceCreated(_ workspace: Workspace, selected: Bool) {
guard let terminalPanel = workspace.focusedTerminalPanel else { return }
workspace.publishCmuxSurfaceCreated(
terminalPanel.id,
paneId: workspace.paneId(forPanelId: terminalPanel.id),
kind: "terminal",
origin: "workspace_initial",
focused: selected
)
}
func publishCmuxWorkspaceClosed(_ workspace: Workspace) {
CmuxEventBus.shared.publishWorkspaceClosed(
workspaceId: workspace.id,
title: workspace.cmuxEventWorkspaceTitle,
customTitle: workspace.customTitle,
currentDirectory: workspace.currentDirectory,
remainingTabCount: tabs.count
)
CmuxSelectionEventState.clearWorkspace(workspace.id)
}
func publishCmuxWorkspaceSelected(_ workspace: Workspace) {
CmuxEventBus.shared.publishWorkspaceSelected(
workspaceId: workspace.id,
title: workspace.cmuxEventWorkspaceTitle,
customTitle: workspace.customTitle,
currentDirectory: workspace.currentDirectory,
previousWorkspaceId: nil,
index: tabs.firstIndex(where: { $0.id == workspace.id }),
tabCount: tabs.count
)
}
func publishCmuxWorkspaceSelectedChange(from previousWorkspaceId: UUID?) {
guard let selectedTabId,
let workspace = tabs.first(where: { $0.id == selectedTabId }) else { return }
CmuxEventBus.shared.publishWorkspaceSelected(
workspaceId: workspace.id,
title: workspace.cmuxEventWorkspaceTitle,
customTitle: workspace.customTitle,
currentDirectory: workspace.currentDirectory,
previousWorkspaceId: previousWorkspaceId,
index: tabs.firstIndex(where: { $0.id == workspace.id }),
tabCount: tabs.count
)
}
}
extension Workspace {
var cmuxEventWorkspaceTitle: String {
customTitle ?? title
}
func publishCmuxSplitCreated(
_ paneId: PaneID,
sourcePaneId: PaneID?,
orientation: SplitOrientation,
surfaceId: UUID?,
kind: String,
origin: String,
focused: Bool
) {
CmuxEventBus.shared.publishPaneCreated(
workspaceId: id,
paneId: paneId.id,
sourcePaneId: sourcePaneId?.id,
orientation: orientation.rawValue,
surfaceId: surfaceId,
origin: origin
)
if let surfaceId {
publishCmuxSurfaceCreated(surfaceId, paneId: paneId, kind: kind, origin: origin, focused: focused)
}
}
func publishCmuxSurfaceCreated(
_ surfaceId: UUID,
paneId: PaneID?,
kind: String,
origin: String,
focused: Bool
) {
CmuxEventBus.shared.publishSurfaceCreated(
workspaceId: id,
surfaceId: surfaceId,
paneId: paneId?.id,
kind: kind,
origin: origin,
focused: focused
)
}
func publishCmuxSurfaceClosed(_ surfaceId: UUID, paneId: PaneID?, panel: (any Panel)?, origin: String) {
CmuxEventBus.shared.publishSurfaceClosed(
workspaceId: id,
surfaceId: surfaceId,
paneId: paneId?.id,
kind: panel.map(Self.cmuxEventSurfaceKind),
origin: origin
)
CmuxSelectionEventState.clearSurface(workspaceId: id, surfaceId: surfaceId)
}
func publishCmuxPaneClosed(_ paneId: PaneID, closedPanelIds: [UUID], origin: String) {
CmuxEventBus.shared.publishPaneClosed(
workspaceId: id,
paneId: paneId.id,
closedSurfaceIds: closedPanelIds,
origin: origin
)
CmuxSelectionEventState.clearPane(workspaceId: id, paneId: paneId.id)
}
func publishCmuxFocusedSelection(paneId: PaneID, surfaceId: UUID, origin: String) {
let paneKey = CmuxSelectionEventState.paneKey(workspaceId: id, paneId: paneId.id)
let previousSelectedSurfaceId = CmuxSelectionEventState.selectedSurfaceByWorkspacePane[paneKey]
let kind = panels[surfaceId].map(Self.cmuxEventSurfaceKind)
if previousSelectedSurfaceId != surfaceId {
CmuxSelectionEventState.selectedSurfaceByWorkspacePane[paneKey] = surfaceId
CmuxEventBus.shared.publishSurfaceSelected(
workspaceId: id,
surfaceId: surfaceId,
paneId: paneId.id,
kind: kind,
previousSurfaceId: previousSelectedSurfaceId,
focused: true,
origin: origin
)
}
if CmuxSelectionEventState.focusedPaneByWorkspace[id] != paneId.id {
CmuxSelectionEventState.focusedPaneByWorkspace[id] = paneId.id
CmuxEventBus.shared.publishPaneFocused(
workspaceId: id,
paneId: paneId.id,
selectedSurfaceId: surfaceId,
origin: origin
)
}
if CmuxSelectionEventState.focusedSurfaceByWorkspace[id] != surfaceId {
CmuxSelectionEventState.focusedSurfaceByWorkspace[id] = surfaceId
CmuxEventBus.shared.publishSurfaceFocused(
workspaceId: id,
surfaceId: surfaceId,
paneId: paneId.id,
kind: kind,
origin: origin
)
}
}
static func cmuxEventSurfaceKind(_ panel: any Panel) -> String {
switch panel.panelType {
case .terminal:
return "terminal"
case .browser:
return "browser"
case .markdown:
return "markdown"
case .filePreview:
return "file_preview"
}
}
}
@MainActor
private enum MainWindowKeyRegainRefresh {
static func refresh(window: NSWindow, context: AppDelegate.MainWindowContext) {
// Window focus regain owns the redraw invariant. Cursor tracking and
// focused subviews can update themselves only after this invalidation.
invalidateContentDisplayTree(window: window)
_ = context.keyboardFocusCoordinator.restoreTargetAfterWindowBecameKey()
}
private static func invalidateContentDisplayTree(window: NSWindow) {
guard let contentView = window.contentView else { return }
invalidateDisplayTree(rootedAt: contentView)
window.invalidateCursorRects(for: contentView)
}
private static func invalidateDisplayTree(rootedAt view: NSView) {
guard !view.isHidden else { return }
view.needsDisplay = true
view.layer?.setNeedsDisplay()
for subview in view.subviews {
invalidateDisplayTree(rootedAt: subview)
}
}
}
extension AppDelegate {
func handleCmuxWindowBecameKey(_ note: Notification) {
guard let window = note.object as? NSWindow else { return }
MainActor.assumeIsolated {
let context = contextForMainTerminalWindow(window)
setActiveMainWindow(window)
if let windowId = mainWindowId(from: window) {
publishCmuxWindowLifecycle(name: "window.keyed", windowId: windowId, origin: "appkit_key")
}
if let context {
MainWindowKeyRegainRefresh.refresh(window: window, context: context)
}
}
}
func handleCmuxWindowResignedKey(_ note: Notification) {
guard let window = note.object as? NSWindow else { return }
MainActor.assumeIsolated {
if let windowId = mainWindowId(from: window) {
publishCmuxWindowLifecycle(name: "window.unkeyed", windowId: windowId, origin: "appkit_key")
}
}
}
func publishCmuxWindowLifecycle(name: String, windowId: UUID, origin: String) {
let manager = tabManagerFor(windowId: windowId)
let workspaceId = manager?.selectedTabId
let selectedWorkspaceIndex = workspaceId.flatMap { selectedId in
manager?.tabs.firstIndex(where: { $0.id == selectedId })
}
let window = mainWindow(for: windowId)
CmuxEventBus.shared.publishWindowLifecycle(
name: name,
windowId: windowId,
workspaceId: workspaceId,
workspaceCount: manager?.tabs.count,
selectedWorkspaceIndex: selectedWorkspaceIndex,
isKeyWindow: window?.isKeyWindow,
isMainWindow: window?.isMainWindow,
origin: origin
)
}
}
+359
View File
@@ -0,0 +1,359 @@
import Foundation
enum CmuxSSHURLParseError: Error, Equatable {
case missingDestination
case destinationTooLong(maxLength: Int)
case destinationContainsUnsafeCharacters
case destinationStartsWithDash
case titleTooLong(maxLength: Int)
case titleContainsUnsafeCharacters
case invalidPort
case invalidIntegerParameter(String)
case invalidHostKeyPolicy(String)
case invalidBooleanParameter(String)
case conflictingDestinationParameters
case conflictingTitleParameters
case duplicateParameter(String)
case unsupportedParameter(String)
case multipleLinks
}
struct CmuxSSHURLRequest: Equatable {
static let maxDestinationLength = 256
static let maxTitleLength = 160
static let supportedSchemes: Set<String> = ["cmux", "cmux-nightly", "cmux-dev"]
static var activeSupportedSchemes: Set<String> {
[AuthEnvironment.callbackScheme.lowercased()]
}
let originalURL: URL
let destination: String
let port: Int?
let title: String?
let sshOptions: [String]
let noFocus: Bool
var cliArguments: [String] {
var parts = ["ssh"]
if let port {
parts += ["--port", String(port)]
}
if let title = normalizedTitle {
parts += ["--name", title]
}
for sshOption in sshOptions {
parts += ["--ssh-option", sshOption]
}
if noFocus {
parts.append("--no-focus")
}
parts.append(destination)
return parts
}
var cliPreview: String {
cliPreview(socketPath: nil)
}
func cliPreview(socketPath: String?) -> String {
var parts = ["cmux"]
if let socketPath, !socketPath.isEmpty {
parts += ["--socket", socketPath]
}
parts += cliArguments
return parts.map(Self.previewArgument).joined(separator: " ")
}
var displayTarget: String {
if let port {
return "\(destination):\(port)"
}
return destination
}
private var normalizedTitle: String? {
guard let title else { return nil }
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
static func parse(
_ url: URL,
supportedSchemes: Set<String> = activeSupportedSchemes
) -> Result<CmuxSSHURLRequest?, CmuxSSHURLParseError> {
guard isSupportedScheme(url.scheme, supportedSchemes: supportedSchemes) else {
return .success(nil)
}
guard sshTarget(from: url) else {
return .success(nil)
}
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return .failure(.missingDestination)
}
let queryItems = components.queryItems ?? []
let allowedQueryNames: Set<String> = [
"host",
"user",
"port",
"title",
"name",
"connect-timeout",
"server-alive-interval",
"server-alive-count-max",
"host-key-policy",
"no-focus"
]
var seenQueryNames = Set<String>()
for item in queryItems {
let name = item.name.lowercased()
guard allowedQueryNames.contains(name) else {
return .failure(.unsupportedParameter(displayParameterName(item.name)))
}
guard seenQueryNames.insert(name).inserted else {
return .failure(.duplicateParameter(displayParameterName(item.name)))
}
}
guard !containsPathDestination(url) else {
return .failure(.conflictingDestinationParameters)
}
guard let hostValue = normalizedQueryValue(namedAnyOf: ["host"], in: queryItems) else {
return .failure(.missingDestination)
}
guard !hostValue.hasPrefix("-") else {
return .failure(.destinationStartsWithDash)
}
guard isAllowedSSHHost(hostValue) else {
return .failure(.destinationContainsUnsafeCharacters)
}
let userValue = normalizedQueryValue(namedAnyOf: ["user"], in: queryItems)
if let userValue {
guard !userValue.hasPrefix("-") else {
return .failure(.destinationStartsWithDash)
}
guard isAllowedSSHUser(userValue) else {
return .failure(.destinationContainsUnsafeCharacters)
}
}
let destination = userValue.map { "\($0)@\(hostValue)" } ?? hostValue
guard destination.count <= maxDestinationLength else {
return .failure(.destinationTooLong(maxLength: maxDestinationLength))
}
let parsedPort: Int?
if let portValue = normalizedQueryValue(namedAnyOf: ["port"], in: queryItems) {
guard let value = Int(portValue), value > 0, value <= 65535 else {
return .failure(.invalidPort)
}
parsedPort = value
} else {
parsedPort = nil
}
let titleValue = normalizedQueryValue(namedAnyOf: ["title"], in: queryItems)
let nameValue = normalizedQueryValue(namedAnyOf: ["name"], in: queryItems)
guard titleValue == nil || nameValue == nil else {
return .failure(.conflictingTitleParameters)
}
let title = titleValue ?? nameValue
if let title {
guard title.count <= maxTitleLength else {
return .failure(.titleTooLong(maxLength: maxTitleLength))
}
guard !containsUnsafeHiddenCharacter(title) else {
return .failure(.titleContainsUnsafeCharacters)
}
}
let sshOptions: [String]
switch structuredSSHOptions(from: queryItems) {
case .success(let options):
sshOptions = options
case .failure(let error):
return .failure(error)
}
let noFocus: Bool
switch normalizedBooleanValue(named: "no-focus", in: queryItems) {
case .success(let value):
noFocus = value
case .failure(let error):
return .failure(error)
}
return .success(
CmuxSSHURLRequest(
originalURL: url,
destination: destination,
port: parsedPort,
title: title,
sshOptions: sshOptions,
noFocus: noFocus
)
)
}
private static func isSupportedScheme(_ scheme: String?, supportedSchemes: Set<String>) -> Bool {
guard let scheme = scheme?.lowercased() else { return false }
return supportedSchemes.contains(scheme)
}
private static func sshTarget(from url: URL) -> Bool {
if let host = url.host?.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased(),
!host.isEmpty {
return host == "ssh"
}
let firstPathComponent = url.path
.split(separator: "/")
.first
.map { String($0).lowercased() }
return firstPathComponent == "ssh"
}
private static func containsPathDestination(_ url: URL) -> Bool {
if let host = url.host?.lowercased(), host == "ssh" {
return !url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty
}
let pathComponents = url.path
.split(separator: "/", omittingEmptySubsequences: true)
.map(String.init)
return pathComponents.first?.lowercased() == "ssh" && pathComponents.count > 1
}
private static func normalizedQueryValue(namedAnyOf names: Set<String>, in queryItems: [URLQueryItem]) -> String? {
guard let value = queryItems.first(where: { names.contains($0.name.lowercased()) })?.value else {
return nil
}
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
return normalized.isEmpty ? nil : normalized
}
private static func structuredSSHOptions(from queryItems: [URLQueryItem]) -> Result<[String], CmuxSSHURLParseError> {
var options: [String] = []
if let value = normalizedQueryValue(namedAnyOf: ["connect-timeout"], in: queryItems) {
switch boundedInteger(value, parameter: "connect-timeout", range: 1...600) {
case .success(let seconds):
options.append("ConnectTimeout=\(seconds)")
case .failure(let error):
return .failure(error)
}
}
if let value = normalizedQueryValue(namedAnyOf: ["server-alive-interval"], in: queryItems) {
switch boundedInteger(value, parameter: "server-alive-interval", range: 1...3600) {
case .success(let seconds):
options.append("ServerAliveInterval=\(seconds)")
case .failure(let error):
return .failure(error)
}
}
if let value = normalizedQueryValue(namedAnyOf: ["server-alive-count-max"], in: queryItems) {
switch boundedInteger(value, parameter: "server-alive-count-max", range: 1...100) {
case .success(let count):
options.append("ServerAliveCountMax=\(count)")
case .failure(let error):
return .failure(error)
}
}
if let value = normalizedQueryValue(namedAnyOf: ["host-key-policy"], in: queryItems) {
switch value.lowercased() {
case "accept-new":
options.append("StrictHostKeyChecking=accept-new")
case "ask":
options.append("StrictHostKeyChecking=ask")
case "strict", "yes":
options.append("StrictHostKeyChecking=yes")
default:
return .failure(.invalidHostKeyPolicy("host-key-policy"))
}
}
return .success(options)
}
private static func boundedInteger(_ value: String, parameter: String, range: ClosedRange<Int>) -> Result<Int, CmuxSSHURLParseError> {
guard !containsUnsafeHiddenCharacter(value),
value.range(of: #"^[0-9]+$"#, options: .regularExpression) != nil,
let integer = Int(value),
range.contains(integer) else {
return .failure(.invalidIntegerParameter(parameter))
}
return .success(integer)
}
private static func normalizedBooleanValue(named name: String, in queryItems: [URLQueryItem]) -> Result<Bool, CmuxSSHURLParseError> {
guard let item = queryItems.first(where: { $0.name.lowercased() == name }) else {
return .success(false)
}
guard let rawValue = item.value else {
return .success(true)
}
let normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalized.isEmpty {
return .success(true)
}
switch normalized {
case "1", "true", "yes", "on":
return .success(true)
case "0", "false", "no", "off":
return .success(false)
default:
return .failure(.invalidBooleanParameter(displayParameterName(item.name)))
}
}
private static func isAllowedSSHHost(_ value: String) -> Bool {
guard !containsUnsafeHiddenCharacter(value) else { return false }
if value.hasPrefix("[") || value.hasSuffix("]") {
guard value.hasPrefix("["), value.hasSuffix("]") else { return false }
let inner = String(value.dropFirst().dropLast())
guard !inner.isEmpty else { return false }
let allowed = CharacterSet(charactersIn: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz:.%")
return inner.unicodeScalars.allSatisfy { allowed.contains($0) }
}
let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._%-")
return value.unicodeScalars.allSatisfy { allowed.contains($0) }
}
private static func isAllowedSSHUser(_ value: String) -> Bool {
guard !containsUnsafeHiddenCharacter(value) else { return false }
let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._%+=-")
return value.unicodeScalars.allSatisfy { allowed.contains($0) }
}
private static func containsUnsafeHiddenCharacter(_ value: String) -> Bool {
value.unicodeScalars.contains { scalar in
switch scalar.properties.generalCategory {
case .control, .format, .lineSeparator, .paragraphSeparator:
return true
default:
return false
}
}
}
private static func previewArgument(_ value: String) -> String {
if value.range(of: #"[^A-Za-z0-9_./:=+@%\-\[\]]"#, options: .regularExpression) == nil {
return value
}
let escaped = value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
private static func displayParameterName(_ name: String) -> String {
if name.isEmpty || containsUnsafeHiddenCharacter(name) {
return "?"
}
let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-")
guard name.unicodeScalars.allSatisfy({ allowed.contains($0) }) else {
return "?"
}
let prefix = String(name.prefix(64))
return prefix.count == name.count ? name : "\(prefix)..."
}
}
+92
View File
@@ -0,0 +1,92 @@
enum SidebarWorkspaceDetailDefaults {
static let showBranchDirectory = true
static let showPullRequests = true
static let showSSH = true
static let showPorts = true
static let showLog = true
static let showProgress = true
static let showCustomMetadata = true
}
enum AutomationSettings {
static let portBaseKey = "cmuxPortBase"
static let portRangeKey = "cmuxPortRange"
static let defaultPortBase = 9100
static let defaultPortRange = 10
}
extension CmuxSettingsFileStore {
// Keep this in sync with the parser below and the web schema/docs. Settings UI rows
// validate against this set so new persisted settings need an explicit cmux.json review.
static let supportedSettingsJSONPaths: Set<String> = [
"app.language",
"app.appearance",
"app.appIcon",
"app.menuBarOnly",
"app.newWorkspacePlacement",
"app.minimalMode",
"app.keepWorkspaceOpenWhenClosingLastSurface",
"app.focusPaneOnFirstClick",
"app.preferredEditor",
"app.openMarkdownInCmuxViewer",
"app.iMessageMode",
"app.reorderOnNotification",
"app.sendAnonymousTelemetry",
"app.warnBeforeQuit",
"app.warnBeforeClosingTab",
"app.renameSelectsExistingName",
"app.commandPaletteSearchesAllSurfaces",
"terminal.showScrollBar",
"terminal.autoResumeAgentSessions",
"notifications.dockBadge",
"notifications.showInMenuBar",
"notifications.unreadPaneRing",
"notifications.paneFlash",
"notifications.sound",
"notifications.customSoundFilePath",
"notifications.command",
"sidebar.hideAllDetails",
"sidebar.branchLayout",
"sidebar.showNotificationMessage",
"sidebar.showBranchDirectory",
"sidebar.showPullRequests",
"sidebar.makePullRequestsClickable",
"sidebar.openPullRequestLinksInCmuxBrowser",
"sidebar.openPortLinksInCmuxBrowser",
"sidebar.showSSH",
"sidebar.showPorts",
"sidebar.showLog",
"sidebar.showProgress",
"sidebar.showCustomMetadata",
"workspaceColors.indicatorStyle",
"workspaceColors.selectionColor",
"workspaceColors.notificationBadgeColor",
"workspaceColors.colors",
"workspaceColors.paletteOverrides",
"workspaceColors.customColors",
"sidebarAppearance.matchTerminalBackground",
"sidebarAppearance.tintColor",
"sidebarAppearance.lightModeTintColor",
"sidebarAppearance.darkModeTintColor",
"sidebarAppearance.tintOpacity",
"automation.socketControlMode",
"automation.socketPassword",
"automation.claudeCodeIntegration",
"automation.claudeBinaryPath",
"automation.cursorIntegration",
"automation.geminiIntegration",
"automation.portBase",
"automation.portRange",
"browser.defaultSearchEngine",
"browser.showSearchSuggestions",
"browser.theme",
"browser.openTerminalLinksInCmuxBrowser",
"browser.interceptTerminalOpenCommandInCmuxBrowser",
"browser.hostsToOpenInEmbeddedBrowser",
"browser.urlsToAlwaysOpenExternally",
"browser.insecureHttpHostsAllowedInEmbeddedBrowser",
"browser.showImportHintOnBlankTabs",
"browser.reactGrabVersion",
"shortcuts.bindings",
]
}
+250
View File
@@ -0,0 +1,250 @@
import Foundation
enum CmuxSocketEventMapper {
static func publish(command: String, response: String) {
if publishV2(command: command, response: response) {
return
}
publishV1(command: command, response: response)
}
private static func publishV2(command: String, response: String) -> Bool {
guard command.hasPrefix("{"),
let requestData = command.data(using: .utf8),
let request = try? JSONSerialization.jsonObject(with: requestData) as? [String: Any],
let method = request["method"] as? String else {
return false
}
guard method != "events.stream" else { return true }
let responseObject: [String: Any]
if let responseData = response.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] {
responseObject = parsed
} else {
responseObject = ["ok": false, "error": ["message": response]]
}
guard (responseObject["ok"] as? Bool) == true else {
return true
}
let params = request["params"] as? [String: Any] ?? [:]
let result = responseObject["result"] as? [String: Any] ?? [:]
publishDomainEventForV2(method: method, params: params, result: result)
return true
}
private static func publishDomainEventForV2(method: String, params: [String: Any], result: [String: Any]) {
switch method {
case "window.create", "window.focus", "window.close":
break
case "workspace.create", "workspace.select", "workspace.next", "workspace.previous", "workspace.last", "workspace.close":
break
case "workspace.rename":
publishResult(name: "workspace.renamed", category: "workspace", method: method, params: params, result: result)
case "workspace.reorder":
publishResult(name: "workspace.reordered", category: "workspace", method: method, params: params, result: result)
case "workspace.move_to_window":
publishResult(name: "workspace.moved", category: "workspace", method: method, params: params, result: result)
case "workspace.action":
publishResult(name: "workspace.action", category: "workspace", method: method, params: params, result: result)
case "surface.create", "surface.split", "browser.open_split", "markdown.open", "file.open":
break
case "surface.split_off", "surface.drag_to_split":
publishResult(name: "pane.created", category: "pane", method: method, params: params, result: result)
case "surface.focus":
break
case "surface.close":
break
case "surface.move":
publishResult(name: "surface.moved", category: "surface", method: method, params: params, result: result)
case "surface.reorder":
publishResult(name: "surface.reordered", category: "surface", method: method, params: params, result: result)
case "surface.action", "tab.action":
publishResult(name: "surface.action", category: "surface", method: method, params: params, result: result)
case "surface.send_text":
publishResult(name: "surface.input_sent", category: "surface", method: method, params: redactedInputParams(params), result: result)
case "surface.send_key":
publishResult(name: "surface.key_sent", category: "surface", method: method, params: params, result: result)
case "pane.create":
break
case "pane.focus", "pane.last":
break
case "pane.resize":
publishResult(name: "pane.resized", category: "pane", method: method, params: params, result: result)
case "pane.swap":
publishResult(name: "pane.swapped", category: "pane", method: method, params: params, result: result)
case "pane.break":
publishResult(name: "pane.broken", category: "pane", method: method, params: params, result: result)
case "pane.join":
publishResult(name: "pane.joined", category: "pane", method: method, params: params, result: result)
case "notification.create", "notification.create_for_caller", "notification.create_for_surface", "notification.create_for_target":
publishResult(name: "notification.requested", category: "notification", method: method, params: redactedNotificationParams(params), result: result)
case "notification.clear":
publishResult(name: "notification.clear_requested", category: "notification", method: method, params: params, result: result)
case "notification.dismiss":
publishResult(name: "notification.dismiss_requested", category: "notification", method: method, params: params, result: result)
case "notification.mark_read":
publishResult(name: "notification.mark_read_requested", category: "notification", method: method, params: params, result: result)
case "notification.open":
publishResult(name: "notification.open_requested", category: "notification", method: method, params: params, result: result)
case "notification.jump_to_unread":
publishResult(name: "notification.jump_to_unread_requested", category: "notification", method: method, params: params, result: result)
case "feed.permission.reply", "feed.question.reply", "feed.exit_plan.reply":
publishResult(name: "feed.item.resolved", category: "feed", method: method, params: params, result: result)
case "app.focus_override.set":
publishResult(name: "app.focus_override.changed", category: "app", method: method, params: params, result: result)
case "app.simulate_active":
publishResult(name: "app.simulated_active", category: "app", method: method, params: params, result: result)
case "browser.navigate", "browser.back", "browser.forward", "browser.reload":
publishResult(name: "browser.navigation", category: "browser", method: method, params: params, result: result)
case "browser.click", "browser.dblclick", "browser.hover", "browser.focus", "browser.press", "browser.keydown", "browser.keyup", "browser.check", "browser.uncheck", "browser.select", "browser.scroll", "browser.scroll_into_view":
publishResult(name: "browser.interaction", category: "browser", method: method, params: params, result: result)
case "browser.type", "browser.fill":
publishResult(name: "browser.input", category: "browser", method: method, params: redactedInputParams(params), result: result)
default:
break
}
}
private static func publishV1(command: String, response: String) {
let parts = command.split(separator: " ", maxSplits: 1).map(String.init)
guard let rawName = parts.first else { return }
let name = rawName.lowercased()
guard response == "OK" || response.hasPrefix("OK ") || response.hasPrefix("OK\n") || response.hasPrefix("OK:") else { return }
let args = parts.count > 1 ? parts[1] : ""
let payload: [String: Any] = ["command": name, "args": redactedV1Args(name: name, args: args)]
switch name {
case "new_window", "focus_window", "close_window":
break
case "new_workspace", "select_workspace", "close_workspace", "new_split", "new_pane", "new_surface", "open_browser":
break
case "focus_surface", "focus_surface_by_panel", "focus_pane":
break
case "close_surface":
break
case "send", "send_surface":
CmuxEventBus.shared.publish(name: "surface.input_sent", category: "surface", source: "socket.v1", payload: payload)
case "send_key", "send_key_surface":
CmuxEventBus.shared.publish(name: "surface.key_sent", category: "surface", source: "socket.v1", payload: payload)
case "notify_surface":
var payloadWithSurface = payload
let surfaceId = firstUUID(in: args)
payloadWithSurface["surface_id"] = surfaceId ?? NSNull()
CmuxEventBus.shared.publish(
name: "notification.requested",
category: "notification",
source: "socket.v1",
surfaceId: surfaceId,
payload: payloadWithSurface
)
case "notify", "notify_target", "notify_target_async":
CmuxEventBus.shared.publish(name: "notification.requested", category: "notification", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
case "clear_notifications":
CmuxEventBus.shared.publish(name: "notification.clear_requested", category: "notification", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
case "set_status", "report_meta", "report_meta_block":
CmuxEventBus.shared.publish(name: "sidebar.metadata.updated", category: "sidebar", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
case "clear_status", "clear_meta", "clear_meta_block":
CmuxEventBus.shared.publish(name: "sidebar.metadata.cleared", category: "sidebar", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
case "set_progress":
CmuxEventBus.shared.publish(name: "sidebar.progress.updated", category: "sidebar", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
case "clear_progress":
CmuxEventBus.shared.publish(name: "sidebar.progress.cleared", category: "sidebar", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
case "log":
CmuxEventBus.shared.publish(name: "sidebar.log.appended", category: "sidebar", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
case "clear_log":
CmuxEventBus.shared.publish(name: "sidebar.log.cleared", category: "sidebar", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
case "reset_sidebar":
CmuxEventBus.shared.publish(name: "sidebar.reset", category: "sidebar", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
case "reload_config":
CmuxEventBus.shared.publish(name: "config.reloaded", category: "config", source: "socket.v1", payload: payload)
case "set_app_focus":
CmuxEventBus.shared.publish(name: "app.focus_override.changed", category: "app", source: "socket.v1", payload: payload)
case "simulate_app_active":
CmuxEventBus.shared.publish(name: "app.simulated_active", category: "app", source: "socket.v1", payload: payload)
default:
break
}
}
private static func publishResult(name: String, category: String, method: String, params: [String: Any], result: [String: Any]) {
let workspaceId = stringValue(result["workspace_id"] ?? params["workspace_id"])
let surfaceId = stringValue(result["surface_id"] ?? params["surface_id"])
let paneId = stringValue(result["pane_id"] ?? params["pane_id"])
let windowId = stringValue(result["window_id"] ?? params["window_id"])
CmuxEventBus.shared.publish(
name: name,
category: category,
source: "socket.v2",
workspaceId: workspaceId,
surfaceId: surfaceId,
paneId: paneId,
windowId: windowId,
payload: [
"method": method,
"params": params,
"result": result
]
)
}
private static func redactedInputParams(_ params: [String: Any]) -> [String: Any] {
var out = params
if let text = out["text"] as? String {
out["text"] = NSNull()
out["text_length"] = text.count
out["redacted_fields"] = ["text"]
}
if let value = out["value"] as? String {
out["value"] = NSNull()
out["value_length"] = value.count
out["redacted_fields"] = ((out["redacted_fields"] as? [String]) ?? []) + ["value"]
}
return out
}
static func redactedNotificationParams(_ params: [String: Any]) -> [String: Any] {
var out = params
var redactedFields = (out["redacted_fields"] as? [String]) ?? []
for key in ["title", "subtitle", "body"] {
if let text = out[key] as? String {
out[key] = NSNull()
out["\(key)_length"] = text.count
if !redactedFields.contains(key) {
redactedFields.append(key)
}
}
}
if !redactedFields.isEmpty {
out["redacted_fields"] = redactedFields
}
return out
}
private static func redactedV1Args(name: String, args: String) -> String {
switch name {
case "send", "send_surface", "notify", "notify_surface", "notify_target", "notify_target_async":
return "<redacted>"
default:
return args
}
}
private static func firstUUID(in text: String) -> String? {
for token in text.split(whereSeparator: { $0.isWhitespace }) {
let cleaned = token.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
if UUID(uuidString: cleaned) != nil {
return cleaned
}
}
return nil
}
private static func stringValue(_ value: Any?) -> String? {
if let string = value as? String, !string.isEmpty { return string }
if let uuid = value as? UUID { return uuid.uuidString }
return nil
}
}
+90
View File
@@ -0,0 +1,90 @@
import Darwin
import Foundation
struct CmuxTopProcessArguments: Sendable {
let arguments: [String]
let environment: [String: String]
}
extension CmuxTopProcessSnapshot {
static func processArgumentsAndEnvironment(for pid: Int) -> CmuxTopProcessArguments? {
guard pid > 0, pid <= Int(Int32.max),
let bytes = kernProcArgsBytes(for: pid) else {
return nil
}
return processArgumentsAndEnvironment(fromKernProcArgs: bytes)
}
static func processArgumentsAndEnvironment(fromKernProcArgs bytes: [UInt8]) -> CmuxTopProcessArguments? {
guard bytes.count > MemoryLayout<Int32>.size else { return nil }
var argcRaw: Int32 = 0
withUnsafeMutableBytes(of: &argcRaw) { rawBuffer in
rawBuffer.copyBytes(from: bytes.prefix(MemoryLayout<Int32>.size))
}
let argc = Int(Int32(littleEndian: argcRaw))
guard argc > 0 else { return nil }
var index = MemoryLayout<Int32>.size
skipString(in: bytes, index: &index)
skipNulls(in: bytes, index: &index)
var arguments: [String] = []
for _ in 0..<argc {
guard index < bytes.count else { return nil }
let start = index
skipString(in: bytes, index: &index)
if start < index,
let argument = String(bytes: bytes[start..<index], encoding: .utf8) {
arguments.append(argument)
}
skipNulls(in: bytes, index: &index)
}
var environment: [String: String] = [:]
while index < bytes.count {
skipNulls(in: bytes, index: &index)
guard index < bytes.count else { break }
let start = index
skipString(in: bytes, index: &index)
guard start < index,
let entry = String(bytes: bytes[start..<index], encoding: .utf8),
let equals = entry.firstIndex(of: "=") else {
continue
}
let key = String(entry[..<equals])
guard !key.isEmpty else { continue }
environment[key] = String(entry[entry.index(after: equals)...])
}
return CmuxTopProcessArguments(arguments: arguments, environment: environment)
}
private static func kernProcArgsBytes(for pid: Int) -> [UInt8]? {
var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, Int32(pid)]
var size: size_t = 0
guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0,
size > MemoryLayout<Int32>.size else {
return nil
}
var buffer = [UInt8](repeating: 0, count: size)
let success = buffer.withUnsafeMutableBytes { rawBuffer in
sysctl(&mib, u_int(mib.count), rawBuffer.baseAddress, &size, nil, 0) == 0
}
guard success else { return nil }
return Array(buffer.prefix(Int(size)))
}
private static func skipString(in bytes: [UInt8], index: inout Int) {
while index < bytes.count, bytes[index] != 0 {
index += 1
}
}
private static func skipNulls(in bytes: [UInt8], index: inout Int) {
while index < bytes.count, bytes[index] == 0 {
index += 1
}
}
}
+125
View File
@@ -0,0 +1,125 @@
import Darwin
import Foundation
import os
nonisolated struct CmuxTopProcessCPUSample: Sendable {
let totalTimeTicks: UInt64
let sampledAtNanoseconds: UInt64
}
private nonisolated struct CmuxTopProcessCPUTrackerState: Sendable {
var samples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] = [:]
var latestPrunedAtNanoseconds: UInt64 = 0
}
private nonisolated final class CmuxTopProcessCPUTracker: @unchecked Sendable {
private let state = OSAllocatedUnfairLock(initialState: CmuxTopProcessCPUTrackerState())
// Snapshot capture is synchronous for the v2 socket path, so an actor would
// force that caller to block on async state. Keep OS sampling outside this
// owner and serialize only the CPU history read/compute/write transaction.
func cpuPercentages(
for currentSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample],
activeKeys: Set<CmuxTopProcessScopeCacheKey>,
sampledAtNanoseconds: UInt64
) -> [CmuxTopProcessScopeCacheKey: Double] {
state.withLock { state in
var percentages: [CmuxTopProcessScopeCacheKey: Double] = [:]
percentages.reserveCapacity(currentSamples.count)
for (key, sample) in currentSamples {
let existing = state.samples[key]
if let existing,
existing.sampledAtNanoseconds > sample.sampledAtNanoseconds {
continue
}
percentages[key] = CmuxTopProcessSnapshot.cpuPercent(
current: sample,
previous: existing
)
state.samples[key] = sample
}
// Overlapping captures can finish out of sample-time order; only
// the newest completed capture is allowed to evict inactive keys.
if sampledAtNanoseconds >= state.latestPrunedAtNanoseconds {
state.latestPrunedAtNanoseconds = sampledAtNanoseconds
state.samples = state.samples.filter { entry in
activeKeys.contains(entry.key)
}
}
return percentages
}
}
}
private nonisolated let cmuxTopProcessCPUTracker = CmuxTopProcessCPUTracker()
private nonisolated let cmuxTopAbsoluteTimeNanosecondsRatio: Double? = {
var info = mach_timebase_info_data_t()
guard mach_timebase_info(&info) == KERN_SUCCESS, info.denom > 0 else {
return nil
}
return Double(info.numer) / Double(info.denom)
}()
nonisolated extension CmuxTopProcessSnapshot {
static func cpuSampleClockNanoseconds() -> UInt64 {
clock_gettime_nsec_np(CLOCK_UPTIME_RAW)
}
static func cpuPercentages(
for samples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample],
activeKeys: Set<CmuxTopProcessScopeCacheKey>,
sampledAtNanoseconds: UInt64
) -> [CmuxTopProcessScopeCacheKey: Double] {
cmuxTopProcessCPUTracker.cpuPercentages(
for: samples,
activeKeys: activeKeys,
sampledAtNanoseconds: sampledAtNanoseconds
)
}
static func cpuSample(
from taskInfo: proc_taskinfo,
sampledAtNanoseconds: UInt64
) -> CmuxTopProcessCPUSample {
CmuxTopProcessCPUSample(
totalTimeTicks: clampedCPUTimeTicks(taskInfo.pti_total_user, taskInfo.pti_total_system),
sampledAtNanoseconds: sampledAtNanoseconds
)
}
static func cpuPercent(
current: CmuxTopProcessCPUSample,
previous: CmuxTopProcessCPUSample?
) -> Double {
guard let previous,
current.sampledAtNanoseconds > previous.sampledAtNanoseconds,
current.totalTimeTicks >= previous.totalTimeTicks,
current.totalTimeTicks != UInt64.max,
previous.totalTimeTicks != UInt64.max else {
return 0
}
let cpuDelta = current.totalTimeTicks - previous.totalTimeTicks
let wallDeltaNanoseconds = current.sampledAtNanoseconds - previous.sampledAtNanoseconds
guard wallDeltaNanoseconds > 0 else { return 0 }
guard let cpuNanoseconds = absoluteTimeNanoseconds(cpuDelta) else { return 0 }
let wallNanoseconds = Double(wallDeltaNanoseconds)
return max(0, cpuNanoseconds / wallNanoseconds * 100.0)
}
private static func clampedCPUTimeTicks(_ user: UInt64, _ system: UInt64) -> UInt64 {
let (sum, overflow) = user.addingReportingOverflow(system)
return overflow ? UInt64.max : sum
}
private static func absoluteTimeNanoseconds(_ ticks: UInt64) -> Double? {
guard let ratio = cmuxTopAbsoluteTimeNanosecondsRatio else { return nil }
return Double(ticks) * ratio
}
}
+17
View File
@@ -0,0 +1,17 @@
import ObjectiveC.runtime
import WebKit
enum CmuxWebContentProcessIdentifier {
@MainActor
static func pid(for webView: WKWebView) -> Int? {
let selector = NSSelectorFromString("_webProcessIdentifier")
guard let method = class_getInstanceMethod(WKWebView.self, selector) else {
return nil
}
typealias WebProcessIdentifierFn = @convention(c) (AnyObject, Selector) -> Int32
let implementation = method_getImplementation(method)
let pid = unsafeBitCast(implementation, to: WebProcessIdentifierFn.self)(webView, selector)
return pid > 0 ? Int(pid) : nil
}
}
+87 -157
View File
@@ -1,9 +1,9 @@
import Foundation
import Darwin
import WebKit
import ObjectiveC.runtime
struct CmuxTopResourceSummary: Sendable {
private nonisolated let cmuxTopPIDPathBufferSize = 4096
nonisolated struct CmuxTopResourceSummary: Sendable {
var cpuPercent: Double = 0
var residentBytes: Int64 = 0
var virtualBytes: Int64 = 0
@@ -23,7 +23,7 @@ struct CmuxTopResourceSummary: Sendable {
}
}
struct CmuxTopProcessInfo: Sendable {
nonisolated struct CmuxTopProcessInfo: Sendable {
let pid: Int
let parentPID: Int
let name: String
@@ -33,21 +33,18 @@ struct CmuxTopProcessInfo: Sendable {
let cmuxSurfaceID: UUID?
let processGroupID: Int?
let terminalProcessGroupID: Int?
let cpuPercent: Double
var cpuPercent: Double
let residentBytes: Int64
let virtualBytes: Int64
let threadCount: Int
}
struct CmuxTopProcessScope: Sendable {
nonisolated struct CmuxTopProcessScope: Sendable {
let workspaceID: UUID?
let surfaceID: UUID?
}
final class CmuxTopProcessSnapshot: @unchecked Sendable {
private static let cpuScale = 2048.0
private static let pidPathBufferSize = 4096
nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable {
let sampledAt: Date
private let includesProcessDetails: Bool
private let processesByPID: [Int: CmuxTopProcessInfo]
@@ -95,7 +92,7 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable {
[
"sampled_at": ISO8601DateFormatter().string(from: sampledAt),
"source": "sysctl+proc_pidinfo",
"cpu_source": "kinfo_proc.p_pctcpu",
"cpu_source": "proc_pidinfo.PROC_PIDTASKINFO.pti_total_user+pti_total_system",
"memory_source": "proc_pidinfo.PROC_PIDTASKINFO",
"process_details": includesProcessDetails
]
@@ -112,6 +109,12 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable {
Set(pidsByCMUXSurfaceID[surfaceID] ?? [])
}
func cmuxScopedProcesses() -> [CmuxTopProcessInfo] {
processesByPID.values
.filter { $0.cmuxWorkspaceID != nil && $0.cmuxSurfaceID != nil }
.sorted { $0.pid < $1.pid }
}
func expandedPIDs(rootPIDs: Set<Int>) -> Set<Int> {
var result: Set<Int> = []
var stack = Array(rootPIDs.filter { $0 > 0 })
@@ -269,11 +272,33 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable {
let count = min(processes.count, length / stride)
let sampledProcesses = Array(processes.prefix(count))
let activeScopeKeys = Set(sampledProcesses.map { scopeCacheKey(from: $0) })
let processInfos = sampledProcesses.compactMap {
processInfo(from: $0, includeProcessDetails: includeProcessDetails)
let sampledAtNanoseconds = cpuSampleClockNanoseconds()
var currentCPUSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] = [:]
var processRecords: [(info: CmuxTopProcessInfo, cpuSampleKey: CmuxTopProcessScopeCacheKey?)] = []
processRecords.reserveCapacity(sampledProcesses.count)
for process in sampledProcesses {
guard let processRecord = processInfo(
from: process,
includeProcessDetails: includeProcessDetails,
sampledAtNanoseconds: sampledAtNanoseconds,
currentCPUSamples: &currentCPUSamples
) else {
continue
}
processRecords.append(processRecord)
}
let cpuPercentages = cpuPercentages(
for: currentCPUSamples,
activeKeys: activeScopeKeys,
sampledAtNanoseconds: sampledAtNanoseconds
)
for index in processRecords.indices {
guard let key = processRecords[index].cpuSampleKey,
let cpuPercent = cpuPercentages[key] else { continue }
processRecords[index].info.cpuPercent = cpuPercent
}
pruneCMUXScopeCache(activeKeys: activeScopeKeys)
return processInfos
return processRecords.map(\.info)
}
guard errno == ENOMEM else {
@@ -285,24 +310,35 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable {
private static func processInfo(
from kinfo: kinfo_proc,
includeProcessDetails: Bool
) -> CmuxTopProcessInfo? {
includeProcessDetails: Bool,
sampledAtNanoseconds: UInt64,
currentCPUSamples: inout [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample]
) -> (info: CmuxTopProcessInfo, cpuSampleKey: CmuxTopProcessScopeCacheKey?)? {
let pid = Int(kinfo.kp_proc.p_pid)
guard pid > 0 else { return nil }
let taskInfo = taskInfo(for: pid)
let cacheKey = scopeCacheKey(from: kinfo)
let fallbackName = fixedString(kinfo.kp_proc.p_comm)
let name = includeProcessDetails ? processName(pid: pid, fallback: fallbackName) : fallbackName
let path = includeProcessDetails ? processPath(pid: pid) : nil
let rawTTY = Int64(kinfo.kp_eproc.e_tdev)
let ttyDevice = rawTTY > 0 ? rawTTY : nil
let cmuxScope = cachedCMUXScope(for: pid, cacheKey: scopeCacheKey(from: kinfo))
let cmuxScope = cachedCMUXScope(for: pid, cacheKey: cacheKey)
let rawProcessGroupID = Int(kinfo.kp_eproc.e_pgid)
let processGroupID = rawProcessGroupID > 0 ? rawProcessGroupID : nil
let rawTerminalProcessGroupID = Int(kinfo.kp_eproc.e_tpgid)
let terminalProcessGroupID = rawTerminalProcessGroupID > 0 ? rawTerminalProcessGroupID : nil
let cpuSampleKey: CmuxTopProcessScopeCacheKey?
if let taskInfo {
let currentCPUSample = cpuSample(from: taskInfo, sampledAtNanoseconds: sampledAtNanoseconds)
currentCPUSamples[cacheKey] = currentCPUSample
cpuSampleKey = cacheKey
} else {
cpuSampleKey = nil
}
return CmuxTopProcessInfo(
return (CmuxTopProcessInfo(
pid: pid,
parentPID: Int(kinfo.kp_eproc.e_ppid),
name: name.isEmpty ? "pid-\(pid)" : name,
@@ -312,134 +348,11 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable {
cmuxSurfaceID: cmuxScope?.surfaceID,
processGroupID: processGroupID,
terminalProcessGroupID: terminalProcessGroupID,
cpuPercent: max(0, Double(kinfo.kp_proc.p_pctcpu) / cpuScale * 100.0),
cpuPercent: 0,
residentBytes: int64Clamped(taskInfo?.pti_resident_size ?? 0),
virtualBytes: int64Clamped(taskInfo?.pti_virtual_size ?? 0),
threadCount: Int(taskInfo?.pti_threadnum ?? 0)
)
}
static func cmuxScope(for pid: Int) -> CmuxTopProcessScope? {
guard pid > 0, pid <= Int(Int32.max) else { return nil }
var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, Int32(pid)]
var size: size_t = 0
guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0,
size > MemoryLayout<Int32>.size else {
return nil
}
var buffer = [UInt8](repeating: 0, count: size)
let success = buffer.withUnsafeMutableBytes { rawBuffer in
sysctl(&mib, u_int(mib.count), rawBuffer.baseAddress, &size, nil, 0) == 0
}
guard success else { return nil }
return cmuxScope(fromKernProcArgs: Array(buffer.prefix(Int(size))))
}
static func cmuxScope(fromKernProcArgs bytes: [UInt8]) -> CmuxTopProcessScope? {
guard bytes.count > MemoryLayout<Int32>.size else { return nil }
var argcRaw: Int32 = 0
withUnsafeMutableBytes(of: &argcRaw) { rawBuffer in
rawBuffer.copyBytes(from: bytes.prefix(MemoryLayout<Int32>.size))
}
let argc = Int(Int32(littleEndian: argcRaw))
guard argc > 0 else { return nil }
var index = MemoryLayout<Int32>.size
skipString(in: bytes, index: &index)
skipNulls(in: bytes, index: &index)
for _ in 0..<argc {
guard index < bytes.count else { return nil }
skipString(in: bytes, index: &index)
skipNulls(in: bytes, index: &index)
}
var workspaceID: UUID?
var surfaceID: UUID?
while index < bytes.count {
skipNulls(in: bytes, index: &index)
guard index < bytes.count else { break }
let start = index
skipString(in: bytes, index: &index)
guard start < index,
let entry = String(bytes: bytes[start..<index], encoding: .utf8) else {
continue
}
if let value = value(inEnvironmentEntry: entry, forKey: "CMUX_WORKSPACE_ID") {
workspaceID = UUID(uuidString: value) ?? workspaceID
} else if workspaceID == nil,
let value = value(inEnvironmentEntry: entry, forKey: "CMUX_TAB_ID") {
workspaceID = UUID(uuidString: value)
} else if let value = value(inEnvironmentEntry: entry, forKey: "CMUX_SURFACE_ID") {
surfaceID = UUID(uuidString: value) ?? surfaceID
} else if surfaceID == nil,
let value = value(inEnvironmentEntry: entry, forKey: "CMUX_PANEL_ID") {
surfaceID = UUID(uuidString: value)
}
if workspaceID != nil, surfaceID != nil {
break
}
}
guard workspaceID != nil || surfaceID != nil else { return nil }
return CmuxTopProcessScope(workspaceID: workspaceID, surfaceID: surfaceID)
}
private static func value(inEnvironmentEntry entry: String, forKey key: String) -> String? {
let prefix = "\(key)="
guard entry.hasPrefix(prefix) else { return nil }
let value = String(entry.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
private static func skipString(in bytes: [UInt8], index: inout Int) {
while index < bytes.count, bytes[index] != 0 {
index += 1
}
}
private static func skipNulls(in bytes: [UInt8], index: inout Int) {
while index < bytes.count, bytes[index] == 0 {
index += 1
}
}
private static func taskInfo(for pid: Int) -> proc_taskinfo? {
var info = proc_taskinfo()
let expectedSize = MemoryLayout<proc_taskinfo>.stride
let size = proc_pidinfo(pid_t(pid), PROC_PIDTASKINFO, 0, &info, Int32(expectedSize))
return size == expectedSize ? info : nil
}
private static func processName(pid: Int, fallback: String) -> String {
var buffer = [CChar](repeating: 0, count: Int(MAXCOMLEN + 1))
let length = proc_name(pid_t(pid), &buffer, UInt32(buffer.count))
guard length > 0 else { return fallback }
let name = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? fallback : name
}
private static func processPath(pid: Int) -> String? {
var buffer = [CChar](repeating: 0, count: pidPathBufferSize)
let length = proc_pidpath(pid_t(pid), &buffer, UInt32(buffer.count))
guard length > 0 else { return nil }
let path = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines)
return path.isEmpty ? nil : path
}
private static func fixedString<T>(_ value: T) -> String {
withUnsafeBytes(of: value) { rawBuffer in
let chars = rawBuffer.bindMemory(to: CChar.self)
guard let baseAddress = chars.baseAddress else { return "" }
return String(cString: baseAddress).trimmingCharacters(in: .whitespacesAndNewlines)
}
), cpuSampleKey)
}
private static func deviceIdentifier(forTTYName ttyName: String) -> Int64? {
@@ -462,28 +375,45 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable {
return Int64(statInfo.st_rdev)
}
private static func int64Clamped(_ value: UInt64) -> Int64 {
value > UInt64(Int64.max) ? Int64.max : Int64(value)
}
private static func clampedAdd(_ lhs: Int64, _ rhs: Int64) -> Int64 {
if rhs > 0, lhs > Int64.max - rhs {
return Int64.max
}
return lhs + rhs
}
}
enum CmuxWebContentProcessIdentifier {
static func pid(for webView: WKWebView) -> Int? {
let selector = NSSelectorFromString("_webProcessIdentifier")
guard let method = class_getInstanceMethod(WKWebView.self, selector) else {
return nil
private static func taskInfo(for pid: Int) -> proc_taskinfo? {
var info = proc_taskinfo()
let expectedSize = MemoryLayout<proc_taskinfo>.stride
let size = proc_pidinfo(pid_t(pid), PROC_PIDTASKINFO, 0, &info, Int32(expectedSize))
return size == expectedSize ? info : nil
}
private static func processName(pid: Int, fallback: String) -> String {
var buffer = [CChar](repeating: 0, count: Int(MAXCOMLEN + 1))
let length = proc_name(pid_t(pid), &buffer, UInt32(buffer.count))
guard length > 0 else { return fallback }
let name = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? fallback : name
}
private static func processPath(pid: Int) -> String? {
var buffer = [CChar](repeating: 0, count: cmuxTopPIDPathBufferSize)
let length = proc_pidpath(pid_t(pid), &buffer, UInt32(buffer.count))
guard length > 0 else { return nil }
let path = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines)
return path.isEmpty ? nil : path
}
private static func fixedString<T>(_ value: T) -> String {
withUnsafeBytes(of: value) { rawBuffer in
let endIndex = rawBuffer.firstIndex(of: 0) ?? rawBuffer.endIndex
return String(decoding: rawBuffer[..<endIndex], as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
typealias WebProcessIdentifierFn = @convention(c) (AnyObject, Selector) -> Int32
let implementation = method_getImplementation(method)
let pid = unsafeBitCast(implementation, to: WebProcessIdentifierFn.self)(webView, selector)
return pid > 0 ? Int(pid) : nil
private static func int64Clamped(_ value: UInt64) -> Int64 {
value > UInt64(Int64.max) ? Int64.max : Int64(value)
}
}
+132 -16
View File
@@ -1,13 +1,14 @@
import Foundation
import Darwin
import os
struct CmuxTopProcessScopeCacheKey: Hashable {
nonisolated struct CmuxTopProcessScopeCacheKey: Hashable {
let pid: Int
let startSeconds: Int
let startMicroseconds: Int
}
private struct CmuxTopProcessScopeCacheValue {
private nonisolated struct CmuxTopProcessScopeCacheValue {
let scope: CmuxTopProcessScope
}
@@ -15,10 +16,11 @@ private struct CmuxTopProcessScopeCacheValue {
// both async task-manager sampling and sync v2 system.top socket handling. Keep
// this tiny lock isolated to dictionary reads/writes; procargs/sysctl work must
// happen outside the critical section.
private let cmuxTopScopeCacheLock = NSLock()
private var cmuxTopScopeCache: [CmuxTopProcessScopeCacheKey: CmuxTopProcessScopeCacheValue] = [:]
private nonisolated let cmuxTopScopeCache = OSAllocatedUnfairLock(
initialState: [CmuxTopProcessScopeCacheKey: CmuxTopProcessScopeCacheValue]()
)
extension CmuxTopProcessSnapshot {
nonisolated extension CmuxTopProcessSnapshot {
static func scopeCacheKey(from kinfo: kinfo_proc) -> CmuxTopProcessScopeCacheKey {
let startTime = kinfo.kp_proc.p_un.__p_starttime
return CmuxTopProcessScopeCacheKey(
@@ -32,27 +34,141 @@ extension CmuxTopProcessSnapshot {
for pid: Int,
cacheKey: CmuxTopProcessScopeCacheKey
) -> CmuxTopProcessScope? {
cmuxTopScopeCacheLock.lock()
if let cached = cmuxTopScopeCache[cacheKey] {
cmuxTopScopeCacheLock.unlock()
if let cached = cmuxTopScopeCache.withLock({ cache in cache[cacheKey] }) {
return cached.scope
}
cmuxTopScopeCacheLock.unlock()
guard let scope = cmuxScope(for: pid) else {
guard let scope = cmuxScope(for: pid, expectedCacheKey: cacheKey) else {
return nil
}
cmuxTopScopeCacheLock.lock()
cmuxTopScopeCache[cacheKey] = CmuxTopProcessScopeCacheValue(scope: scope)
cmuxTopScopeCacheLock.unlock()
cmuxTopScopeCache.withLock { cache in
cache[cacheKey] = CmuxTopProcessScopeCacheValue(scope: scope)
}
return scope
}
static func pruneCMUXScopeCache(activeKeys: Set<CmuxTopProcessScopeCacheKey>) {
cmuxTopScopeCacheLock.lock()
cmuxTopScopeCache = cmuxTopScopeCache.filter { activeKeys.contains($0.key) }
cmuxTopScopeCacheLock.unlock()
cmuxTopScopeCache.withLock { cache in
cache = cache.filter { activeKeys.contains($0.key) }
}
}
private static func cmuxScope(
for pid: Int,
expectedCacheKey: CmuxTopProcessScopeCacheKey
) -> CmuxTopProcessScope? {
guard let currentProcess = kinfoProc(for: pid),
scopeCacheKey(from: currentProcess) == expectedCacheKey else {
return nil
}
var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, Int32(pid)]
var size: size_t = 0
guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0,
size > MemoryLayout<Int32>.size else {
return nil
}
var buffer = [UInt8](repeating: 0, count: size)
let success = buffer.withUnsafeMutableBytes { rawBuffer in
sysctl(&mib, u_int(mib.count), rawBuffer.baseAddress, &size, nil, 0) == 0
}
guard success else { return nil }
guard let currentProcess = kinfoProc(for: pid),
scopeCacheKey(from: currentProcess) == expectedCacheKey else {
return nil
}
return cmuxScope(fromKernProcArgs: Array(buffer.prefix(Int(size))))
}
static func cmuxScope(fromKernProcArgs bytes: [UInt8]) -> CmuxTopProcessScope? {
guard bytes.count > MemoryLayout<Int32>.size else { return nil }
var argcRaw: Int32 = 0
withUnsafeMutableBytes(of: &argcRaw) { rawBuffer in
rawBuffer.copyBytes(from: bytes.prefix(MemoryLayout<Int32>.size))
}
let argc = Int(Int32(littleEndian: argcRaw))
guard argc > 0 else { return nil }
var index = MemoryLayout<Int32>.size
skipString(in: bytes, index: &index)
skipNulls(in: bytes, index: &index)
for _ in 0..<argc {
guard index < bytes.count else { return nil }
skipString(in: bytes, index: &index)
skipNulls(in: bytes, index: &index)
}
var workspaceID: UUID?
var surfaceID: UUID?
while index < bytes.count {
skipNulls(in: bytes, index: &index)
guard index < bytes.count else { break }
let start = index
skipString(in: bytes, index: &index)
guard start < index,
let entry = String(bytes: bytes[start..<index], encoding: .utf8) else {
continue
}
if let value = value(inEnvironmentEntry: entry, forKey: "CMUX_WORKSPACE_ID") {
workspaceID = UUID(uuidString: value) ?? workspaceID
} else if workspaceID == nil,
let value = value(inEnvironmentEntry: entry, forKey: "CMUX_TAB_ID") {
workspaceID = UUID(uuidString: value)
} else if let value = value(inEnvironmentEntry: entry, forKey: "CMUX_SURFACE_ID") {
surfaceID = UUID(uuidString: value) ?? surfaceID
} else if surfaceID == nil,
let value = value(inEnvironmentEntry: entry, forKey: "CMUX_PANEL_ID") {
surfaceID = UUID(uuidString: value)
}
if workspaceID != nil, surfaceID != nil {
break
}
}
guard workspaceID != nil || surfaceID != nil else { return nil }
return CmuxTopProcessScope(workspaceID: workspaceID, surfaceID: surfaceID)
}
private static func value(inEnvironmentEntry entry: String, forKey key: String) -> String? {
let prefix = "\(key)="
guard entry.hasPrefix(prefix) else { return nil }
let value = String(entry.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
private static func skipString(in bytes: [UInt8], index: inout Int) {
while index < bytes.count, bytes[index] != 0 {
index += 1
}
}
private static func skipNulls(in bytes: [UInt8], index: inout Int) {
while index < bytes.count, bytes[index] == 0 {
index += 1
}
}
private static func kinfoProc(for pid: Int) -> kinfo_proc? {
guard pid > 0, pid <= Int(Int32.max) else { return nil }
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, Int32(pid)]
var process = kinfo_proc()
var length = MemoryLayout<kinfo_proc>.stride
let result = sysctl(&mib, u_int(mib.count), &process, &length, nil, 0)
guard result == 0,
length >= MemoryLayout<kinfo_proc>.stride,
process.kp_proc.p_pid == pid_t(pid) else {
return nil
}
return process
}
}
+288 -1212
View File
File diff suppressed because it is too large Load Diff
+389
View File
@@ -0,0 +1,389 @@
import AppKit
import SwiftUI
struct DetachedFolderDragIcon: NSViewRepresentable {
let directory: String
func makeNSView(context: Context) -> DetachedFolderDragIconHostView {
DetachedFolderDragIconHostView(directory: directory)
}
func updateNSView(_ nsView: DetachedFolderDragIconHostView, context: Context) {
nsView.directory = directory
nsView.syncDetachedIcon()
}
}
@MainActor
final class DetachedFolderDragIconHostView: NSView {
var directory: String
private var childWindow: NSPanel?
private var iconView: DraggableFolderNSView?
private var observers: [NSObjectProtocol] = []
private weak var observedParentWindow: NSWindow?
init(directory: String) {
self.directory = directory
super.init(frame: NSRect(origin: .zero, size: NSSize(width: 16, height: 16)))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
MainActor.assumeIsolated {
tearDownDetachedIcon()
}
}
override var intrinsicContentSize: NSSize {
NSSize(width: 16, height: 16)
}
override var mouseDownCanMoveWindow: Bool { false }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
syncDetachedIcon()
}
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
syncDetachedIcon()
}
override func layout() {
super.layout()
syncDetachedIconFrame()
}
func syncDetachedIcon() {
guard let parentWindow = window else {
tearDownDetachedIcon()
return
}
let child = childWindow ?? makeDetachedIconWindow(parentWindow: parentWindow)
if child.parent !== parentWindow {
child.parent?.removeChildWindow(child)
parentWindow.addChildWindow(child, ordered: .above)
installParentWindowObservers(parentWindow)
}
if iconView?.directory != directory {
iconView?.directory = directory
iconView?.updateIcon()
}
child.orderFront(nil)
syncDetachedIconFrame()
}
private func makeDetachedIconWindow(parentWindow: NSWindow) -> NSPanel {
let iconView = DraggableFolderNSView(directory: directory)
iconView.frame = NSRect(origin: .zero, size: NSSize(width: 16, height: 16))
let panel = NSPanel(
contentRect: iconView.frame,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.contentView = iconView
panel.backgroundColor = .clear
panel.isOpaque = false
panel.hasShadow = false
panel.hidesOnDeactivate = false
panel.ignoresMouseEvents = false
panel.isMovable = false
panel.isMovableByWindowBackground = false
panel.collectionBehavior = [.fullScreenAuxiliary]
panel.identifier = NSUserInterfaceItemIdentifier("cmux.folderDragIcon")
parentWindow.addChildWindow(panel, ordered: .above)
self.childWindow = panel
self.iconView = iconView
installParentWindowObservers(parentWindow)
return panel
}
private func installParentWindowObservers(_ parentWindow: NSWindow) {
guard observedParentWindow !== parentWindow || observers.isEmpty else { return }
removeParentWindowObservers()
// The child panel frame is derived from this host view in the parent
// window. AppKit does not call layout() when only the parent window
// moves, resizes, or changes miniaturized state, so observe those
// window events and recompute the panel's screen-space frame.
let center = NotificationCenter.default
let names: [Notification.Name] = [
NSWindow.didMoveNotification,
NSWindow.didResizeNotification,
NSWindow.didMiniaturizeNotification,
NSWindow.didDeminiaturizeNotification,
]
observers = names.map { name in
center.addObserver(forName: name, object: parentWindow, queue: .main) { [weak self] _ in
MainActor.assumeIsolated {
self?.syncDetachedIconFrame()
}
}
}
observedParentWindow = parentWindow
}
private func syncDetachedIconFrame() {
guard let parentWindow = window,
let childWindow else { return }
let localRect = bounds.isEmpty
? NSRect(origin: .zero, size: NSSize(width: 16, height: 16))
: bounds
let rectInWindow = convert(localRect, to: nil)
let rectOnScreen = parentWindow.convertToScreen(rectInWindow)
if childWindow.frame.origin != rectOnScreen.origin || childWindow.frame.size != rectOnScreen.size {
childWindow.setFrame(rectOnScreen, display: true)
}
}
private func removeParentWindowObservers() {
for observer in observers {
NotificationCenter.default.removeObserver(observer)
}
observers.removeAll()
observedParentWindow = nil
}
private func tearDownDetachedIcon() {
// Observer lifetime is tied to the derived child panel: once this host
// leaves its parent window or deinitializes, remove both so no stale
// panel keeps tracking an old parent window.
removeParentWindowObservers()
if let childWindow {
childWindow.parent?.removeChildWindow(childWindow)
childWindow.orderOut(nil)
}
childWindow = nil
iconView = nil
}
}
@MainActor
final class DraggableFolderNSView: NSView, NSDraggingSource {
private final class FolderIconImageView: NSImageView {
override var mouseDownCanMoveWindow: Bool { false }
}
var directory: String
private var imageView: FolderIconImageView!
private var pendingDragEvent: NSEvent?
private var pendingDragStartPoint: NSPoint?
private let dragStartThresholdSquared: CGFloat = 9
private func formatPoint(_ point: NSPoint) -> String {
String(format: "(%.1f,%.1f)", point.x, point.y)
}
init(directory: String) {
self.directory = directory
super.init(frame: .zero)
setupImageView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var intrinsicContentSize: NSSize {
NSSize(width: 16, height: 16)
}
override var mouseDownCanMoveWindow: Bool { false }
private func setupImageView() {
imageView = FolderIconImageView()
imageView.imageScaling = .scaleProportionallyDown
imageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(imageView)
NSLayoutConstraint.activate([
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
imageView.topAnchor.constraint(equalTo: topAnchor),
imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
imageView.widthAnchor.constraint(equalToConstant: 16),
imageView.heightAnchor.constraint(equalToConstant: 16),
])
let dragHint = String(localized: "sidebar.folderIcon.dragHint", defaultValue: "Drag to open in Finder or another app")
toolTip = dragHint
imageView.toolTip = dragHint
updateIcon()
}
func updateIcon() {
#if DEBUG
dispatchPrecondition(condition: .onQueue(.main))
#endif
let icon = NSWorkspace.shared.icon(forFile: directory)
icon.size = NSSize(width: 16, height: 16)
imageView.image = icon
}
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
return context == .outsideApplication ? [.copy, .link] : .copy
}
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
#if DEBUG
let nowMovable = window.map { String($0.isMovable) } ?? "nil"
let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil"
cmuxDebugLog("folder.dragEnd dirBytes=\(directory.utf8.count) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)")
#endif
}
override func hitTest(_ point: NSPoint) -> NSView? {
guard bounds.contains(point) else { return nil }
let hit = super.hitTest(point)
#if DEBUG
let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil"
let imageHit = (hit === imageView)
let nowMovable = window.map { String($0.isMovable) } ?? "nil"
cmuxDebugLog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView nowMovable=\(nowMovable)")
#endif
return self
}
override func mouseDown(with event: NSEvent) {
if event.modifierFlags.contains(.control) {
clearPendingDrag()
showPathMenu()
return
}
if event.clickCount == 2 {
clearPendingDrag()
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory)
return
}
#if DEBUG
let localPoint = convert(event.locationInWindow, from: nil)
let responderDesc = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
let nowMovable = window.map { String($0.isMovable) } ?? "nil"
let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil"
cmuxDebugLog("folder.mouseDown dirBytes=\(directory.utf8.count) point=\(formatPoint(localPoint)) firstResponder=\(responderDesc) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)")
#endif
pendingDragEvent = event
pendingDragStartPoint = convert(event.locationInWindow, from: nil)
}
override func mouseDragged(with event: NSEvent) {
guard let dragStartPoint = pendingDragStartPoint else { return }
let currentPoint = convert(event.locationInWindow, from: nil)
let deltaX = currentPoint.x - dragStartPoint.x
let deltaY = currentPoint.y - dragStartPoint.y
guard (deltaX * deltaX) + (deltaY * deltaY) >= dragStartThresholdSquared else { return }
let dragEvent = pendingDragEvent ?? event
clearPendingDrag()
beginFolderDrag(with: dragEvent)
}
override func mouseUp(with event: NSEvent) {
clearPendingDrag()
super.mouseUp(with: event)
}
private func clearPendingDrag() {
pendingDragEvent = nil
pendingDragStartPoint = nil
}
private func beginFolderDrag(with event: NSEvent) {
let fileURL = URL(fileURLWithPath: directory)
let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL)
let iconImage = NSWorkspace.shared.icon(forFile: directory)
iconImage.size = NSSize(width: 32, height: 32)
draggingItem.setDraggingFrame(bounds, contents: iconImage)
let session = beginDraggingSession(with: [draggingItem], event: event, source: self)
#if DEBUG
let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0
cmuxDebugLog("folder.dragStart dirBytes=\(directory.utf8.count) pasteboardItems=\(itemCount)")
#endif
}
override func rightMouseDown(with event: NSEvent) {
clearPendingDrag()
showPathMenu()
}
private func showPathMenu() {
let menu = buildPathMenu()
// Pop up menu at bottom-left of icon (like native proxy icon)
let menuLocation = NSPoint(x: 0, y: bounds.height)
menu.popUp(positioning: nil, at: menuLocation, in: self)
}
private func buildPathMenu() -> NSMenu {
let menu = NSMenu()
let url = URL(fileURLWithPath: directory).standardized
var pathComponents: [URL] = []
// Build path from current directory up to root
var current = url
while current.path != "/" {
pathComponents.append(current)
current = current.deletingLastPathComponent()
}
pathComponents.append(URL(fileURLWithPath: "/"))
// Add path components (current dir at top, root at bottom - matches native macOS)
for pathURL in pathComponents {
let icon = NSWorkspace.shared.icon(forFile: pathURL.path)
icon.size = NSSize(width: 16, height: 16)
let displayName: String
if pathURL.path == "/" {
// Use the volume name for root
if let volumeName = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName {
displayName = volumeName
} else {
displayName = String(localized: "sidebar.pathMenu.macintoshHD", defaultValue: "Macintosh HD")
}
} else {
displayName = FileManager.default.displayName(atPath: pathURL.path)
}
let item = NSMenuItem(title: displayName, action: #selector(openPathComponent(_:)), keyEquivalent: "")
item.target = self
item.image = icon
item.representedObject = pathURL
menu.addItem(item)
}
// Add computer name at the bottom (like native proxy icon)
let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage()
computerIcon.size = NSSize(width: 16, height: 16)
let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "")
computerItem.target = self
computerItem.image = computerIcon
menu.addItem(computerItem)
return menu
}
@objc private func openPathComponent(_ sender: NSMenuItem) {
guard let url = sender.representedObject as? URL else { return }
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path)
}
@objc private func openComputer(_ sender: NSMenuItem) {
// Open the root filesystem entry represented by the bottom path item.
NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true))
}
}
+278 -2
View File
@@ -1,6 +1,192 @@
import AppKit
import Bonsplit
import Foundation
enum FileDropResolvedBehavior: Equatable {
case text
case preview
var inverted: FileDropResolvedBehavior {
switch self {
case .text:
return .preview
case .preview:
return .text
}
}
}
enum FileDropDefaultBehavior: String, CaseIterable, Identifiable {
case text
case preview
var id: String { rawValue }
var resolvedBehavior: FileDropResolvedBehavior {
switch self {
case .text:
return .text
case .preview:
return .preview
}
}
var displayName: String {
switch self {
case .text:
return String(localized: "settings.app.fileDrop.defaultBehavior.text", defaultValue: "Drop path text")
case .preview:
return String(localized: "settings.app.fileDrop.defaultBehavior.preview", defaultValue: "Open file preview")
}
}
var settingsSubtitle: String {
switch self {
case .text:
return String(
localized: "settings.app.fileDrop.defaultBehavior.text.subtitle",
defaultValue: "Over terminals and editors, dragging files inserts shell-escaped paths. Hold Shift to open a file preview or split."
)
case .preview:
return String(
localized: "settings.app.fileDrop.defaultBehavior.preview.subtitle",
defaultValue: "Dragging files opens previews or split panes. Hold Shift over terminals and editors to insert path text."
)
}
}
}
enum FileDropTextDestinationKind: Equatable {
case terminal
case editor
func hintText(for alternateBehavior: FileDropResolvedBehavior) -> String? {
switch alternateBehavior {
case .text:
switch self {
case .terminal:
return String(
localized: "fileDrop.holdShiftDropIntoTerminal",
defaultValue: "Hold Shift to drop into terminal"
)
case .editor:
return String(
localized: "fileDrop.holdShiftDropIntoEditor",
defaultValue: "Hold Shift to drop into editor"
)
}
case .preview:
return String(
localized: "fileDrop.holdShiftOpenAsSplit",
defaultValue: "Hold Shift to open as split"
)
}
}
}
enum FileDropBehaviorSettings {
static let defaultBehaviorKey = "fileDrop.defaultBehavior"
static let defaultBehavior: FileDropDefaultBehavior = .text
static func behavior(for rawValue: String?) -> FileDropDefaultBehavior {
FileDropDefaultBehavior(rawValue: rawValue ?? "") ?? defaultBehavior
}
static func behavior(defaults: UserDefaults = .standard) -> FileDropDefaultBehavior {
behavior(for: defaults.string(forKey: defaultBehaviorKey))
}
}
@MainActor
enum FileDropTextDropController {
static func panelIdForTerminalDropFocus(
terminalSurfaceId: UUID,
workspace: Workspace
) -> UUID? {
if workspace.panels[terminalSurfaceId] != nil {
return terminalSurfaceId
}
return workspace.panelIdFromSurfaceId(TabID(uuid: terminalSurfaceId))
}
@discardableResult
static func performPanelTextDrop(
workspace: Workspace,
panelId: UUID,
focusIntent: PanelFocusIntent,
window: NSWindow?,
insert: () -> Bool
) -> Bool {
guard insert() else { return false }
focusPanelAfterSuccessfulTextDrop(
workspace: workspace,
panelId: panelId,
focusIntent: focusIntent,
window: window
)
return true
}
@discardableResult
static func performTerminalFileDrop(
workspace: Workspace,
panelId: UUID,
hostedView: GhosttySurfaceScrollView,
urls: [URL],
window: NSWindow?
) -> Bool {
performPanelTextDrop(
workspace: workspace,
panelId: panelId,
focusIntent: .terminal(.surface),
window: window,
insert: {
hostedView.handleDroppedURLs(urls)
}
)
}
@discardableResult
static func performTerminalFileDrop(
terminal: GhosttyNSView,
urls: [URL]
) -> Bool {
guard let workspaceId = terminal.tabId,
let terminalSurfaceId = terminal.terminalSurface?.id,
let workspace = AppDelegate.shared?.workspaceFor(tabId: workspaceId),
let panelId = panelIdForTerminalDropFocus(
terminalSurfaceId: terminalSurfaceId,
workspace: workspace
) else {
return terminal.handleDroppedFileURLs(urls)
}
return performPanelTextDrop(
workspace: workspace,
panelId: panelId,
focusIntent: .terminal(.surface),
window: terminal.window,
insert: {
terminal.handleDroppedFileURLs(urls)
}
)
}
static func focusPanelAfterSuccessfulTextDrop(
workspace: Workspace,
panelId: UUID,
focusIntent: PanelFocusIntent,
window: NSWindow?
) {
AppDelegate.shared?.noteMainPanelKeyboardFocusIntent(
workspaceId: workspace.id,
panelId: panelId,
in: window
)
workspace.focusPanel(panelId, focusIntent: focusIntent)
_ = workspace.panels[panelId]?.restoreFocusIntent(focusIntent)
}
}
enum DragOverlayRoutingPolicy {
static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
static let filePreviewTransferType = NSPasteboard.PasteboardType("com.cmux.filepreview.transfer")
@@ -25,8 +211,98 @@ enum DragOverlayRoutingPolicy {
PasteboardFileURLReader.hasFileURLType(pasteboardTypes ?? [])
}
static func hasFileDropPayload(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool {
hasFileURL(pasteboardTypes) || hasFilePreviewTransfer(pasteboardTypes)
}
static func fileURLs(from pasteboard: NSPasteboard) -> [URL] {
PasteboardFileURLReader.fileURLs(from: pasteboard)
let fileURLs = PasteboardFileURLReader.fileURLs(from: pasteboard)
if !fileURLs.isEmpty {
return fileURLs
}
guard let dragId = FilePreviewDragPasteboardWriter.dragID(from: pasteboard),
let entry = FilePreviewDragRegistry.shared.entry(id: dragId) else {
return []
}
return [URL(fileURLWithPath: entry.filePath).standardizedFileURL]
}
static func textDropOperation(pasteboardTypes: [NSPasteboard.PasteboardType]?) -> NSDragOperation {
hasFilePreviewTransfer(pasteboardTypes) ? .move : .copy
}
@MainActor
static var currentModifierFlags: NSEvent.ModifierFlags {
mergedModifierFlags(
appKitFlags: NSApp.currentEvent?.modifierFlags ?? NSEvent.modifierFlags,
cgEventFlags: CGEventSource.flagsState(.combinedSessionState)
)
}
static func mergedModifierFlags(
appKitFlags: NSEvent.ModifierFlags,
cgEventFlags: CGEventFlags
) -> NSEvent.ModifierFlags {
var flags = appKitFlags
if cgEventFlags.contains(.maskShift) {
flags.insert(.shift)
}
if cgEventFlags.contains(.maskCommand) {
flags.insert(.command)
}
if cgEventFlags.contains(.maskAlternate) {
flags.insert(.option)
}
if cgEventFlags.contains(.maskControl) {
flags.insert(.control)
}
if cgEventFlags.contains(.maskAlphaShift) {
flags.insert(.capsLock)
}
if cgEventFlags.contains(.maskSecondaryFn) {
flags.insert(.function)
}
return flags
}
static func resolvedFileDropBehavior(
pasteboardTypes: [NSPasteboard.PasteboardType]?,
modifierFlags: NSEvent.ModifierFlags,
canDropAsText: Bool = true,
defaultBehavior: FileDropDefaultBehavior = FileDropBehaviorSettings.behavior()
) -> FileDropResolvedBehavior? {
guard hasFileDropPayload(pasteboardTypes) else { return nil }
guard canDropAsText else { return .preview }
let behavior = defaultBehavior.resolvedBehavior
return modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.shift)
? behavior.inverted
: behavior
}
static func shouldRouteFileDropToTextDestination(
pasteboardTypes: [NSPasteboard.PasteboardType]?,
modifierFlags: NSEvent.ModifierFlags,
canDropAsText: Bool = true,
defaultBehavior: FileDropDefaultBehavior = FileDropBehaviorSettings.behavior()
) -> Bool {
resolvedFileDropBehavior(
pasteboardTypes: pasteboardTypes,
modifierFlags: modifierFlags,
canDropAsText: canDropAsText,
defaultBehavior: defaultBehavior
) == .text
}
static func alternateFileDropBehaviorForShiftHint(
pasteboardTypes: [NSPasteboard.PasteboardType]?,
modifierFlags: NSEvent.ModifierFlags,
canDropAsText: Bool = true,
defaultBehavior: FileDropDefaultBehavior = FileDropBehaviorSettings.behavior()
) -> FileDropResolvedBehavior? {
guard hasFileDropPayload(pasteboardTypes) else { return nil }
guard canDropAsText else { return nil }
guard !modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.shift) else { return nil }
return defaultBehavior.resolvedBehavior.inverted
}
static func shouldCaptureFileDropDestination(
@@ -35,7 +311,7 @@ enum DragOverlayRoutingPolicy {
) -> Bool {
// The window overlay delegates Finder/sidebar files to pane-level Bonsplit targets.
_ = hasLocalDraggingSource
guard hasFileURL(pasteboardTypes) else { return false }
guard hasFileDropPayload(pasteboardTypes) else { return false }
return true
}
+250 -161
View File
@@ -60,7 +60,7 @@ struct FeedPanelView: View {
case .actionable:
return String(localized: "feed.filter.actionable", defaultValue: "Actionable")
case .activity:
return String(localized: "feed.filter.activity", defaultValue: "Activity")
return String(localized: "feed.filter.activity", defaultValue: "All Activity")
}
}
var symbolName: String {
@@ -108,7 +108,7 @@ struct FeedPanelView: View {
private var controlBarContent: some View {
HStack(spacing: 6) {
ForEach([Filter.actionable]) { f in
ForEach(Filter.allCases) { f in
FeedSecondaryFilterButton(
filter: f,
isSelected: filter == f
@@ -161,9 +161,12 @@ private struct FeedListView: View {
@State private var focusSnapshot = FeedFocusSnapshot()
@State private var scrollRequest: FeedScrollRequest?
@State private var scrollRequestSequence = 0
@State private var stopDrafts: [UUID: FeedStopDraft] = [:]
var body: some View {
let snapshots = visibleSnapshots(items)
let activityGroups = filter == .activity ? activitySnapshotGroups(snapshots) : nil
let focusSnapshots = activityGroups?.ordered ?? snapshots
let rowActions = FeedRowActions.bound()
ScrollViewReader { proxy in
Group {
@@ -172,6 +175,7 @@ private struct FeedListView: View {
} else {
contentBody(
snapshots: snapshots,
activityGroups: activityGroups,
actions: rowActions
)
}
@@ -190,13 +194,13 @@ private struct FeedListView: View {
syncFeedFocusSnapshot(window: window)
},
onMoveSelection: { delta in
moveSelection(in: snapshots, delta: delta)
moveSelection(in: focusSnapshots, delta: delta)
},
onActivateSelection: {
activateSelection(in: snapshots, actions: rowActions)
activateSelection(in: focusSnapshots, actions: rowActions)
},
onFocusFirstItemRequested: {
focusFirstVisibleItem(in: snapshots, focusHost: false)
focusFirstVisibleItem(in: focusSnapshots, focusHost: false)
},
onFocusChanged: { focused in
let window = activeFeedWindow()
@@ -217,6 +221,7 @@ private struct FeedListView: View {
@ViewBuilder
private func contentBody(
snapshots: [FeedItemSnapshot],
activityGroups: ActivitySnapshotGroups?,
actions: FeedRowActions
) -> some View {
switch filter {
@@ -226,29 +231,11 @@ private struct FeedListView: View {
actions: actions
)
case .activity:
let stable = snapshots.filter(prefersStableSurface)
let history = snapshots.filter { !prefersStableSurface($0) }
if history.isEmpty && !hasMorePersistedItems {
stableScrollSurface(
snapshots: stable,
actions: actions
)
} else {
VStack(spacing: 0) {
if !stable.isEmpty {
stableRows(
snapshots: stable,
actions: actions
)
rowSeparator
}
historyList(
snapshots: history,
actions: actions,
showsLoadMore: hasMorePersistedItems
)
}
}
activityScrollSurface(
groups: activityGroups ?? activitySnapshotGroups(snapshots),
actions: actions,
showsLoadMore: hasMorePersistedItems
)
}
}
@@ -272,36 +259,34 @@ private struct FeedListView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private func stableRows(
snapshots: [FeedItemSnapshot],
actions: FeedRowActions
) -> some View {
VStack(spacing: 0) {
ForEach(Array(snapshots.enumerated()), id: \.element.id) { idx, snapshot in
rowSurface(
snapshot: snapshot,
actions: actions,
showsDivider: idx < snapshots.count - 1
)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func historyList(
snapshots: [FeedItemSnapshot],
private func activityScrollSurface(
groups: ActivitySnapshotGroups,
actions: FeedRowActions,
showsLoadMore: Bool
) -> some View {
List {
// Single chronological history stream. The plain List keeps
// virtualization for older feed rows while active decision
// surfaces live above it in a stable stack.
ForEach(Array(snapshots.enumerated()), id: \.element.id) { idx, snapshot in
ForEach(Array(groups.stable.enumerated()), id: \.element.id) { idx, snapshot in
rowSurface(
snapshot: snapshot,
actions: actions,
showsDivider: idx < snapshots.count - 1
showsDivider: idx < groups.stable.count - 1
)
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
if !groups.stable.isEmpty && (!groups.history.isEmpty || showsLoadMore) {
rowSeparator
.id("feed.activity.separator")
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
ForEach(Array(groups.history.enumerated()), id: \.element.id) { idx, snapshot in
rowSurface(
snapshot: snapshot,
actions: actions,
showsDivider: idx < groups.history.count - 1
)
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
@@ -324,6 +309,27 @@ private struct FeedListView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private struct ActivitySnapshotGroups {
let stable: [FeedItemSnapshot]
let history: [FeedItemSnapshot]
let ordered: [FeedItemSnapshot]
}
private func activitySnapshotGroups(_ snapshots: [FeedItemSnapshot]) -> ActivitySnapshotGroups {
var stable: [FeedItemSnapshot] = []
var history: [FeedItemSnapshot] = []
stable.reserveCapacity(snapshots.count)
history.reserveCapacity(snapshots.count)
for snapshot in snapshots {
if prefersStableSurface(snapshot) {
stable.append(snapshot)
} else {
history.append(snapshot)
}
}
return ActivitySnapshotGroups(stable: stable, history: history, ordered: stable + history)
}
private func rowSurface(
snapshot: FeedItemSnapshot,
actions: FeedRowActions,
@@ -335,12 +341,16 @@ private struct FeedListView: View {
isSelected: focusSnapshot.selectedItemId == snapshot.id,
isFocusActive: focusSnapshot.isKeyboardActive && focusSnapshot.selectedItemId == snapshot.id,
showsDivider: showsDivider,
stopDraft: stopDraftBinding(for: snapshot.id),
onPressSelect: {
selectRow(snapshot.id, focusFeed: true)
selectRow(snapshot.id, focusFeed: false)
},
onControlFocus: {
selectRow(snapshot.id, focusFeed: false)
},
onControlAction: {
selectRow(snapshot.id, focusFeed: true)
},
onControlBlur: {
syncFeedFocusSnapshot()
},
@@ -352,6 +362,19 @@ private struct FeedListView: View {
.id(snapshot.id)
}
private func stopDraftBinding(for id: UUID) -> Binding<FeedStopDraft> {
Binding(
get: { stopDrafts[id] ?? FeedStopDraft() },
set: { draft in
if draft.isPristine {
stopDrafts.removeValue(forKey: id)
} else {
stopDrafts[id] = draft
}
}
)
}
/// Walks the full items list (not just the filtered visible set),
/// ordered by createdAt, and records the most recent user-prompt
/// text per workstreamId. Rows consult this dict to show a
@@ -420,7 +443,7 @@ private struct FeedListView: View {
"frBefore=\(feedDebugResponderSummary(window?.firstResponder))"
)
#endif
if focusFeed || selectionChanged {
if focusFeed {
FeedInlineNativeTextView.blurActiveEditor()
}
let optimisticSnapshot = FeedFocusSnapshot(selectedItemId: id, isKeyboardActive: true)
@@ -574,18 +597,29 @@ private struct FeedScrollRequest: Equatable {
let sequence: Int
}
struct FeedStopDraft: Equatable {
var reply = ""
var isPristine: Bool {
reply.isEmpty
}
}
private struct FeedRowSurface: View {
let snapshot: FeedItemSnapshot
let actions: FeedRowActions
let isSelected: Bool
let isFocusActive: Bool
let showsDivider: Bool
@Binding var stopDraft: FeedStopDraft
let onPressSelect: () -> Void
let onControlFocus: () -> Void
let onControlAction: () -> Void
let onControlBlur: () -> Void
let onActivate: () -> Void
@State private var isHovered = false
@State private var stopReplyFocusRequest = 0
var body: some View {
VStack(spacing: 0) {
@@ -595,8 +629,13 @@ private struct FeedRowSurface: View {
isSelected: isFocusActive,
onPressSelect: onPressSelect,
onControlFocus: onControlFocus,
onControlAction: onControlAction,
onControlBlur: onControlBlur,
onActivate: onActivate
onActivate: onActivate,
stopDraft: $stopDraft,
stopDraftValue: stopDraft,
stopFocusRequest: $stopReplyFocusRequest,
stopFocusRequestValue: stopReplyFocusRequest
)
.equatable()
if showsDivider {
@@ -950,14 +989,21 @@ struct FeedItemRow: View, Equatable {
let isSelected: Bool
let onPressSelect: () -> Void
let onControlFocus: () -> Void
let onControlAction: () -> Void
let onControlBlur: () -> Void
let onActivate: () -> Void
@Binding var stopDraft: FeedStopDraft
let stopDraftValue: FeedStopDraft
@Binding var stopFocusRequest: Int
let stopFocusRequestValue: Int
@State private var didHandlePressSelection = false
static func == (lhs: FeedItemRow, rhs: FeedItemRow) -> Bool {
lhs.snapshot == rhs.snapshot
&& lhs.isSelected == rhs.isSelected
&& lhs.stopDraftValue == rhs.stopDraftValue
&& lhs.stopFocusRequestValue == rhs.stopFocusRequestValue
}
var body: some View {
@@ -1118,11 +1164,11 @@ struct FeedItemRow: View, Equatable {
case .claude: return Color(red: 0.92, green: 0.54, blue: 0.29)
case .codex: return .green
case .opencode: return .blue
case .hermesAgent: return .teal
case .cursor: return .purple
default: return .secondary
}
}
private var sourceChipBackground: Color {
return sourceChipForeground.opacity(0.18)
}
@@ -1180,6 +1226,7 @@ struct FeedItemRow: View, Equatable {
toolInputJSON: toolInputJSON,
source: snapshot.source,
status: snapshot.status,
onActionRow: onControlAction,
onApprove: { mode in
actions.approvePermission(snapshot.id, mode)
}
@@ -1191,6 +1238,7 @@ struct FeedItemRow: View, Equatable {
status: snapshot.status,
isRowSelected: isSelected,
onFocusRow: onControlFocus,
onActionRow: onControlAction,
onBlurRow: onControlBlur,
onApprove: { mode, feedback in
actions.approveExitPlan(snapshot.id, mode, feedback)
@@ -1203,6 +1251,7 @@ struct FeedItemRow: View, Equatable {
status: snapshot.status,
isRowSelected: isSelected,
onFocusRow: onControlFocus,
onActionRow: onControlAction,
onBlurRow: onControlBlur,
context: displayContext,
onReply: { selections in
@@ -1211,9 +1260,10 @@ struct FeedItemRow: View, Equatable {
)
case .stop:
StopActionArea(
workstreamId: snapshot.workstreamId,
isRowSelected: isSelected,
draft: $stopDraft,
focusRequest: $stopFocusRequest,
onFocusRow: onControlFocus,
onActionRow: onControlAction,
onBlurRow: onControlBlur,
onSend: { text in actions.sendText(snapshot.workstreamId, text) }
)
@@ -1371,6 +1421,7 @@ private struct PermissionActionArea: View {
let toolInputJSON: String
let source: WorkstreamSource
let status: WorkstreamStatus
let onActionRow: () -> Void
let onApprove: (WorkstreamPermissionMode) -> Void
var body: some View {
@@ -1380,19 +1431,31 @@ private struct PermissionActionArea: View {
if status.isPending {
HStack(spacing: 6) {
FeedButton(label: String(localized: "feed.permission.deny", defaultValue: "Deny"),
kind: .dark, size: .medium, fullWidth: true) { onApprove(.deny) }
kind: .dark, size: .medium, fullWidth: true) {
onActionRow()
onApprove(.deny)
}
.accessibilityIdentifier("FeedPermissionDenyButton")
FeedButton(label: String(localized: "feed.permission.once", defaultValue: "Allow Once"),
kind: .light, size: .medium, fullWidth: true) { onApprove(.once) }
kind: .light, size: .medium, fullWidth: true) {
onActionRow()
onApprove(.once)
}
.accessibilityIdentifier("FeedPermissionAllowOnceButton")
if FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: source) {
FeedButton(label: String(localized: "feed.permission.always", defaultValue: "Always Allow"),
kind: .primary, size: .medium, fullWidth: true) { onApprove(.always) }
kind: .primary, size: .medium, fullWidth: true) {
onActionRow()
onApprove(.always)
}
.accessibilityIdentifier("FeedPermissionAlwaysAllowButton")
}
if FeedPermissionActionPolicy.supportsBypassPermissions(source: source) {
FeedButton(label: String(localized: "feed.permission.bypass", defaultValue: "Bypass"),
kind: .destructive, size: .medium, fullWidth: true) { onApprove(.bypass) }
kind: .destructive, size: .medium, fullWidth: true) {
onActionRow()
onApprove(.bypass)
}
.accessibilityIdentifier("FeedPermissionBypassButton")
}
}
@@ -2135,6 +2198,7 @@ private struct ExitPlanActionArea: View {
let status: WorkstreamStatus
let isRowSelected: Bool
let onFocusRow: () -> Void
let onActionRow: () -> Void
let onBlurRow: () -> Void
let onApprove: (WorkstreamExitPlanMode, String?) -> Void
@@ -2204,7 +2268,8 @@ private struct ExitPlanActionArea: View {
kind: hasFeedback ? .primary : .soft,
size: .medium, fullWidth: true
) {
onFocusRow()
feedbackFocused = false
onActionRow()
// Feedback always wins over mode; hook translates
// non-empty feedback into block+reason.
onApprove(hasFeedback ? .manual : .ultraplan, hasFeedback ? trimmedFeedback : nil)
@@ -2216,7 +2281,8 @@ private struct ExitPlanActionArea: View {
size: .medium, fullWidth: true,
dimmed: hasFeedback
) {
onFocusRow()
feedbackFocused = false
onActionRow()
onApprove(.manual, hasFeedback ? trimmedFeedback : nil)
}
FeedButton(
@@ -2226,7 +2292,8 @@ private struct ExitPlanActionArea: View {
size: .medium, fullWidth: true,
dimmed: hasFeedback
) {
onFocusRow()
feedbackFocused = false
onActionRow()
onApprove(.autoAccept, hasFeedback ? trimmedFeedback : nil)
}
}
@@ -2542,6 +2609,7 @@ private struct QuestionActionArea: View {
let status: WorkstreamStatus
let isRowSelected: Bool
let onFocusRow: () -> Void
let onActionRow: () -> Void
let onBlurRow: () -> Void
let context: WorkstreamContext?
let onReply: ([String]) -> Void
@@ -2555,7 +2623,8 @@ private struct QuestionActionArea: View {
// non-empty, wins over preset option selections for that
// question mirrors Claude's TUI fallback.
@State private var freeTexts: [String: String] = [:]
@State private var focusedCustomAnswerId: String?
@State private var customAnswerFocusKey: String?
@State private var customAnswerFocusRequest = 0
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -2637,7 +2706,7 @@ private struct QuestionActionArea: View {
let selected = selections[questionId]?.contains(option.id) == true
return Button {
guard status.isPending else { return }
onFocusRow()
onActionRow()
clearCustomAnswerFocus()
var current = selections[questionId] ?? []
if multi {
@@ -2712,7 +2781,7 @@ private struct QuestionActionArea: View {
)
customAnswerField(
text: customAnswerBinding(questionId: questionId, multi: multi),
isFocused: customAnswerFocusBinding(focusKey),
focusRequest: focusRequest(forCustomAnswerKey: focusKey),
font: font,
onFocus: {
onFocusRow()
@@ -2741,7 +2810,7 @@ private struct QuestionActionArea: View {
guard status.isPending else { return }
onFocusRow()
selectCustomAnswer(questionId: questionId, multi: multi)
focusedCustomAnswerId = focusKey
requestCustomAnswerFocus(focusKey)
}
.feedIBeamCursorOnHover(enabled: status.isPending)
.disabled(!status.isPending)
@@ -2805,7 +2874,7 @@ private struct QuestionActionArea: View {
let font = NSFont.systemFont(ofSize: 11)
return customAnswerField(
text: customAnswerBinding(questionId: questionId, multi: multi),
isFocused: customAnswerFocusBinding(focusKey),
focusRequest: focusRequest(forCustomAnswerKey: focusKey),
font: font,
onFocus: {
onFocusRow()
@@ -2829,26 +2898,27 @@ private struct QuestionActionArea: View {
guard status.isPending else { return }
onFocusRow()
selectCustomAnswer(questionId: questionId, multi: multi)
focusedCustomAnswerId = focusKey
requestCustomAnswerFocus(focusKey)
}
}
private func customAnswerField(
text: Binding<String>,
isFocused: Binding<Bool>,
focusRequest: Int?,
font: NSFont,
onFocus: @escaping () -> Void,
onBlur: @escaping () -> Void
) -> some View {
FeedInlineTextField(
text: text,
isFocused: isFocused,
focusRequest: focusRequest,
placeholder: String(localized: "feed.question.typeSomething",
defaultValue: "Type something..."),
isEnabled: status.isPending,
font: font,
onFocus: onFocus,
onBlur: onBlur
onBlur: onBlur,
onSubmit: nil
)
.frame(
maxWidth: .infinity,
@@ -2876,23 +2946,19 @@ private struct QuestionActionArea: View {
)
}
private func customAnswerFocusBinding(_ focusKey: String) -> Binding<Bool> {
Binding<Bool>(
get: { focusedCustomAnswerId == focusKey },
set: { focused in
if focused {
focusedCustomAnswerId = focusKey
} else if focusedCustomAnswerId == focusKey {
focusedCustomAnswerId = nil
}
}
)
}
private func customAnswerFocusKey(_ questionId: String) -> String {
"\(questionId)::custom"
}
private func focusRequest(forCustomAnswerKey focusKey: String) -> Int? {
customAnswerFocusKey == focusKey ? customAnswerFocusRequest : nil
}
private func requestCustomAnswerFocus(_ focusKey: String) {
customAnswerFocusKey = focusKey
customAnswerFocusRequest += 1
}
private func selectCustomAnswer(questionId: String, multi: Bool) {
var current = selections[questionId] ?? []
if multi {
@@ -2904,7 +2970,7 @@ private struct QuestionActionArea: View {
}
private func clearCustomAnswerFocus() {
focusedCustomAnswerId = nil
customAnswerFocusKey = nil
}
private func optionPill(
@@ -2925,7 +2991,7 @@ private struct QuestionActionArea: View {
dimmed: !status.isPending
) {
guard status.isPending else { return }
onFocusRow()
onActionRow()
clearCustomAnswerFocus()
var current = selections[questionId] ?? []
if multi {
@@ -3014,7 +3080,7 @@ private struct QuestionActionArea: View {
fullWidth: true,
dimmed: !enabled
) {
onFocusRow()
onActionRow()
// Selections carry human-readable answer strings (one per
// answered question) so the hook can feed them straight
// back to the agent as the user's reply.
@@ -3031,7 +3097,7 @@ private struct QuestionActionArea: View {
size: .medium,
fullWidth: true
) {
onFocusRow()
onActionRow()
onReply([Self.skipInterviewAndPlanAnswer])
}
}
@@ -3046,6 +3112,7 @@ private final class FeedInlineNativeTextView: NSTextView, FeedKeyboardFocusRespo
var onActivate: (() -> Void)?
var onEscape: (() -> Void)?
var onSubmit: (() -> Void)?
static func blurActiveEditor() {
guard let activeEditor else { return }
@@ -3086,6 +3153,17 @@ private final class FeedInlineNativeTextView: NSTextView, FeedKeyboardFocusRespo
return super.performKeyEquivalent(with: event)
}
override func keyDown(with event: NSEvent) {
let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
let shouldSubmit = (event.keyCode == 36 || event.keyCode == 76)
&& normalizedFlags.intersection([.shift, .option, .command, .control]).isEmpty
if shouldSubmit, !hasMarkedText(), let onSubmit {
onSubmit()
return
}
super.keyDown(with: event)
}
override func resetCursorRects() {
super.resetCursorRects()
addCursorRect(bounds, cursor: .iBeam)
@@ -3250,8 +3328,11 @@ private final class FeedInlineTextEditorView: NSView {
)
layoutManager.ensureLayout(for: textContainer)
let usedRect = layoutManager.usedRect(for: textContainer)
let extraLineHeight = layoutManager.extraLineFragmentTextContainer == textContainer
? layoutManager.extraLineFragmentRect.height
: 0
let lineHeight = ceil(currentFont.ascender - currentFont.descender + currentFont.leading)
let contentHeight = max(lineHeight, ceil(usedRect.height))
let contentHeight = max(lineHeight, ceil(usedRect.height + extraLineHeight))
return max(
Self.minimumHeight(for: currentFont),
ceil(contentHeight + Self.textInset.height * 2)
@@ -3279,22 +3360,24 @@ private final class FeedInlineTextEditorView: NSView {
private struct FeedInlineTextField: NSViewRepresentable {
@Binding var text: String
@Binding var isFocused: Bool
let focusRequest: Int?
let placeholder: String
let isEnabled: Bool
let font: NSFont
let onFocus: () -> Void
let onBlur: () -> Void
let onSubmit: (() -> Void)?
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: FeedInlineTextField
var isProgrammaticMutation = false
weak var view: FeedInlineTextEditorView?
var pendingFocusRequest: Bool?
var lastAppliedFocusRequest: Int?
init(parent: FeedInlineTextField) {
self.parent = parent
self.lastAppliedFocusRequest = parent.focusRequest
}
func activateField() {
@@ -3302,16 +3385,9 @@ private struct FeedInlineTextField: NSViewRepresentable {
dlog("feed.editor.activateField")
#endif
parent.onFocus()
if !parent.isFocused {
parent.isFocused = true
}
}
func blurField() {
pendingFocusRequest = nil
if parent.isFocused {
parent.isFocused = false
}
guard let view, let window = view.window, window.firstResponder === view.textView else {
return
}
@@ -3344,9 +3420,6 @@ private struct FeedInlineTextField: NSViewRepresentable {
if !isProgrammaticMutation, let textView = notification.object as? NSTextView {
parent.text = textView.string
}
if parent.isFocused {
parent.isFocused = false
}
guard let window = view?.window else {
parent.onBlur()
return
@@ -3373,6 +3446,7 @@ private struct FeedInlineTextField: NSViewRepresentable {
view.textView.onEscape = { [weak coordinator = context.coordinator] in
coordinator?.blurField()
}
view.textView.onSubmit = onSubmit
configure(view)
context.coordinator.view = view
return view
@@ -3387,6 +3461,7 @@ private struct FeedInlineTextField: NSViewRepresentable {
nsView.textView.onEscape = { [weak coordinator = context.coordinator] in
coordinator?.blurField()
}
nsView.textView.onSubmit = onSubmit
configure(nsView)
if nsView.textView.string != text, !nsView.textView.hasMarkedText() {
@@ -3397,34 +3472,36 @@ private struct FeedInlineTextField: NSViewRepresentable {
}
guard let window = nsView.window else { return }
let firstResponder = window.firstResponder
let isFirstResponder = firstResponder === nsView.textView
if isFocused, isEnabled, !isFirstResponder, context.coordinator.pendingFocusRequest != true {
context.coordinator.pendingFocusRequest = true
DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
coordinator?.pendingFocusRequest = nil
guard let coordinator, coordinator.parent.isFocused, coordinator.parent.isEnabled else { return }
nsView?.focusIfNeeded()
let isFirstResponder = window.firstResponder === nsView.textView
if let focusRequest,
focusRequest != context.coordinator.lastAppliedFocusRequest {
context.coordinator.lastAppliedFocusRequest = focusRequest
if isEnabled {
nsView.focusIfNeeded()
} else if isFirstResponder {
moveFocusToFeedHost(in: window)
}
} else if (!isFocused || !isEnabled), isFirstResponder, context.coordinator.pendingFocusRequest != false {
context.coordinator.pendingFocusRequest = false
Task { @MainActor [weak nsView, weak coordinator = context.coordinator] in
coordinator?.pendingFocusRequest = nil
guard let nsView, let window = nsView.window else { return }
let stillFocused = window.firstResponder === nsView.textView
guard stillFocused else { return }
if AppDelegate.shared?.focusRightSidebarInActiveMainWindow(
mode: .feed,
focusFirstItem: false,
preferredWindow: window
) != true {
window.makeFirstResponder(nil)
}
} else if focusRequest == nil {
context.coordinator.lastAppliedFocusRequest = nil
if !isEnabled, isFirstResponder {
moveFocusToFeedHost(in: window)
}
} else if !isEnabled, isFirstResponder {
moveFocusToFeedHost(in: window)
}
}
private func moveFocusToFeedHost(in window: NSWindow) {
if AppDelegate.shared?.focusRightSidebarInActiveMainWindow(
mode: .feed,
focusFirstItem: false,
preferredWindow: window
) == true {
return
}
window.makeFirstResponder(nil)
}
private func configure(_ view: FeedInlineTextEditorView) {
view.placeholder = placeholder
view.apply(font: font, isEnabled: isEnabled)
@@ -3442,6 +3519,7 @@ private struct FeedInlineTextField: NSViewRepresentable {
nsView.textView.delegate = nil
nsView.textView.onActivate = nil
nsView.textView.onEscape = nil
nsView.textView.onSubmit = nil
}
}
@@ -3549,19 +3627,25 @@ private struct FlowLayout: Layout {
/// types the reply into the agent's terminal surface and presses
/// Return so the user can reply without switching focus.
private struct StopActionArea: View {
let workstreamId: String
let isRowSelected: Bool
@Binding var draft: FeedStopDraft
@Binding var focusRequest: Int
let onFocusRow: () -> Void
let onActionRow: () -> Void
let onBlurRow: () -> Void
let onSend: (String) -> Void
@State private var reply: String = ""
@FocusState private var replyFocused: Bool
private var trimmed: String {
reply.trimmingCharacters(in: .whitespacesAndNewlines)
draft.reply.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSend: Bool { !trimmed.isEmpty }
private var replyFont: NSFont { NSFont.systemFont(ofSize: 12) }
private var replyBinding: Binding<String> {
Binding(
get: { draft.reply },
set: { draft.reply = $0 }
)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
@@ -3573,23 +3657,23 @@ private struct StopActionArea: View {
.font(.system(size: 11, weight: .medium))
.foregroundColor(.secondary)
}
TextField(
String(localized: "feed.stop.placeholder", defaultValue: "Reply to Claude…"),
text: $reply,
axis: .vertical
FeedInlineTextField(
text: replyBinding,
focusRequest: focusRequest == 0 ? nil : focusRequest,
placeholder: String(localized: "feed.stop.placeholder", defaultValue: "Reply to Claude…"),
isEnabled: true,
font: replyFont,
onFocus: onFocusRow,
onBlur: onBlurRow,
onSubmit: sendReply
)
.textFieldStyle(.plain)
.font(.system(size: 12))
.focused($replyFocused)
.onChange(of: replyFocused) { _, focused in
if focused {
onFocusRow()
} else {
onBlurRow()
}
}
.lineLimit(1...5)
.padding(10)
.frame(
maxWidth: .infinity,
minHeight: FeedInlineTextEditorView.minimumHeight(for: replyFont),
alignment: .leading
)
.padding(.horizontal, 10)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.primary.opacity(0.06))
@@ -3598,11 +3682,11 @@ private struct StopActionArea: View {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.stroke(Color.primary.opacity(canSend ? 0.25 : 0.10), lineWidth: 1)
)
.onSubmit {
if canSend {
onSend(trimmed)
reply = ""
}
.contentShape(Rectangle())
.feedIBeamCursorOnHover(enabled: true)
.onTapGesture {
onFocusRow()
requestReplyFocus()
}
FeedButton(
label: String(localized: "feed.stop.send", defaultValue: "Send to Claude"),
@@ -3612,17 +3696,22 @@ private struct StopActionArea: View {
fullWidth: true,
dimmed: !canSend
) {
onFocusRow()
onSend(trimmed)
reply = ""
}
}
.onChange(of: isRowSelected) { _, selected in
if !selected {
replyFocused = false
guard canSend else { return }
onActionRow()
sendReply()
}
}
}
private func requestReplyFocus() {
focusRequest += 1
}
private func sendReply() {
guard canSend else { return }
onSend(trimmed)
draft.reply = ""
}
}
private struct TelemetryActionArea: View {
-17
View File
@@ -57,8 +57,6 @@ struct FeedHistoryLoadMoreRow: View {
let isLoading: Bool
let action: () -> Void
@State private var isVisible = false
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
@@ -77,17 +75,6 @@ struct FeedHistoryLoadMoreRow: View {
}
.buttonStyle(.plain)
.disabled(isLoading)
.onAppear {
isVisible = true
requestLoadIfVisible()
}
.onDisappear {
isVisible = false
}
.onChange(of: isLoading) { _, loading in
guard !loading else { return }
requestLoadIfVisible()
}
}
private var label: String {
@@ -97,8 +84,4 @@ struct FeedHistoryLoadMoreRow: View {
return String(localized: "feed.history.loadOlder", defaultValue: "Load older activity")
}
private func requestLoadIfVisible() {
guard isVisible, !isLoading else { return }
action()
}
}
@@ -2,10 +2,10 @@ import CMUXWorkstream
enum FeedPermissionActionPolicy {
static func supportsPersistentPermissionModes(source: WorkstreamSource) -> Bool {
source != .codex
source != .codex && source != .hermesAgent
}
static func supportsBypassPermissions(source: WorkstreamSource) -> Bool {
source != .codex && source != .claude
source != .codex && source != .claude && source != .hermesAgent
}
}
@@ -119,6 +119,9 @@ private struct FeedPreviewRootView: View {
private struct FeedPreviewCardHost: View {
let item: WorkstreamItem
@State private var stopDraft = FeedStopDraft()
@State private var stopFocusRequest = 0
var body: some View {
FeedItemRow(
snapshot: FeedItemSnapshot(
@@ -129,8 +132,13 @@ private struct FeedPreviewCardHost: View {
isSelected: false,
onPressSelect: {},
onControlFocus: {},
onControlAction: {},
onControlBlur: {},
onActivate: {}
onActivate: {},
stopDraft: $stopDraft,
stopDraftValue: stopDraft,
stopFocusRequest: $stopFocusRequest,
stopFocusRequestValue: stopFocusRequest
)
}
}
@@ -16,6 +16,7 @@ final class FeedTextEditorDebugWindowController: NSWindowController, NSWindowDel
localized: "feed.textEditorDebug.windowTitle",
defaultValue: "Feed Text Editor Lab"
)
window.identifier = NSUserInterfaceItemIdentifier("cmux.feedTextEditorDebug")
window.center()
window.contentView = NSHostingView(rootView: FeedTextEditorDebugView())
super.init(window: window)
+117
View File
@@ -0,0 +1,117 @@
import AppKit
@MainActor
final class FileDropHintBadgeView: NSView {
private static let neutralBackgroundColor = NSColor.systemGray.withAlphaComponent(0.20)
private let effectView: NSView
private let label = NSTextField(labelWithString: "")
private var animationGeneration: UInt64 = 0
override var isOpaque: Bool { false }
override init(frame frameRect: NSRect) {
if let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type {
effectView = glassClass.init(frame: .zero)
} else {
let visualEffect = NSVisualEffectView(frame: .zero)
visualEffect.material = .hudWindow
visualEffect.blendingMode = .withinWindow
visualEffect.state = .active
effectView = visualEffect
}
super.init(frame: frameRect)
frame.size = CGSize(width: 140, height: 26)
wantsLayer = true
layer?.backgroundColor = NSColor.clear.cgColor
alphaValue = 0
isHidden = true
effectView.translatesAutoresizingMaskIntoConstraints = false
effectView.wantsLayer = true
effectView.layer?.backgroundColor = Self.neutralBackgroundColor.cgColor
effectView.layer?.cornerRadius = 13
effectView.layer?.masksToBounds = true
configureNativeGlassIfNeeded(effectView)
addSubview(effectView)
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .systemFont(ofSize: 12, weight: .medium)
label.textColor = .labelColor
label.alignment = .center
label.lineBreakMode = .byClipping
label.maximumNumberOfLines = 1
label.setContentCompressionResistancePriority(.required, for: .horizontal)
addSubview(label, positioned: .above, relativeTo: effectView)
NSLayoutConstraint.activate([
effectView.topAnchor.constraint(equalTo: topAnchor),
effectView.bottomAnchor.constraint(equalTo: bottomAnchor),
effectView.leadingAnchor.constraint(equalTo: leadingAnchor),
effectView.trailingAnchor.constraint(equalTo: trailingAnchor),
label.topAnchor.constraint(equalTo: topAnchor, constant: 5),
label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5),
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {}
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
func show(text: String, centeredIn targetBounds: CGRect, clippedTo bounds: CGRect) {
animationGeneration &+= 1
label.stringValue = text
let fitting = label.intrinsicContentSize
let maxWidth = max(80, min(bounds.width, targetBounds.width) - 16)
let width = min(max(140, fitting.width + 20), maxWidth)
let height: CGFloat = 26
let origin = CGPoint(
x: min(max(targetBounds.midX - width / 2, bounds.minX + 8), max(bounds.minX + 8, bounds.maxX - width - 8)),
y: min(max(targetBounds.midY - height / 2, bounds.minY + 8), max(bounds.minY + 8, bounds.maxY - height - 8))
)
frame = CGRect(origin: origin, size: CGSize(width: width, height: height))
if isHidden {
alphaValue = 0
isHidden = false
animator().alphaValue = 1
} else {
alphaValue = 1
}
}
func hide() {
guard !isHidden else { return }
animationGeneration &+= 1
let generation = animationGeneration
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.12
animator().alphaValue = 0
} completionHandler: { [weak self] in
Task { @MainActor in
guard let self else { return }
guard self.animationGeneration == generation else { return }
self.isHidden = true
}
}
}
private func configureNativeGlassIfNeeded(_ view: NSView) {
guard view.className == "NSGlassEffectView" else { return }
let tintSelector = NSSelectorFromString("setTintColor:")
if view.responds(to: tintSelector) {
view.perform(tintSelector, with: Self.neutralBackgroundColor)
}
}
}
+437
View File
@@ -0,0 +1,437 @@
import AppKit
import Bonsplit
import Foundation
import WebKit
@MainActor
protocol FileDropPaneTarget: AnyObject {
func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation
func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation
func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?)
func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool
func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool
func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?)
}
extension PaneDropTargetView: FileDropPaneTarget {
func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingEntered(sender) }
func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) }
func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?) { draggingExited(sender) }
func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { prepareForDragOperation(sender) }
func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool { performDragOperation(sender) }
func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?) { concludeDragOperation(sender) }
}
extension BrowserPaneDropTargetView: FileDropPaneTarget {
func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingEntered(sender) }
func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) }
func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?) { draggingExited(sender) }
func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { prepareForDragOperation(sender) }
func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool { performDragOperation(sender) }
func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?) { concludeDragOperation(sender) }
}
/// Transparent NSView installed on the window's theme frame (above the NSHostingView) to
/// handle file/URL drags from Finder. Nested NSHostingController layers (created by bonsplit's
/// SinglePaneWrapper) prevent AppKit's NSDraggingDestination routing from reaching deeply
/// embedded terminal views. This overlay sits above the entire content view hierarchy and
/// intercepts file drags, forwarding drops to the GhosttyNSView under the cursor.
///
/// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks,
/// scrolls, and other interactions pass through normally.
final class FileDropOverlayView: NSView {
/// Fallback handler when no terminal is found under the drop point.
var onDrop: (([URL]) -> Bool)?
private var isForwardingMouseEvent = false
private weak var forwardedMouseDragTarget: NSView?
private var forwardedMouseDragButton: ForwardedMouseDragButton?
/// The WKWebView currently receiving forwarded drag events, so we can
/// synthesize draggingExited/draggingEntered as the cursor moves.
weak var activeDragWebView: WKWebView?
/// The WKWebView that accepted prepareForDragOperation so conclude can be
/// delivered to the same browser target after the drop completes.
weak var preparedDragWebView: WKWebView?
/// Pane drop target currently receiving delegated file drag events.
weak var activePaneDropTarget: (any FileDropPaneTarget)?
/// Pane drop target that accepted prepareForDragOperation.
weak var preparedPaneDropTarget: (any FileDropPaneTarget)?
var didPerformDragAsText = false
weak var performedTextDragWebView: WKWebView?
weak var performedTextPaneDropTarget: (any FileDropPaneTarget)?
let hintBadgeView = FileDropHintBadgeView(frame: .zero)
var lastHitTestLogSignature: String?
var lastDragRouteLogSignatureByPhase: [String: String] = [:]
override var acceptsFirstResponder: Bool { false }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
registerForDraggedTypes(Array(PasteboardFileURLReader.fileURLPasteboardTypes))
addSubview(hintBadgeView)
}
required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") }
private enum ForwardedMouseDragButton: Equatable {
case left
case right
case other(Int)
}
private func dragButton(for event: NSEvent) -> ForwardedMouseDragButton? {
switch event.type {
case .leftMouseDown, .leftMouseUp, .leftMouseDragged:
return .left
case .rightMouseDown, .rightMouseUp, .rightMouseDragged:
return .right
case .otherMouseDown, .otherMouseUp, .otherMouseDragged:
return .other(Int(event.buttonNumber))
default:
return nil
}
}
private func shouldTrackForwardedMouseDragStart(for eventType: NSEvent.EventType) -> Bool {
switch eventType {
case .leftMouseDown, .rightMouseDown, .otherMouseDown:
return true
default:
return false
}
}
private func shouldTrackForwardedMouseDragEnd(for eventType: NSEvent.EventType) -> Bool {
switch eventType {
case .leftMouseUp, .rightMouseUp, .otherMouseUp:
return true
default:
return false
}
}
// MARK: Hit-testing participation is routed by DragOverlayRoutingPolicy so
// file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict.
override func hitTest(_ point: NSPoint) -> NSView? {
let pb = NSPasteboard(name: .drag)
let eventType = NSApp.currentEvent?.type
let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay(
pasteboardTypes: pb.types,
eventType: eventType
)
#if DEBUG
logHitTestDecision(
pasteboardTypes: pb.types,
eventType: eventType,
shouldCapture: shouldCapture
)
#endif
guard shouldCapture else { return nil }
if shouldDeferFileDropOverlayToBonsplitTabBar(at: point) {
return nil
}
return super.hitTest(point)
}
// MARK: Mouse forwarding safety net for the rare case where stale drag pasteboard
// data causes hitTest to return self when no drag is actually active.
// We hit-test contentView directly and dispatch to the target rather than using
// window.sendEvent(), which caches the mouse target and causes infinite recursion.
private func forwardEvent(_ event: NSEvent) {
guard !isForwardingMouseEvent else { return }
guard let window, let contentView = window.contentView else { return }
let eventButton = dragButton(for: event)
isForwardingMouseEvent = true
isHidden = true
defer {
isHidden = false
isForwardingMouseEvent = false
}
let target: NSView?
if let eventButton,
forwardedMouseDragButton == eventButton,
let activeTarget = forwardedMouseDragTarget,
activeTarget.window != nil {
// Preserve normal AppKit mouse-delivery semantics: once a drag starts,
// keep routing dragged/up events to the original mouseDown target.
target = activeTarget
} else {
let point = contentView.convert(event.locationInWindow, from: nil)
target = contentView.hitTest(point)
}
guard let target, target !== self else {
if shouldTrackForwardedMouseDragEnd(for: event.type),
let eventButton,
forwardedMouseDragButton == eventButton {
forwardedMouseDragTarget = nil
forwardedMouseDragButton = nil
}
return
}
if shouldTrackForwardedMouseDragStart(for: event.type), let eventButton {
forwardedMouseDragTarget = target
forwardedMouseDragButton = eventButton
}
switch event.type {
case .leftMouseDown: target.mouseDown(with: event)
case .leftMouseUp: target.mouseUp(with: event)
case .leftMouseDragged: target.mouseDragged(with: event)
case .rightMouseDown: target.rightMouseDown(with: event)
case .rightMouseUp: target.rightMouseUp(with: event)
case .rightMouseDragged: target.rightMouseDragged(with: event)
case .otherMouseDown: target.otherMouseDown(with: event)
case .otherMouseUp: target.otherMouseUp(with: event)
case .otherMouseDragged: target.otherMouseDragged(with: event)
case .scrollWheel: target.scrollWheel(with: event)
default: break
}
if shouldTrackForwardedMouseDragEnd(for: event.type),
let eventButton,
forwardedMouseDragButton == eventButton {
forwardedMouseDragTarget = nil
forwardedMouseDragButton = nil
}
}
override func mouseDown(with event: NSEvent) { forwardEvent(event) }
override func mouseUp(with event: NSEvent) { forwardEvent(event) }
override func mouseDragged(with event: NSEvent) { forwardEvent(event) }
override func rightMouseDown(with event: NSEvent) { forwardEvent(event) }
override func rightMouseUp(with event: NSEvent) { forwardEvent(event) }
override func rightMouseDragged(with event: NSEvent) { forwardEvent(event) }
override func otherMouseDown(with event: NSEvent) { forwardEvent(event) }
override func otherMouseUp(with event: NSEvent) { forwardEvent(event) }
override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) }
override func scrollWheel(with event: NSEvent) { forwardEvent(event) }
// MARK: NSDraggingDestination accept file drops over terminal and browser views.
//
// AppKit sends draggingEntered once when the drag enters this overlay, then
// draggingUpdated as the cursor moves within it. We track which WKWebView (if
// any) is under the cursor and synthesize enter/exit calls so the browser's
// HTML5 drag events (dragenter, dragleave, drop) fire correctly.
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
return updateDragTarget(sender, phase: "entered")
}
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
return updateDragTarget(sender, phase: "updated")
}
override func draggingExited(_ sender: (any NSDraggingInfo)?) {
hintBadgeView.hide()
preparedDragWebView = nil
preparedPaneDropTarget = nil
didPerformDragAsText = false
performedTextDragWebView = nil
performedTextPaneDropTarget = nil
exitActiveDragTargets(sender)
}
private func exitActiveDragTargets(_ sender: (any NSDraggingInfo)?) {
if let prev = activeDragWebView {
prev.draggingExited(sender)
activeDragWebView = nil
}
if let prev = activePaneDropTarget {
prev.fileDropDraggingExited(sender)
activePaneDropTarget = nil
}
}
override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool {
let hasLocalDraggingSource = sender.draggingSource != nil
let types = sender.draggingPasteboard.types
let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination(
pasteboardTypes: types,
hasLocalDraggingSource: hasLocalDraggingSource
)
if shouldRouteFileDropToTextDestination(sender) {
let paneDropTarget = activePaneDropTarget ?? paneDropTargetForTextDrop(at: sender.draggingLocation)
exitActiveDragTargets(sender)
preparedDragWebView = nil
if let paneDropTarget {
let accepted = paneDropTarget.fileDropPrepareForDragOperation(sender)
preparedPaneDropTarget = accepted ? paneDropTarget : nil
return accepted
}
preparedPaneDropTarget = nil
if let webView = webViewUnderPoint(sender.draggingLocation) {
let accepted = webView.prepareForDragOperation(sender)
preparedDragWebView = accepted ? webView : nil
return accepted
}
return textDropDestinationKindUnderPoint(sender.draggingLocation) != nil
}
let paneDropTarget = shouldCapture
? (activePaneDropTarget ?? paneDropTargetUnderPoint(sender.draggingLocation))
: nil
let terminal = paneDropTarget == nil ? terminalUnderPoint(sender.draggingLocation) : nil
let webView = paneDropTarget == nil && terminal == nil ? webViewUnderPoint(sender.draggingLocation) : nil
let hasPaneTarget = paneDropTarget != nil || terminal != nil || webView != nil
#if DEBUG
logDragRouteDecision(
phase: "prepare",
pasteboardTypes: types,
shouldCapture: shouldCapture,
hasLocalDraggingSource: hasLocalDraggingSource,
hasPaneTarget: hasPaneTarget
)
#endif
guard shouldCapture else {
preparedDragWebView = nil
preparedPaneDropTarget = nil
exitActiveDragTargets(sender)
return false
}
preparedDragWebView = nil
if let paneDropTarget {
let accepted = paneDropTarget.fileDropPrepareForDragOperation(sender)
preparedPaneDropTarget = accepted ? paneDropTarget : nil
return accepted
}
preparedPaneDropTarget = nil
if let webView {
let accepted = webView.prepareForDragOperation(sender)
preparedDragWebView = accepted ? webView : nil
return accepted
}
return hasPaneTarget
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
let hasLocalDraggingSource = sender.draggingSource != nil
let types = sender.draggingPasteboard.types
let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination(
pasteboardTypes: types,
hasLocalDraggingSource: hasLocalDraggingSource
)
if shouldRouteFileDropToTextDestination(sender) {
hintBadgeView.hide()
didPerformDragAsText = false
performedTextDragWebView = nil
performedTextPaneDropTarget = nil
let paneDropTarget = preparedPaneDropTarget ?? activePaneDropTarget ?? paneDropTargetForTextDrop(at: sender.draggingLocation)
let webView = preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)
exitActiveDragTargets(sender)
preparedDragWebView = nil
if let paneDropTarget {
let handled = paneDropTarget.fileDropPerformDragOperation(sender)
if handled {
didPerformDragAsText = true
performedTextPaneDropTarget = paneDropTarget
} else {
preparedPaneDropTarget = nil
}
return handled
}
if let webView {
let handled = webView.performDragOperation(sender)
if !handled {
preparedDragWebView = nil
performedTextDragWebView = nil
} else {
didPerformDragAsText = true
performedTextDragWebView = webView
}
return handled
}
let handled = performFileDropAsText(sender)
didPerformDragAsText = handled
return handled
}
didPerformDragAsText = false
performedTextDragWebView = nil
performedTextPaneDropTarget = nil
let paneDropTarget = shouldCapture
? (preparedPaneDropTarget ?? activePaneDropTarget ?? paneDropTargetUnderPoint(sender.draggingLocation))
: nil
let terminal = paneDropTarget == nil ? terminalUnderPoint(sender.draggingLocation) : nil
let webView = paneDropTarget == nil && terminal == nil
? (preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation))
: nil
let hasPaneTarget = paneDropTarget != nil || terminal != nil || webView != nil
#if DEBUG
logDragRouteDecision(
phase: "perform",
pasteboardTypes: types,
shouldCapture: shouldCapture,
hasLocalDraggingSource: hasLocalDraggingSource,
hasPaneTarget: hasPaneTarget
)
#endif
guard shouldCapture else {
preparedDragWebView = nil
preparedPaneDropTarget = nil
exitActiveDragTargets(sender)
return false
}
preparedDragWebView = nil
if let paneDropTarget {
let handled = paneDropTarget.fileDropPerformDragOperation(sender)
if !handled {
preparedPaneDropTarget = nil
activePaneDropTarget = nil
}
return handled
}
preparedPaneDropTarget = nil
if let webView {
let handled = webView.performDragOperation(sender)
if !handled {
preparedDragWebView = nil
activeDragWebView = nil
}
return handled
}
activeDragWebView = nil
guard let terminal else { return false }
return terminal.performDragOperation(sender)
}
override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) {
defer {
hintBadgeView.hide()
preparedDragWebView = nil
activeDragWebView = nil
preparedPaneDropTarget = nil
activePaneDropTarget = nil
didPerformDragAsText = false
performedTextDragWebView = nil
performedTextPaneDropTarget = nil
}
guard let sender else { return }
if didPerformDragAsText {
if let paneDropTarget = performedTextPaneDropTarget ?? preparedPaneDropTarget ?? activePaneDropTarget {
paneDropTarget.fileDropConcludeDragOperation(sender)
} else if let webView = performedTextDragWebView {
webView.concludeDragOperation(sender)
}
exitActiveDragTargets(sender)
return
}
guard DragOverlayRoutingPolicy.shouldCaptureFileDropDestination(
pasteboardTypes: sender.draggingPasteboard.types,
hasLocalDraggingSource: sender.draggingSource != nil
) else {
return
}
if let paneDropTarget = preparedPaneDropTarget ?? activePaneDropTarget {
paneDropTarget.fileDropConcludeDragOperation(sender)
return
}
if let webView = preparedDragWebView ?? activeDragWebView {
webView.concludeDragOperation(sender)
}
}
}
+441
View File
@@ -0,0 +1,441 @@
import AppKit
import Bonsplit
import Foundation
import WebKit
extension FileDropOverlayView {
func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation {
let loc = sender.draggingLocation
let hasLocalDraggingSource = sender.draggingSource != nil
let types = sender.draggingPasteboard.types
let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination(
pasteboardTypes: types,
hasLocalDraggingSource: hasLocalDraggingSource
)
updateHintBadge(sender: sender, pasteboardTypes: types)
if shouldRouteFileDropToTextDestination(sender) {
let paneDropTarget = paneDropTargetForTextDrop(at: loc)
if let prev = activePaneDropTarget {
if fileDropPaneTargetsAreIdentical(prev, paneDropTarget) {
return prev.fileDropDraggingUpdated(sender)
}
prev.fileDropDraggingExited(sender)
activePaneDropTarget = nil
}
if let paneDropTarget {
if let prev = activeDragWebView {
prev.draggingExited(sender)
activeDragWebView = nil
}
activePaneDropTarget = paneDropTarget
return paneDropTarget.fileDropDraggingEntered(sender)
}
if let webView = webViewUnderPoint(loc) {
if activeDragWebView !== webView {
if let prev = activeDragWebView {
prev.draggingExited(sender)
}
activeDragWebView = webView
return webView.draggingEntered(sender)
}
return webView.draggingUpdated(sender)
}
if let prev = activeDragWebView {
prev.draggingExited(sender)
activeDragWebView = nil
}
return textDropDestinationKindUnderPoint(loc) == nil
? []
: DragOverlayRoutingPolicy.textDropOperation(pasteboardTypes: types)
}
let paneDropTarget = shouldCapture ? paneDropTargetUnderPoint(loc) : nil
let webView = shouldCapture && paneDropTarget == nil ? webViewUnderPoint(loc) : nil
if let prev = activeDragWebView {
if prev !== webView {
prev.draggingExited(sender)
activeDragWebView = nil
}
}
if let prev = activePaneDropTarget,
!fileDropPaneTargetsAreIdentical(prev, paneDropTarget) {
prev.fileDropDraggingExited(sender)
activePaneDropTarget = nil
}
if let paneDropTarget {
if !fileDropPaneTargetsAreIdentical(activePaneDropTarget, paneDropTarget) {
activePaneDropTarget = paneDropTarget
return paneDropTarget.fileDropDraggingEntered(sender)
}
return paneDropTarget.fileDropDraggingUpdated(sender)
}
if let webView {
if activeDragWebView !== webView {
activeDragWebView = webView
return webView.draggingEntered(sender)
}
return webView.draggingUpdated(sender)
}
let hasPaneTarget = terminalUnderPoint(loc) != nil
#if DEBUG
logDragRouteDecision(
phase: phase,
pasteboardTypes: types,
shouldCapture: shouldCapture,
hasLocalDraggingSource: hasLocalDraggingSource,
hasPaneTarget: hasPaneTarget
)
#endif
guard shouldCapture, hasPaneTarget else { return [] }
return .copy
}
private func fileDropPaneTargetsAreIdentical(
_ lhs: (any FileDropPaneTarget)?,
_ rhs: (any FileDropPaneTarget)?
) -> Bool {
guard let lhs, let rhs else { return lhs == nil && rhs == nil }
return (lhs as AnyObject) === (rhs as AnyObject)
}
private func debugPasteboardTypes(_ types: [NSPasteboard.PasteboardType]?) -> String {
guard let types, !types.isEmpty else { return "-" }
return types.map(\.rawValue).joined(separator: ",")
}
func shouldRouteFileDropToTextDestination(_ sender: any NSDraggingInfo) -> Bool {
let canDropAsText = textDropDestinationKindUnderPoint(sender.draggingLocation) != nil
return DragOverlayRoutingPolicy.shouldRouteFileDropToTextDestination(
pasteboardTypes: sender.draggingPasteboard.types,
modifierFlags: DragOverlayRoutingPolicy.currentModifierFlags,
canDropAsText: canDropAsText
)
}
private func updateHintBadge(
sender: any NSDraggingInfo,
pasteboardTypes: [NSPasteboard.PasteboardType]?
) {
let kind = textDropDestinationKindUnderPoint(sender.draggingLocation)
guard let alternateBehavior = DragOverlayRoutingPolicy.alternateFileDropBehaviorForShiftHint(
pasteboardTypes: pasteboardTypes,
modifierFlags: DragOverlayRoutingPolicy.currentModifierFlags,
canDropAsText: kind != nil
), let kind,
let hintText = kind.hintText(for: alternateBehavior),
let targetBounds = hintBadgeTargetBoundsUnderPoint(sender.draggingLocation) else {
hintBadgeView.hide()
return
}
hintBadgeView.show(text: hintText, centeredIn: targetBounds, clippedTo: bounds)
}
func textDropDestinationKindUnderPoint(_ windowPoint: NSPoint) -> FileDropTextDestinationKind? {
if editableTextViewUnderPoint(windowPoint) != nil {
return .editor
}
if webViewUnderPoint(windowPoint) != nil {
return .editor
}
if terminalUnderPoint(windowPoint) != nil {
return .terminal
}
return nil
}
private func hintBadgeTargetBoundsUnderPoint(_ windowPoint: NSPoint) -> CGRect? {
if let paneDropTarget = paneDropTargetUnderPoint(windowPoint),
let targetView = paneDropTarget as? NSView {
return convert(targetView.bounds, from: targetView)
}
if let terminal = terminalUnderPoint(windowPoint) {
return convert(terminal.bounds, from: terminal)
}
if let webView = webViewUnderPoint(windowPoint) {
return convert(webView.bounds, from: webView)
}
if let textView = editableTextViewUnderPoint(windowPoint) {
return convert(textView.visibleRect, from: textView)
}
return nil
}
func performFileDropAsText(_ sender: any NSDraggingInfo) -> Bool {
let urls = DragOverlayRoutingPolicy.fileURLs(from: sender.draggingPasteboard)
guard !urls.isEmpty else { return false }
let windowPoint = sender.draggingLocation
if let textView = editableTextViewUnderPoint(windowPoint) {
let text = TerminalImageTransferPlanner.insertedText(forFileURLs: urls)
guard !text.isEmpty else { return false }
return insert(text, into: textView)
}
if let terminal = terminalUnderPoint(windowPoint) {
return insert(urls, into: terminal)
}
return false
}
private func viewUnderPoint(_ windowPoint: NSPoint) -> NSView? {
guard let window, let contentView = window.contentView else { return nil }
isHidden = true
defer { isHidden = false }
let point = contentView.convert(windowPoint, from: nil)
return contentView.hitTest(point)
}
private func editableTextViewUnderPoint(_ windowPoint: NSPoint) -> NSTextView? {
var current = viewUnderPoint(windowPoint)
while let view = current {
if let textView = view as? NSTextView, textView.isEditable {
return textView
}
if let textField = view as? NSTextField,
textField.isEditable,
let editor = textField.currentEditor() as? NSTextView {
return editor
}
current = view.superview
}
return nil
}
private func insert(_ text: String, into textView: NSTextView) -> Bool {
guard textView.isEditable else { return false }
textView.window?.makeFirstResponder(textView)
textView.insertText(text, replacementRange: textView.selectedRange())
return true
}
private func insert(_ urls: [URL], into terminal: GhosttyNSView) -> Bool {
FileDropTextDropController.performTerminalFileDrop(
terminal: terminal,
urls: urls
)
}
/// Hit-tests the window to find a WKWebView (browser panel) under the cursor.
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 }
let point = contentView.convert(windowPoint, from: nil)
let hitView = contentView.hitTest(point)
var current: NSView? = hitView
while let view = current {
if let webView = view as? WKWebView { return webView }
current = view.superview
}
return nil
}
private func debugTopHitViewForCurrentEvent() -> String {
guard let window,
let currentEvent = NSApp.currentEvent,
let contentView = window.contentView,
let themeFrame = contentView.superview else { return "-" }
let pointInTheme = themeFrame.convert(currentEvent.locationInWindow, from: nil)
// Don't toggle isHidden here it triggers setNeedsDisplay which can
// exceed AppKit's display-pass limit during cursor-update display cycles.
guard let hit = themeFrame.hitTest(pointInTheme) else { return "nil" }
var chain: [String] = []
var current: NSView? = hit
var depth = 0
while let view = current, depth < 6 {
chain.append(debugHitViewDescriptor(view))
current = view.superview
depth += 1
}
return chain.joined(separator: "->")
}
private func debugHitViewDescriptor(_ view: NSView) -> String {
let className = String(describing: type(of: view))
let ptr = String(describing: Unmanaged.passUnretained(view).toOpaque())
let dragTypes = debugRegisteredDragTypes(view)
return "\(className)@\(ptr){dragTypes=\(dragTypes)}"
}
private func debugRegisteredDragTypes(_ view: NSView) -> String {
let types = view.registeredDraggedTypes
guard !types.isEmpty else { return "-" }
let interestingTypes = types.filter { type in
let raw = type.rawValue
return PasteboardFileURLReader.fileURLPasteboardTypes.contains(type)
|| raw == DragOverlayRoutingPolicy.bonsplitTabTransferType.rawValue
|| raw == DragOverlayRoutingPolicy.sidebarTabReorderType.rawValue
|| raw.contains("public.text")
|| raw.contains("public.url")
|| raw.contains("public.data")
}
let selected = interestingTypes.isEmpty ? Array(types.prefix(3)) : interestingTypes
let rendered = selected.map(\.rawValue).joined(separator: ",")
if selected.count < types.count {
return "\(rendered),+\(types.count - selected.count)"
}
return rendered
}
private func hasRelevantDragTypes(_ types: [NSPasteboard.PasteboardType]?) -> Bool {
guard let types else { return false }
return DragOverlayRoutingPolicy.hasFileDropPayload(types)
|| types.contains(DragOverlayRoutingPolicy.bonsplitTabTransferType)
|| types.contains(DragOverlayRoutingPolicy.sidebarTabReorderType)
}
private func debugEventName(_ eventType: NSEvent.EventType?) -> String {
guard let eventType else { return "none" }
switch eventType {
case .cursorUpdate: return "cursorUpdate"
case .appKitDefined: return "appKitDefined"
case .systemDefined: return "systemDefined"
case .applicationDefined: return "applicationDefined"
case .periodic: return "periodic"
case .mouseMoved: return "mouseMoved"
case .mouseEntered: return "mouseEntered"
case .mouseExited: return "mouseExited"
case .flagsChanged: return "flagsChanged"
case .leftMouseDown: return "leftMouseDown"
case .leftMouseUp: return "leftMouseUp"
case .leftMouseDragged: return "leftMouseDragged"
case .rightMouseDown: return "rightMouseDown"
case .rightMouseUp: return "rightMouseUp"
case .rightMouseDragged: return "rightMouseDragged"
case .otherMouseDown: return "otherMouseDown"
case .otherMouseUp: return "otherMouseUp"
case .otherMouseDragged: return "otherMouseDragged"
case .scrollWheel: return "scrollWheel"
default: return "other(\(eventType.rawValue))"
}
}
#if DEBUG
func logHitTestDecision(
pasteboardTypes: [NSPasteboard.PasteboardType]?,
eventType: NSEvent.EventType?,
shouldCapture: Bool
) {
let isDragEvent = eventType == .leftMouseDragged
|| eventType == .rightMouseDragged
|| eventType == .otherMouseDragged
guard shouldCapture || isDragEvent || hasRelevantDragTypes(pasteboardTypes) else { return }
let signature = "\(shouldCapture ? 1 : 0)|\(debugEventName(eventType))|\(debugPasteboardTypes(pasteboardTypes))"
guard lastHitTestLogSignature != signature else { return }
lastHitTestLogSignature = signature
cmuxDebugLog(
"overlay.fileDrop.hitTest capture=\(shouldCapture ? 1 : 0) " +
"event=\(debugEventName(eventType)) " +
"topHit=\(debugTopHitViewForCurrentEvent()) " +
"types=\(debugPasteboardTypes(pasteboardTypes))"
)
}
func logDragRouteDecision(
phase: String,
pasteboardTypes: [NSPasteboard.PasteboardType]?,
shouldCapture: Bool,
hasLocalDraggingSource: Bool,
hasPaneTarget: Bool
) {
guard shouldCapture || hasRelevantDragTypes(pasteboardTypes) else { return }
let signature = [
shouldCapture ? "1" : "0",
hasLocalDraggingSource ? "1" : "0",
hasPaneTarget ? "1" : "0",
debugPasteboardTypes(pasteboardTypes)
].joined(separator: "|")
guard lastDragRouteLogSignatureByPhase[phase] != signature else { return }
lastDragRouteLogSignatureByPhase[phase] = signature
cmuxDebugLog(
"overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " +
"localSource=\(hasLocalDraggingSource ? 1 : 0) " +
"hasPane=\(hasPaneTarget ? 1 : 0) " +
"types=\(debugPasteboardTypes(pasteboardTypes))"
)
}
#endif
/// Hit-tests the window to find the GhosttyNSView under the cursor.
func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? {
if let window,
let portalTerminal = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window) {
return portalTerminal
}
guard let window, let contentView = window.contentView else { return nil }
isHidden = true
defer { isHidden = false }
let point = contentView.convert(windowPoint, from: nil)
let hitView = contentView.hitTest(point)
var current: NSView? = hitView
while let view = current {
if let terminal = view as? GhosttyNSView { return terminal }
current = view.superview
}
return nil
}
func shouldDeferFileDropOverlayToBonsplitTabBar(at point: NSPoint) -> Bool {
guard let window else { return false }
let windowPoint = convert(point, to: nil)
return BonsplitTabBarHitRegionRegistry.containsWindowPoint(windowPoint, in: window)
}
func paneDropTargetUnderPoint(_ windowPoint: NSPoint) -> (any FileDropPaneTarget)? {
if let paneTarget = inlinePaneDropTargetUnderPoint(windowPoint) {
return paneTarget
}
guard let window else { return nil }
if let terminalPaneTarget = TerminalWindowPortalRegistry.terminalPaneDropTargetAtWindowPoint(windowPoint, in: window) {
return terminalPaneTarget
}
return BrowserWindowPortalRegistry.browserPaneDropTargetAtWindowPoint(windowPoint, in: window)
}
func paneDropTargetForTextDrop(at windowPoint: NSPoint) -> (any FileDropPaneTarget)? {
if let textView = editableTextViewUnderPoint(windowPoint),
!(textView is SavingTextView) {
return nil
}
return paneDropTargetUnderPoint(windowPoint)
}
private func inlinePaneDropTargetUnderPoint(_ windowPoint: NSPoint) -> PaneDropTargetView? {
guard let window, let contentView = window.contentView else { return nil }
isHidden = true
defer { isHidden = false }
let point = contentView.convert(windowPoint, from: nil)
return paneDropTarget(in: contentView, at: point)
}
private func paneDropTarget(in view: NSView, at point: NSPoint) -> PaneDropTargetView? {
for subview in view.subviews.reversed() {
guard !subview.isHidden, subview.alphaValue > 0 else { continue }
let pointInSubview = subview.convert(point, from: view)
guard subview.bounds.contains(pointInSubview) else { continue }
if let paneTarget = subview as? PaneDropTargetView {
return paneTarget
}
if let nestedTarget = paneDropTarget(in: subview, at: pointInSubview) {
return nestedTarget
}
}
return view as? PaneDropTargetView
}
}
+351 -90
View File
@@ -1,6 +1,7 @@
import Darwin
import Foundation
struct FileSearchResult: Equatable {
struct FileSearchResult: Equatable, Sendable {
let path: String
let relativePath: String
let lineNumber: Int
@@ -27,7 +28,7 @@ enum FileSearchRipgrepParser {
let columnNumber = (firstStart ?? 0) + 1
return FileSearchResult(
path: path,
relativePath: relativePath(for: path, rootPath: rootPath),
relativePath: FileExplorerTerminalPathInsertion.relativePath(for: path, rootPath: rootPath),
lineNumber: lineNumber,
columnNumber: columnNumber,
preview: lineText.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -44,22 +45,10 @@ enum FileSearchRipgrepParser {
}
return String(decoding: data, as: UTF8.self)
}
private static func relativePath(for path: String, rootPath: String) -> String {
guard !rootPath.isEmpty else { return path }
let standardizedPath = URL(fileURLWithPath: path).standardizedFileURL.path
let standardizedRoot = URL(fileURLWithPath: rootPath).standardizedFileURL.path
guard standardizedPath.hasPrefix(standardizedRoot) else { return path }
var relative = String(standardizedPath.dropFirst(standardizedRoot.count))
if relative.hasPrefix("/") {
relative.removeFirst()
}
return relative.isEmpty ? (path as NSString).lastPathComponent : relative
}
}
struct FileSearchSnapshot: Equatable {
enum Status: Equatable {
struct FileSearchSnapshot: Equatable, Sendable {
enum Status: Equatable, Sendable {
case idle
case unsupported
case searching
@@ -78,7 +67,244 @@ struct FileSearchSnapshot: Equatable {
}
@MainActor
final class FileSearchController {
protocol FileSearchControlling: AnyObject {
var onSnapshotChanged: ((FileSearchSnapshot) -> Void)? { get set }
func search(query rawQuery: String, rootPath: String, isLocal: Bool, contentRevision: Int)
func cancel(clear: Bool)
}
struct FileSearchPipelineUpdate: Sendable {
let results: [FileSearchResult]
let status: FileSearchSnapshot.Status
let isSearching: Bool
let shouldStopProcess: Bool
}
private actor FileSearchTerminationSignal {
private var status: Int32?
private var continuations: [UUID: CheckedContinuation<Int32?, Never>] = [:]
private var cancelledWaits = Set<UUID>()
func complete(status: Int32) {
guard self.status == nil else { return }
self.status = status
let pendingContinuations = Array(continuations.values)
continuations.removeAll()
cancelledWaits.removeAll()
for continuation in pendingContinuations {
continuation.resume(returning: status)
}
}
func wait() async -> Int32? {
if let status {
return status
}
let waitID = UUID()
return await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
if let status {
continuation.resume(returning: status)
} else if cancelledWaits.remove(waitID) != nil {
continuation.resume(returning: nil)
} else {
continuations[waitID] = continuation
}
}
} onCancel: {
Task {
await self.cancelWait(id: waitID)
}
}
}
private func cancelWait(id: UUID) {
guard status == nil else { return }
if let continuation = continuations.removeValue(forKey: id) {
continuation.resume(returning: nil)
} else {
cancelledWaits.insert(id)
}
}
}
actor FileSearchOutputPipeline {
private let rootPath: String
private let maxResults: Int
private let snapshotInterval: TimeInterval
private var stdoutBuffer = Data()
private var stderrBuffer = Data()
private var results: [FileSearchResult] = []
private var lastSnapshotEmissionDate = Date.distantPast
private var isFinished = false
private var terminalUpdate: FileSearchPipelineUpdate?
init(rootPath: String, maxResults: Int, snapshotInterval: TimeInterval) {
self.rootPath = rootPath
self.maxResults = maxResults
self.snapshotInterval = snapshotInterval
}
func consumeStdout(_ data: Data) -> FileSearchPipelineUpdate? {
guard !isFinished else { return nil }
stdoutBuffer.append(data)
return consumeBufferedStdout(includeTrailingLine: false)
}
private func consumeBufferedStdout(includeTrailingLine: Bool) -> FileSearchPipelineUpdate? {
var latestUpdate: FileSearchPipelineUpdate?
while let newlineIndex = stdoutBuffer.firstIndex(of: 10) {
let lineData = stdoutBuffer[..<newlineIndex]
stdoutBuffer.removeSubrange(...newlineIndex)
guard let update = consumeStdoutLine(lineData) else { continue }
latestUpdate = update
if update.shouldStopProcess {
return update
}
}
if includeTrailingLine, !stdoutBuffer.isEmpty {
let lineData = stdoutBuffer
stdoutBuffer.removeAll(keepingCapacity: true)
if let update = consumeStdoutLine(lineData) {
latestUpdate = update
}
}
return latestUpdate
}
private func consumeStdoutLine(_ lineData: Data) -> FileSearchPipelineUpdate? {
guard let line = String(data: lineData, encoding: .utf8),
let result = FileSearchRipgrepParser.parseMatchLine(line, rootPath: rootPath) else {
return nil
}
results.append(result)
if results.count >= maxResults {
let update = FileSearchPipelineUpdate(
results: results,
status: .limited(maxResults),
isSearching: false,
shouldStopProcess: true
)
isFinished = true
terminalUpdate = update
return update
}
let now = Date()
guard now.timeIntervalSince(lastSnapshotEmissionDate) >= snapshotInterval else {
return nil
}
lastSnapshotEmissionDate = now
return FileSearchPipelineUpdate(
results: results,
status: .searching,
isSearching: true,
shouldStopProcess: false
)
}
func consumeStderr(_ data: Data) {
guard !isFinished else { return }
stderrBuffer.append(data)
if stderrBuffer.count > 8_192 {
stderrBuffer.removeSubrange(0..<(stderrBuffer.count - 8_192))
}
}
func consumeStderrLine(_ line: String) {
guard !isFinished else { return }
let lineData = Data((line + "\n").utf8)
consumeStderr(lineData)
}
func finish(status: Int32) -> FileSearchPipelineUpdate {
if let terminalUpdate {
return terminalUpdate
}
let trailingUpdate: FileSearchPipelineUpdate?
if !isFinished {
trailingUpdate = consumeBufferedStdout(includeTrailingLine: true)
} else {
trailingUpdate = nil
}
if let trailingUpdate, trailingUpdate.shouldStopProcess {
return trailingUpdate
}
isFinished = true
if status == 0 || status == 1 {
return FileSearchPipelineUpdate(
results: results,
status: results.isEmpty ? .noMatches : .matches,
isSearching: false,
shouldStopProcess: false
)
}
let errorText = String(data: stderrBuffer, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let fallback = String(
format: String(localized: "fileExplorer.search.rgExited", defaultValue: "rg exited with status %d"),
Int(status)
)
return FileSearchPipelineUpdate(
results: results,
status: .failed(errorText?.isEmpty == false ? errorText! : fallback),
isSearching: false,
shouldStopProcess: false
)
}
}
private final class FileSearchReadHandle: @unchecked Sendable {
private let fileHandle: FileHandle
init(_ fileHandle: FileHandle) {
self.fileHandle = fileHandle
}
var fileDescriptor: Int32 {
fileHandle.fileDescriptor
}
}
private enum FileSearchPipeReadResult: Sendable {
case chunk(Data)
case endOfFile
case failure(Int32)
}
private enum FileSearchPipeReader {
private static let queue = DispatchQueue(
label: "com.cmux.file-search.pipe-read",
qos: .userInitiated,
attributes: .concurrent
)
static func read(from readHandle: FileSearchReadHandle, maxByteCount: Int) async -> FileSearchPipeReadResult {
await withCheckedContinuation { continuation in
queue.async {
var buffer = [UInt8](repeating: 0, count: maxByteCount)
let bytesRead = buffer.withUnsafeMutableBytes { rawBuffer in
// Keep blocking pipe reads off Swift's cooperative executor.
Darwin.read(readHandle.fileDescriptor, rawBuffer.baseAddress, rawBuffer.count)
}
if bytesRead > 0 {
continuation.resume(returning: .chunk(Data(buffer.prefix(bytesRead))))
} else if bytesRead == 0 {
continuation.resume(returning: .endOfFile)
} else {
continuation.resume(returning: .failure(errno))
}
}
}
}
}
@MainActor
final class FileSearchController: FileSearchControlling {
private struct Request: Equatable {
let query: String
let rootPath: String
@@ -94,6 +320,7 @@ final class FileSearchController {
var onSnapshotChanged: ((FileSearchSnapshot) -> Void)?
private let maxResults = 500
private let snapshotInterval: TimeInterval = 0.05
private let excludedSearchGlobs = [
"!.git/**",
"!**/.git/**",
@@ -107,11 +334,11 @@ final class FileSearchController {
"!**/DerivedData/**",
]
private var process: Process?
private var stdoutBuffer = Data()
private var stderrBuffer = Data()
private var generation = 0
private var request: Request?
private var results: [FileSearchResult] = []
private var pipeline: FileSearchOutputPipeline?
private var searchTask: Task<Void, Never>?
func search(query rawQuery: String, rootPath: String, isLocal: Bool, contentRevision: Int = 0) {
let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -128,8 +355,6 @@ final class FileSearchController {
stopAndAdvanceGeneration()
results.removeAll()
stdoutBuffer.removeAll(keepingCapacity: true)
stderrBuffer.removeAll(keepingCapacity: true)
guard !query.isEmpty else {
emit(status: .idle, isSearching: false)
@@ -177,34 +402,60 @@ final class FileSearchController {
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr
let pipeline = FileSearchOutputPipeline(
rootPath: rootPath,
maxResults: maxResults,
snapshotInterval: snapshotInterval
)
self.pipeline = pipeline
let terminationSignal = FileSearchTerminationSignal()
stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty else { return }
Task { @MainActor [weak self] in
self?.consumeStdout(data, generation: searchGeneration, rootPath: rootPath)
}
}
stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty else { return }
Task { @MainActor [weak self] in
self?.consumeStderr(data, generation: searchGeneration)
}
}
process.terminationHandler = { [weak self] process in
Task { @MainActor [weak self] in
self?.finish(generation: searchGeneration, status: process.terminationStatus)
process.terminationHandler = { process in
let status = process.terminationStatus
Task {
await terminationSignal.complete(status: status)
}
}
do {
try process.run()
self.process = process
let stdoutReadHandle = FileSearchReadHandle(stdout.fileHandleForReading)
let stderrReadHandle = FileSearchReadHandle(stderr.fileHandleForReading)
searchTask = Task.detached(priority: .userInitiated) { [weak self, pipeline, terminationSignal, stdoutReadHandle, stderrReadHandle] in
// Result completeness is defined by stdout. Stderr stays diagnostic-only:
// successful searches do not wait on it, failed searches do before formatting the error.
let stderrTask = Task.detached(priority: .utility) { [stderrReadHandle, pipeline] in
await Self.streamStderr(from: stderrReadHandle, pipeline: pipeline)
}
let applyUpdate: @Sendable (FileSearchPipelineUpdate, Int) async -> Void = { [weak self] update, generation in
await self?.applyPipelineUpdate(update, generation: generation)
}
let stdoutTask = Task.detached(priority: .userInitiated) { [stdoutReadHandle, pipeline, searchGeneration, applyUpdate] in
await Self.streamStdout(
from: stdoutReadHandle,
pipeline: pipeline,
generation: searchGeneration,
applyUpdate: applyUpdate
)
}
defer {
stderrTask.cancel()
stdoutTask.cancel()
}
guard let status = await terminationSignal.wait() else { return }
await stdoutTask.value
guard !Task.isCancelled else { return }
if status != 0 && status != 1 {
await stderrTask.value
}
let update = await pipeline.finish(status: status)
await self?.finish(generation: searchGeneration, update: update)
}
} catch {
process.standardOutput = nil
process.standardError = nil
self.pipeline = nil
emit(status: .failed(error.localizedDescription), isSearching: false)
}
}
@@ -212,64 +463,28 @@ final class FileSearchController {
func cancel(clear: Bool) {
request = nil
stopAndAdvanceGeneration()
stdoutBuffer.removeAll(keepingCapacity: true)
stderrBuffer.removeAll(keepingCapacity: true)
if clear {
results.removeAll()
emit(status: .idle, isSearching: false)
}
}
private func consumeStdout(_ data: Data, generation searchGeneration: Int, rootPath: String) {
private func applyPipelineUpdate(_ update: FileSearchPipelineUpdate, generation searchGeneration: Int) {
guard searchGeneration == generation else { return }
stdoutBuffer.append(data)
var didAppendResult = false
while let newlineIndex = stdoutBuffer.firstIndex(of: 10) {
let lineData = stdoutBuffer[..<newlineIndex]
stdoutBuffer.removeSubrange(...newlineIndex)
guard let line = String(data: lineData, encoding: .utf8),
let result = FileSearchRipgrepParser.parseMatchLine(line, rootPath: rootPath) else {
continue
}
results.append(result)
didAppendResult = true
if results.count >= maxResults {
stopAndAdvanceGeneration()
emit(status: .limited(maxResults), isSearching: false)
return
}
}
if didAppendResult {
emit(status: .searching, isSearching: true)
results = update.results
if update.shouldStopProcess {
stopAndAdvanceGeneration()
}
emit(status: update.status, isSearching: update.isSearching)
}
private func consumeStderr(_ data: Data, generation searchGeneration: Int) {
private func finish(generation searchGeneration: Int, update: FileSearchPipelineUpdate) {
guard searchGeneration == generation else { return }
stderrBuffer.append(data)
if stderrBuffer.count > 8_192 {
stderrBuffer.removeSubrange(0..<(stderrBuffer.count - 8_192))
}
}
private func finish(generation searchGeneration: Int, status: Int32) {
guard searchGeneration == generation else { return }
stopCurrentProcess()
if status == 0 || status == 1 {
let finalStatus: FileSearchSnapshot.Status = results.isEmpty ? .noMatches : .matches
emit(status: finalStatus, isSearching: false)
return
}
let errorText = String(data: stderrBuffer, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let fallback = String(
format: String(localized: "fileExplorer.search.rgExited", defaultValue: "rg exited with status %d"),
Int(status)
)
emit(status: .failed(errorText?.isEmpty == false ? errorText! : fallback), isSearching: false)
process = nil
pipeline = nil
searchTask = nil
results = update.results
emit(status: update.status, isSearching: update.isSearching)
}
private func emit(status: FileSearchSnapshot.Status, isSearching: Bool) {
@@ -289,11 +504,57 @@ final class FileSearchController {
private func stopCurrentProcess() {
guard let process else { return }
self.process = nil
(process.standardOutput as? Pipe)?.fileHandleForReading.readabilityHandler = nil
(process.standardError as? Pipe)?.fileHandleForReading.readabilityHandler = nil
process.terminationHandler = nil
searchTask?.cancel()
searchTask = nil
pipeline = nil
if process.isRunning {
process.terminate()
_ = Darwin.kill(process.processIdentifier, SIGTERM)
}
}
private nonisolated static func streamStdout(
from readHandle: FileSearchReadHandle,
pipeline: FileSearchOutputPipeline,
generation: Int,
applyUpdate: @Sendable (FileSearchPipelineUpdate, Int) async -> Void
) async {
while !Task.isCancelled {
let readResult = await FileSearchPipeReader.read(from: readHandle, maxByteCount: 32 * 1024)
guard !Task.isCancelled else { return }
switch readResult {
case .chunk(let data):
guard let update = await pipeline.consumeStdout(data) else { continue }
await applyUpdate(update, generation)
if update.shouldStopProcess { return }
case .endOfFile:
return
case .failure(let errorNumber) where errorNumber == EINTR:
continue
case .failure(let errorNumber):
await pipeline.consumeStderrLine(String(cString: strerror(errorNumber)))
return
}
}
}
private nonisolated static func streamStderr(
from readHandle: FileSearchReadHandle,
pipeline: FileSearchOutputPipeline
) async {
while !Task.isCancelled {
let readResult = await FileSearchPipeReader.read(from: readHandle, maxByteCount: 8 * 1024)
guard !Task.isCancelled else { return }
switch readResult {
case .chunk(let data):
await pipeline.consumeStderr(data)
case .endOfFile:
return
case .failure(let errorNumber) where errorNumber == EINTR:
continue
case .failure(let errorNumber):
await pipeline.consumeStderrLine(String(cString: strerror(errorNumber)))
return
}
}
}
+436 -71
View File
@@ -203,7 +203,7 @@ enum FileExplorerStyle: Int, CaseIterable {
// MARK: - Models
struct FileExplorerEntry {
struct FileExplorerEntry: Sendable {
let name: String
let path: String
let isDirectory: Bool
@@ -261,6 +261,34 @@ protocol FileExplorerProvider: AnyObject {
var isAvailable: Bool { get }
}
struct SSHFileExplorerConnection: Equatable, Sendable {
let destination: String
let port: Int?
let identityFile: String?
let sshOptions: [String]
}
protocol SSHFileExplorerTransport: AnyObject {
nonisolated func resolveHomePath(connection: SSHFileExplorerConnection) async throws -> String
nonisolated func listDirectory(
path: String,
connection: SSHFileExplorerConnection,
showHidden: Bool
) async throws -> [FileExplorerEntry]
}
enum FileExplorerWorkspaceRoot: Equatable {
case none
case local(path: String)
case remoteSSH(
workspaceId: UUID,
connection: SSHFileExplorerConnection,
displayTarget: String,
isAvailable: Bool,
unavailableDetail: String?
)
}
// MARK: - Local Provider
final class LocalFileExplorerProvider: FileExplorerProvider {
@@ -282,107 +310,229 @@ final class LocalFileExplorerProvider: FileExplorerProvider {
// MARK: - SSH Provider
final class SSHFileExplorerProvider: FileExplorerProvider {
let destination: String
let port: Int?
let identityFile: String?
let sshOptions: [String]
private(set) var homePath: String
private(set) var isAvailable: Bool
// Captured by async SSH tasks; mutable availability/root state is guarded by stateLock.
final class SSHFileExplorerProvider: FileExplorerProvider, @unchecked Sendable {
private struct State: Sendable {
var homePath: String
var isAvailable: Bool
}
let connection: SSHFileExplorerConnection
let displayTarget: String
private let transport: SSHFileExplorerTransport
private let stateLock = NSLock()
private var state: State
var homePath: String {
stateLock.lock()
defer { stateLock.unlock() }
return state.homePath
}
var isAvailable: Bool {
stateLock.lock()
defer { stateLock.unlock() }
return state.isAvailable
}
var destination: String { connection.destination }
var port: Int? { connection.port }
var identityFile: String? { connection.identityFile }
var sshOptions: [String] { connection.sshOptions }
init(
destination: String,
port: Int?,
identityFile: String?,
sshOptions: [String],
displayTarget: String? = nil,
homePath: String,
isAvailable: Bool
isAvailable: Bool,
transport: SSHFileExplorerTransport = ProcessSSHFileExplorerTransport.shared
) {
self.destination = destination
self.port = port
self.identityFile = identityFile
self.sshOptions = sshOptions
self.homePath = homePath
self.isAvailable = isAvailable
self.connection = SSHFileExplorerConnection(
destination: destination,
port: port,
identityFile: identityFile,
sshOptions: sshOptions
)
self.displayTarget = displayTarget ?? {
guard let port else { return destination }
return "\(destination):\(port)"
}()
self.transport = transport
self.state = State(homePath: homePath, isAvailable: isAvailable)
}
init(
connection: SSHFileExplorerConnection,
displayTarget: String,
homePath: String,
isAvailable: Bool,
transport: SSHFileExplorerTransport = ProcessSSHFileExplorerTransport.shared
) {
self.connection = connection
self.displayTarget = displayTarget
self.transport = transport
self.state = State(homePath: homePath, isAvailable: isAvailable)
}
func updateAvailability(_ available: Bool, homePath: String?) {
self.isAvailable = available
stateLock.lock()
defer { stateLock.unlock() }
state.isAvailable = available
if let homePath {
self.homePath = homePath
state.homePath = homePath
}
}
func resolveHomePath() async throws -> String {
guard isAvailable else {
throw FileExplorerError.providerUnavailable
}
let home = try await transport.resolveHomePath(connection: connection)
guard !home.isEmpty else {
throw FileExplorerError.sshCommandFailed("remote HOME was empty")
}
return home
}
func listDirectory(path: String, showHidden: Bool) async throws -> [FileExplorerEntry] {
guard isAvailable else {
throw FileExplorerError.providerUnavailable
}
// Capture immutable config values for Sendable closure
let dest = destination
let p = port
let identity = identityFile
let opts = sshOptions
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
let result = try SSHFileExplorerProvider.runSSHListCommand(
path: path, destination: dest, port: p,
identityFile: identity, sshOptions: opts,
showHidden: showHidden
)
continuation.resume(returning: result)
} catch {
continuation.resume(throwing: error)
}
return try await transport.listDirectory(path: path, connection: connection, showHidden: showHidden)
}
}
final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport {
static let shared = ProcessSSHFileExplorerTransport()
nonisolated func resolveHomePath(connection: SSHFileExplorerConnection) async throws -> String {
let output = try await Self.runSSHCommand(
connection: connection,
command: #"printf '%s\n' "$HOME""#
)
return output.trimmingCharacters(in: .whitespacesAndNewlines)
}
nonisolated func listDirectory(
path: String,
connection: SSHFileExplorerConnection,
showHidden: Bool
) async throws -> [FileExplorerEntry] {
try await Self.runSSHListCommand(path: path, connection: connection, showHidden: showHidden)
}
private struct SSHCommandResult: Sendable {
let stdout: String
let stderr: String
let terminationStatus: Int32
}
// Keeps the child process reachable from the cancellation handler while
// the blocking wait runs off Swift's cooperative executor.
private final class SSHCommandProcess: @unchecked Sendable {
private let process = Process()
private let outPipe = Pipe()
private let errPipe = Pipe()
private let lock = NSLock()
private var cancelled = false
init(connection: SSHFileExplorerConnection, command: String) {
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
process.arguments = ProcessSSHFileExplorerTransport.sshArguments(connection: connection, command: command)
process.standardOutput = outPipe
process.standardError = errPipe
}
func run() throws -> SSHCommandResult {
lock.lock()
let wasCancelled = cancelled
lock.unlock()
if wasCancelled {
throw CancellationError()
}
try process.run()
lock.lock()
let shouldTerminate = cancelled && process.isRunning
lock.unlock()
if shouldTerminate {
process.terminate()
}
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
let stderrData = errPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return SSHCommandResult(
stdout: String(data: data, encoding: .utf8) ?? "",
stderr: String(data: stderrData, encoding: .utf8) ?? "",
terminationStatus: process.terminationStatus
)
}
func terminate() {
lock.lock()
cancelled = true
let isRunning = process.isRunning
lock.unlock()
if isRunning {
process.terminate()
}
}
}
private static func runSSHListCommand(
path: String, destination: String, port: Int?,
identityFile: String?, sshOptions: [String],
showHidden: Bool
) throws -> [FileExplorerEntry] {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
private static func runSSHCommand(connection: SSHFileExplorerConnection, command: String) async throws -> String {
let commandProcess = SSHCommandProcess(connection: connection, command: command)
let result = try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(with: Result { try commandProcess.run() })
}
}
} onCancel: {
commandProcess.terminate()
}
guard result.terminationStatus == 0 else {
throw FileExplorerError.sshCommandFailed(result.stderr)
}
return result.stdout
}
private static func sshArguments(connection: SSHFileExplorerConnection, command: String) -> [String] {
var args: [String] = []
if let port {
if let port = connection.port {
args += ["-p", String(port)]
}
if let identityFile {
if let identityFile = connection.identityFile {
args += ["-i", identityFile]
}
for option in sshOptions {
for option in connection.sshOptions {
args += ["-o", option]
}
// Batch mode, no TTY, connection timeout
args += ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-T"]
args += [connection.destination, command]
return args
}
private static func runSSHListCommand(
path: String,
connection: SSHFileExplorerConnection,
showHidden: Bool
) async throws -> [FileExplorerEntry] {
// Escape single quotes in path for shell safety
let escapedPath = path.replacingOccurrences(of: "'", with: "'\\''")
let lsFlags = showHidden ? "-1paFA" : "-1paF"
args += [destination, "ls \(lsFlags) '\(escapedPath)' 2>/dev/null"]
process.arguments = args
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
try process.run()
// Read pipe data before waitUntilExit to avoid deadlock when pipe buffer fills
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
let stderrData = errPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
let stderrStr = String(data: stderrData, encoding: .utf8) ?? ""
throw FileExplorerError.sshCommandFailed(stderrStr)
}
guard let output = String(data: data, encoding: .utf8) else {
return []
}
let output = try await runSSHCommand(
connection: connection,
command: "ls \(lsFlags) '\(escapedPath)' 2>/dev/null"
)
let normalizedPath = path.hasSuffix("/") ? path : path + "/"
return output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { line in
@@ -419,6 +569,17 @@ enum FileExplorerError: LocalizedError {
}
}
// MARK: - Selection Restoration
enum FileExplorerSelectionRestoration {
static func scrollRow(anchorRow: Int?, exactRows: IndexSet) -> Int? {
if let anchorRow, exactRows.contains(anchorRow) {
return anchorRow
}
return exactRows.first
}
}
// MARK: - Store
/// All access must happen on the main thread. Properties are not marked @MainActor
@@ -430,6 +591,7 @@ final class FileExplorerStore: ObservableObject {
@Published private(set) var isRootLoading: Bool = false
@Published private(set) var gitStatusByPath: [String: GitFileStatus] = [:]
@Published private(set) var contentRevision = 0
@Published private(set) var rootStatusMessage: String?
var provider: FileExplorerProvider?
@@ -445,6 +607,9 @@ final class FileExplorerStore: ObservableObject {
/// Stable navigation selection. The outline view mirrors this path after reloads.
private(set) var selectedPath: String?
/// Stable multi-selection. `selectedPath` remains the keyboard/navigation anchor.
private(set) var selectedPaths: Set<String> = []
/// Folder path whose first child should be selected once its async load completes.
private var pendingDescendIntoFirstChildPath: String?
@@ -460,12 +625,55 @@ final class FileExplorerStore: ObservableObject {
/// Prefetch debounce: path -> work item
private var prefetchWorkItems: [String: DispatchWorkItem] = [:]
private var remoteHomeResolutionTask: Task<Void, Never>?
private var remoteHomeResolutionKey: String?
var displayRootPath: String {
FileExplorerRootResolver.displayPath(for: rootPath, homePath: provider?.homePath)
if let sshProvider = provider as? SSHFileExplorerProvider {
guard !rootPath.isEmpty else {
return "ssh://\(sshProvider.displayTarget)"
}
return "ssh://\(sshProvider.displayTarget):\(rootPath)"
}
return FileExplorerRootResolver.displayPath(for: rootPath, homePath: provider?.homePath)
}
// MARK: - Public API
func applyWorkspaceRoot(
_ request: FileExplorerWorkspaceRoot,
sshTransport: SSHFileExplorerTransport = ProcessSSHFileExplorerTransport.shared
) {
switch request {
case .none:
cancelRemoteHomeResolution()
setRootStatusMessage(nil)
if provider != nil {
setProvider(nil, reloadIfAvailable: false)
}
setRootPath("")
case .local(let path):
cancelRemoteHomeResolution()
setRootStatusMessage(nil)
if !(provider is LocalFileExplorerProvider) {
setRootPath("")
setProvider(LocalFileExplorerProvider(), reloadIfAvailable: false)
}
setRootPath(path)
case .remoteSSH(let workspaceId, let connection, let displayTarget, let isAvailable, let unavailableDetail):
applyRemoteSSHWorkspaceRoot(
workspaceId: workspaceId,
connection: connection,
displayTarget: displayTarget,
isAvailable: isAvailable,
unavailableDetail: unavailableDetail,
sshTransport: sshTransport
)
}
}
func setRootPath(_ path: String) {
guard path != rootPath else {
#if DEBUG
@@ -478,6 +686,7 @@ final class FileExplorerStore: ObservableObject {
#endif
if let selectedPath, !Self.path(selectedPath, isContainedIn: path) {
self.selectedPath = nil
selectedPaths = []
pendingDescendIntoFirstChildPath = nil
}
rootPath = path
@@ -530,17 +739,23 @@ final class FileExplorerStore: ObservableObject {
}
}
func setProvider(_ newProvider: FileExplorerProvider?) {
private func setProvider(_ newProvider: FileExplorerProvider?, reloadIfAvailable: Bool = true) {
#if DEBUG
NSLog("[FileExplorer] setProvider: \(type(of: newProvider).self) available=\(newProvider?.isAvailable ?? false)")
#endif
provider = newProvider
// Re-expand previously expanded nodes if provider becomes available
if newProvider?.isAvailable == true {
if reloadIfAvailable, newProvider?.isAvailable == true {
reload()
}
}
#if DEBUG
func setProviderForTesting(_ newProvider: FileExplorerProvider?, reloadIfAvailable: Bool = true) {
setProvider(newProvider, reloadIfAvailable: reloadIfAvailable)
}
#endif
func reload() {
#if DEBUG
NSLog("[FileExplorer] reload() path=\(rootPath) provider=\(type(of: provider).self)")
@@ -589,8 +804,21 @@ final class FileExplorerStore: ObservableObject {
func select(node: FileExplorerNode?) {
let path = node?.path
guard selectedPath != path else { return }
let paths = path.map { Set([$0]) } ?? []
guard selectedPath != path || selectedPaths != paths else { return }
selectedPath = path
selectedPaths = paths
if path != pendingDescendIntoFirstChildPath {
pendingDescendIntoFirstChildPath = nil
}
}
func select(nodes: [FileExplorerNode], anchor: FileExplorerNode?) {
let paths = Set(nodes.map(\.path))
let path = anchor?.path ?? nodes.first?.path
guard selectedPath != path || selectedPaths != paths else { return }
selectedPath = path
selectedPaths = paths
if path != pendingDescendIntoFirstChildPath {
pendingDescendIntoFirstChildPath = nil
}
@@ -599,6 +827,7 @@ final class FileExplorerStore: ObservableObject {
func requestDescendIntoFirstChild(of node: FileExplorerNode) {
guard node.isDirectory else { return }
selectedPath = node.path
selectedPaths = [node.path]
pendingDescendIntoFirstChildPath = node.path
expand(node: node)
}
@@ -648,6 +877,7 @@ final class FileExplorerStore: ObservableObject {
do {
let entries = try await provider.listDirectory(path: path, showHidden: showHiddenFiles)
try Task.checkCancellation()
let children = entries.map { entry in
let node = FileExplorerNode(name: entry.name, path: entry.path, isDirectory: entry.isDirectory)
nodesByPath[entry.path] = node
@@ -662,14 +892,18 @@ final class FileExplorerStore: ObservableObject {
parentNode.isLoading = false
parentNode.error = nil
if pendingDescendIntoFirstChildPath == parentNode.path {
selectedPath = children.first?.path ?? parentNode.path
let path = children.first?.path ?? parentNode.path
selectedPath = path
selectedPaths = [path]
pendingDescendIntoFirstChildPath = nil
}
} else {
rootNodes = children
isRootLoading = false
setRootStatusMessage(nil)
if selectedPath == nil {
selectedPath = children.first?.path
selectedPaths = selectedPath.map { Set([$0]) } ?? []
}
}
loadingPaths.remove(path)
@@ -694,6 +928,7 @@ final class FileExplorerStore: ObservableObject {
parentNode.error = error.localizedDescription
} else {
isRootLoading = false
setRootStatusMessage(error.localizedDescription)
}
loadingPaths.remove(path)
loadTasks.removeValue(forKey: path)
@@ -716,6 +951,132 @@ final class FileExplorerStore: ObservableObject {
isRootLoading = false
}
private func applyRemoteSSHWorkspaceRoot(
workspaceId: UUID,
connection: SSHFileExplorerConnection,
displayTarget: String,
isAvailable: Bool,
unavailableDetail: String?,
sshTransport: SSHFileExplorerTransport
) {
let existingProvider = provider as? SSHFileExplorerProvider
let sshProvider: SSHFileExplorerProvider
if let existingProvider,
existingProvider.connection == connection,
existingProvider.displayTarget == displayTarget {
sshProvider = existingProvider
sshProvider.updateAvailability(isAvailable, homePath: nil)
} else {
cancelRemoteHomeResolution()
setRootPath("")
sshProvider = SSHFileExplorerProvider(
connection: connection,
displayTarget: displayTarget,
homePath: "",
isAvailable: isAvailable,
transport: sshTransport
)
setProvider(sshProvider, reloadIfAvailable: false)
}
guard isAvailable else {
cancelRemoteHomeResolution()
setRootPath("")
let detail = unavailableDetail?.trimmingCharacters(in: .whitespacesAndNewlines)
if let detail, !detail.isEmpty {
setRootStatusMessage(
String(
localized: "fileExplorer.status.sshUnavailableWithDetail",
defaultValue: "SSH files unavailable: \(detail)"
)
)
} else {
setRootStatusMessage(
String(localized: "fileExplorer.status.sshUnavailable", defaultValue: "SSH files unavailable")
)
}
return
}
let currentHomePath = sshProvider.homePath
if !currentHomePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
setRootStatusMessage(nil)
setRootPath(currentHomePath)
return
}
resolveRemoteHome(
workspaceId: workspaceId,
provider: sshProvider,
connection: connection
)
}
private func resolveRemoteHome(
workspaceId: UUID,
provider sshProvider: SSHFileExplorerProvider,
connection: SSHFileExplorerConnection
) {
let resolutionKey = [
workspaceId.uuidString,
connection.destination,
connection.port.map(String.init) ?? "",
connection.identityFile ?? "",
connection.sshOptions.joined(separator: "\u{1f}"),
].joined(separator: "\u{1e}")
guard remoteHomeResolutionKey != resolutionKey else { return }
remoteHomeResolutionTask?.cancel()
remoteHomeResolutionKey = resolutionKey
setRootPath("")
setRootStatusMessage(String(localized: "fileExplorer.status.sshResolvingHome", defaultValue: "Resolving remote home..."))
remoteHomeResolutionTask = Task { [weak self, weak sshProvider] in
guard let sshProvider else { return }
do {
let homePath = try await sshProvider.resolveHomePath()
await MainActor.run { [weak self, weak sshProvider] in
guard let self,
let sshProvider,
self.remoteHomeResolutionKey == resolutionKey,
self.provider === sshProvider else { return }
self.remoteHomeResolutionKey = nil
self.remoteHomeResolutionTask = nil
sshProvider.updateAvailability(true, homePath: homePath)
self.setRootStatusMessage(nil)
self.setRootPath(homePath)
}
} catch {
await MainActor.run { [weak self, weak sshProvider] in
guard let self,
let sshProvider,
self.remoteHomeResolutionKey == resolutionKey,
self.provider === sshProvider else { return }
self.remoteHomeResolutionKey = nil
self.remoteHomeResolutionTask = nil
self.setRootPath("")
self.setRootStatusMessage(
String(
localized: "fileExplorer.status.sshHomeFailed",
defaultValue: "Unable to resolve SSH home: \(error.localizedDescription)"
)
)
}
}
}
}
private func cancelRemoteHomeResolution() {
remoteHomeResolutionTask?.cancel()
remoteHomeResolutionTask = nil
remoteHomeResolutionKey = nil
}
private func setRootStatusMessage(_ message: String?) {
guard rootStatusMessage != message else { return }
rootStatusMessage = message
}
private static func path(_ candidate: String, isContainedIn root: String) -> Bool {
guard !root.isEmpty else { return false }
if root == "/" {
@@ -723,6 +1084,10 @@ final class FileExplorerStore: ObservableObject {
}
return candidate == root || candidate.hasPrefix(root + "/")
}
deinit {
cancelRemoteHomeResolution()
}
}
// MARK: - Directory Watcher
@@ -0,0 +1,184 @@
import AppKit
enum FileExplorerTerminalPathInsertion {
static func insertedText(forPaths paths: [String]) -> String {
TerminalImageTransferPlanner.insertedText(forPathStrings: paths)
}
static func insertedText(forPaths paths: [String], relativeToRootPath rootPath: String) -> String {
insertedText(forPaths: paths.map { relativePath(for: $0, rootPath: rootPath) })
}
static func relativePath(for path: String, rootPath: String) -> String {
let normalizedPath = normalizedFileSystemPath(path)
guard !rootPath.isEmpty else { return normalizedPath }
let normalizedRootPath = normalizedFileSystemPath(rootPath)
if normalizedPath == normalizedRootPath { return "." }
let normalizedRoot = normalizedRootPath == "/" ? "/" : normalizedRootPath + "/"
if normalizedPath.hasPrefix(normalizedRoot) {
return String(normalizedPath.dropFirst(normalizedRoot.count))
}
return normalizedPath
}
private static func normalizedFileSystemPath(_ path: String) -> String {
let path = pathWithoutTrailingSlashes(path)
guard path.hasPrefix("/") else { return path }
return macOSDisplayPath(
pathWithoutTrailingSlashes(URL(fileURLWithPath: path).standardizedFileURL.path)
)
}
private static func macOSDisplayPath(_ path: String) -> String {
let rewrites = [
(privatePath: "/private/tmp", displayPath: "/tmp"),
(privatePath: "/private/var", displayPath: "/var"),
(privatePath: "/private/etc", displayPath: "/etc"),
]
for rewrite in rewrites {
if path == rewrite.privatePath {
return rewrite.displayPath
}
if path.hasPrefix(rewrite.privatePath + "/") {
return rewrite.displayPath + String(path.dropFirst(rewrite.privatePath.count))
}
}
return path
}
private static func pathWithoutTrailingSlashes(_ path: String) -> String {
var result = path
while result.count > 1 && result.hasSuffix("/") {
result.removeLast()
}
return result
}
@MainActor
@discardableResult
static func insert(paths: [String], relativeToRootPath rootPath: String? = nil, intoTerminalFor window: NSWindow?) -> Bool {
let text: String
if let rootPath {
text = insertedText(forPaths: paths, relativeToRootPath: rootPath)
} else {
text = insertedText(forPaths: paths)
}
guard !text.isEmpty else { return false }
guard let terminalPanel = targetTerminalPanel(for: window) else { return false }
terminalPanel.sendText(text)
return true
}
@MainActor
private static func targetTerminalPanel(for window: NSWindow?) -> TerminalPanel? {
guard let appDelegate = AppDelegate.shared else { return nil }
if let window,
let terminalPanel = appDelegate.contextForMainTerminalWindow(window)?.tabManager.selectedWorkspace?.focusedTerminalPanel {
return terminalPanel
}
if let window,
let windowId = appDelegate.mainWindowId(from: window),
let terminalPanel = appDelegate.tabManagerFor(windowId: windowId)?.selectedWorkspace?.focusedTerminalPanel {
return terminalPanel
}
return appDelegate.tabManager?.selectedWorkspace?.focusedTerminalPanel
}
}
extension NSMenu {
func addFileExplorerInsertPathItems(
target: AnyObject,
representedObject: Any,
insertAction: Selector,
insertRelativeAction: Selector
) {
let insertPathItem = NSMenuItem(
title: String(localized: "fileExplorer.contextMenu.insertPath", defaultValue: "Insert Path"),
action: insertAction,
keyEquivalent: ""
)
insertPathItem.target = target
insertPathItem.representedObject = representedObject
addItem(insertPathItem)
let insertRelativePathItem = NSMenuItem(
title: String(localized: "fileExplorer.contextMenu.insertRelativePath", defaultValue: "Insert Relative Path"),
action: insertRelativeAction,
keyEquivalent: ""
)
insertRelativePathItem.target = target
insertRelativePathItem.representedObject = representedObject
addItem(insertRelativePathItem)
}
}
extension FileExplorerPanelView.Coordinator {
@MainActor
private func contextMenuNodes(clicked node: FileExplorerNode) -> [FileExplorerNode] {
guard let outlineView else { return [node] }
let clickedRow = outlineView.clickedRow
let selectedRows = outlineView.selectedRowIndexes
guard clickedRow >= 0, selectedRows.contains(clickedRow) else {
return [node]
}
let nodes = selectedRows.compactMap { row -> FileExplorerNode? in
guard row >= 0, row < outlineView.numberOfRows else { return nil }
return outlineView.item(atRow: row) as? FileExplorerNode
}
return nodes.isEmpty ? [node] : nodes
}
@MainActor
@objc func contextMenuInsertPath(_ sender: NSMenuItem) {
guard let node = sender.representedObject as? FileExplorerNode else { return }
FileExplorerTerminalPathInsertion.insert(
paths: contextMenuNodes(clicked: node).map(\.path),
intoTerminalFor: outlineView?.window ?? containerView?.window
)
}
@MainActor
@objc func contextMenuInsertRelativePath(_ sender: NSMenuItem) {
guard let node = sender.representedObject as? FileExplorerNode else { return }
FileExplorerTerminalPathInsertion.insert(
paths: contextMenuNodes(clicked: node).map(\.path),
relativeToRootPath: store.rootPath,
intoTerminalFor: outlineView?.window ?? containerView?.window
)
}
}
extension FileExplorerContainerView {
@MainActor
private func searchResultsForContextMenu(row: Int) -> [FileSearchResult] {
guard row >= 0, row < searchSnapshot.results.count else { return [] }
let selectedRows = searchResultsView.selectedRowIndexes
guard selectedRows.contains(row) else {
return [searchSnapshot.results[row]]
}
let results = selectedRows.compactMap { selectedRow -> FileSearchResult? in
guard selectedRow >= 0, selectedRow < searchSnapshot.results.count else { return nil }
return searchSnapshot.results[selectedRow]
}
return results.isEmpty ? [searchSnapshot.results[row]] : results
}
@MainActor
@objc func contextMenuInsertSearchResultPath(_ sender: NSMenuItem) {
guard let row = (sender.representedObject as? NSNumber)?.intValue else { return }
FileExplorerTerminalPathInsertion.insert(
paths: searchResultsForContextMenu(row: row).map(\.path),
intoTerminalFor: window
)
}
@MainActor
@objc func contextMenuInsertSearchResultRelativePath(_ sender: NSMenuItem) {
guard let row = (sender.representedObject as? NSNumber)?.intValue else { return }
FileExplorerTerminalPathInsertion.insert(
paths: searchResultsForContextMenu(row: row).map(\.relativePath),
intoTerminalFor: window
)
}
}
+348 -54
View File
@@ -100,15 +100,22 @@ struct FileExplorerPanelView: NSViewRepresentable {
observationCancellable = store.objectWillChange
.debounce(for: .milliseconds(50), scheduler: RunLoop.main)
.sink { [weak self] _ in
self?.reloadIfNeeded()
Task { @MainActor [weak self] in
self?.reloadIfNeeded()
}
}
}
@MainActor
func reloadIfNeeded() {
guard let outlineView else { return }
// Update empty state vs tree visibility
containerView?.updateVisibility(hasContent: !store.rootPath.isEmpty, isLoading: store.isRootLoading)
containerView?.updateVisibility(
hasContent: !store.rootPath.isEmpty,
isLoading: store.isRootLoading,
statusMessage: store.rootStatusMessage
)
let newCount = store.rootNodes.count
withProgrammaticOutlineUpdate {
@@ -225,15 +232,11 @@ struct FileExplorerPanelView: NSViewRepresentable {
let outlineView = notification.object as? NSOutlineView else {
return
}
guard outlineView.selectedRow >= 0,
outlineView.selectedRow < outlineView.numberOfRows,
let node = outlineView.item(atRow: outlineView.selectedRow) as? FileExplorerNode else {
store.select(node: nil)
return
}
store.select(node: node)
let nodes = outlineView.selectedRowIndexes.compactMap { outlineView.item(atRow: $0) as? FileExplorerNode }
guard !nodes.isEmpty else { store.select(node: nil); return }
let anchor = outlineView.selectedRow >= 0 ? outlineView.item(atRow: outlineView.selectedRow) as? FileExplorerNode : nil
store.select(nodes: nodes, anchor: anchor ?? nodes.first)
}
func outlineViewItemDidExpand(_ notification: Notification) {
guard let node = notification.userInfo?["NSObject"] as? FileExplorerNode else { return }
if !store.isExpanded(node) {
@@ -370,6 +373,12 @@ struct FileExplorerPanelView: NSViewRepresentable {
fallbackToFirstVisible: Bool,
scroll: Bool
) {
let exactRows = store.selectedPaths.reduce(into: IndexSet()) { if let resolution = selectionResolution(for: $1, in: outlineView), resolution.isExact { $0.insert(resolution.row) } }
if !exactRows.isEmpty {
withProgrammaticOutlineUpdate { outlineView.selectRowIndexes(exactRows, byExtendingSelection: false) }
let anchorRow = store.selectedPath.flatMap { selectionResolution(for: $0, in: outlineView)?.row }
if scroll, let row = FileExplorerSelectionRestoration.scrollRow(anchorRow: anchorRow, exactRows: exactRows) { outlineView.scrollRowToVisible(row) }; return
}
if let selectedPath = store.selectedPath,
let resolution = selectionResolution(for: selectedPath, in: outlineView) {
selectRow(
@@ -402,7 +411,6 @@ struct FileExplorerPanelView: NSViewRepresentable {
let row: Int
let isExact: Bool
}
private func selectionResolution(for path: String, in outlineView: NSOutlineView) -> SelectionResolution? {
var bestAncestor: (row: Int, pathLength: Int)?
for row in 0..<outlineView.numberOfRows {
@@ -525,6 +533,8 @@ struct FileExplorerPanelView: NSViewRepresentable {
menu.addItem(.separator())
}
menu.addFileExplorerInsertPathItems(target: self, representedObject: node, insertAction: #selector(contextMenuInsertPath(_:)), insertRelativeAction: #selector(contextMenuInsertRelativePath(_:)))
let copyPathItem = NSMenuItem(
title: String(localized: "fileExplorer.contextMenu.copyPath", defaultValue: "Copy Path"),
action: #selector(contextMenuCopyPath(_:)),
@@ -562,14 +572,7 @@ struct FileExplorerPanelView: NSViewRepresentable {
@objc private func contextMenuCopyRelativePath(_ sender: NSMenuItem) {
guard let node = sender.representedObject as? FileExplorerNode else { return }
let rootPath = store.rootPath
var relativePath = node.path
if relativePath.hasPrefix(rootPath) {
relativePath = String(relativePath.dropFirst(rootPath.count))
if relativePath.hasPrefix("/") {
relativePath = String(relativePath.dropFirst())
}
}
let relativePath = FileExplorerTerminalPathInsertion.relativePath(for: node.path, rootPath: store.rootPath)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(relativePath, forType: .string)
}
@@ -579,6 +582,7 @@ struct FileExplorerPanelView: NSViewRepresentable {
// MARK: - Container View (all-AppKit)
/// Pure AppKit container holding the header bar and outline view.
@MainActor
final class FileExplorerContainerView: NSView {
private let headerView: FileExplorerHeaderView
private let searchBarView: NSView
@@ -587,20 +591,45 @@ final class FileExplorerContainerView: NSView {
private let scrollView: NSScrollView
private let outlineView: FileExplorerNSOutlineView
private let searchScrollView: NSScrollView
private let searchResultsView: FileExplorerSearchResultsTableView
let searchResultsView: FileExplorerSearchResultsTableView
private let emptyLabel: NSTextField
private let loadingIndicator: NSProgressIndicator
private let searchController: FileSearchController
private let searchController: any FileSearchControlling
private var searchBarHeightConstraint: NSLayoutConstraint!
private var searchSnapshot = FileSearchSnapshot.empty
private(set) var searchSnapshot = FileSearchSnapshot.empty
private var currentRootPath = ""
private var currentProviderIsLocal = false
private var currentContentRevision = 0
private var isSearchVisible = false
private let searchDebounceSubject = PassthroughSubject<Int, Never>()
private var searchDebounceCancellable: AnyCancellable?
private var searchDebounceGeneration = 0
private var pendingSearchRefreshAfterSettled = false
private var isSearchVisible = false {
didSet {
if !isSearchVisible {
cancelPendingSearchRefresh()
pendingSearchRefreshAfterSettled = false
}
}
}
private var presentation: FileExplorerPanelPresentation
private let coordinator: FileExplorerPanelView.Coordinator
private let searchDebounceDelayMilliseconds = 200
private let searchBarVisibleHeight: CGFloat = 48
init(coordinator: FileExplorerPanelView.Coordinator, presentation: FileExplorerPanelPresentation) {
#if DEBUG
private var debugLastSearchTextChangeUptime: TimeInterval = 0
private var debugLastSearchLayoutFieldWidth: CGFloat = -1
private var debugLastSearchLayoutStatusWidth: CGFloat = -1
private var debugLastLoggedSearchResultCount = -1
private var debugLastLoggedSearchStatus = ""
#endif
init(
coordinator: FileExplorerPanelView.Coordinator,
presentation: FileExplorerPanelPresentation,
searchController: (any FileSearchControlling)? = nil
) {
headerView = FileExplorerHeaderView()
searchBarView = NSView()
searchField = FileExplorerSearchField()
@@ -611,11 +640,12 @@ final class FileExplorerContainerView: NSView {
searchResultsView = FileExplorerSearchResultsTableView()
emptyLabel = NSTextField(labelWithString: String(localized: "fileExplorer.empty", defaultValue: "No folder open"))
loadingIndicator = NSProgressIndicator()
searchController = FileSearchController()
self.searchController = searchController ?? FileSearchController()
self.presentation = presentation
self.coordinator = coordinator
super.init(frame: .zero)
configureSearchDebounce()
// Header
headerView.translatesAutoresizingMaskIntoConstraints = false
@@ -631,6 +661,11 @@ final class FileExplorerContainerView: NSView {
searchField.placeholderString = String(localized: "fileExplorer.search.placeholder", defaultValue: "Search files")
searchField.font = .systemFont(ofSize: 12, weight: .regular)
searchField.focusRingType = .none
searchField.cell?.usesSingleLineMode = true
searchField.cell?.isScrollable = true
searchField.cell?.lineBreakMode = .byClipping
searchField.setContentHuggingPriority(.defaultLow, for: .horizontal)
searchField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
searchField.delegate = self
searchField.onCancel = { [weak self] in
self?.closeSearchAndFocusOutline()
@@ -656,6 +691,8 @@ final class FileExplorerContainerView: NSView {
searchStatusLabel.textColor = .secondaryLabelColor
searchStatusLabel.lineBreakMode = .byTruncatingTail
searchStatusLabel.maximumNumberOfLines = 1
searchStatusLabel.alignment = .left
searchStatusLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
searchStatusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
searchBarView.addSubview(searchStatusLabel)
@@ -681,6 +718,7 @@ final class FileExplorerContainerView: NSView {
outlineView.selectionHighlightStyle = .regular
outlineView.rowSizeStyle = .default
outlineView.indentationPerLevel = FileExplorerStyle.current.indentation
outlineView.allowsMultipleSelection = true
outlineView.autoresizesOutlineColumn = true
outlineView.floatsGroupRows = false
outlineView.backgroundColor = .clear
@@ -724,6 +762,7 @@ final class FileExplorerContainerView: NSView {
searchResultsView.selectionHighlightStyle = .regular
searchResultsView.backgroundColor = .clear
searchResultsView.rowHeight = 46
searchResultsView.allowsMultipleSelection = true
searchResultsView.intercellSpacing = NSSize(width: 0, height: 0)
searchResultsView.onCancel = { [weak self] in
self?.closeSearchAndFocusOutline()
@@ -762,7 +801,7 @@ final class FileExplorerContainerView: NSView {
searchScrollView.isHidden = true
addSubview(searchScrollView)
searchController.onSnapshotChanged = { [weak self] snapshot in
self.searchController.onSnapshotChanged = { [weak self] snapshot in
self?.applySearchSnapshot(snapshot)
}
@@ -778,13 +817,14 @@ final class FileExplorerContainerView: NSView {
searchBarHeightConstraint,
searchField.leadingAnchor.constraint(equalTo: searchBarView.leadingAnchor, constant: 8),
searchField.centerYAnchor.constraint(equalTo: searchBarView.centerYAnchor),
searchField.trailingAnchor.constraint(equalTo: searchBarView.trailingAnchor, constant: -8),
searchField.topAnchor.constraint(equalTo: searchBarView.topAnchor, constant: 4),
searchField.heightAnchor.constraint(equalToConstant: 24),
searchField.widthAnchor.constraint(greaterThanOrEqualToConstant: 120),
searchStatusLabel.leadingAnchor.constraint(equalTo: searchField.trailingAnchor, constant: 8),
searchStatusLabel.trailingAnchor.constraint(equalTo: searchBarView.trailingAnchor, constant: -8),
searchStatusLabel.centerYAnchor.constraint(equalTo: searchField.centerYAnchor),
searchStatusLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 140),
searchStatusLabel.leadingAnchor.constraint(equalTo: searchField.leadingAnchor, constant: 4),
searchStatusLabel.trailingAnchor.constraint(equalTo: searchField.trailingAnchor),
searchStatusLabel.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 2),
scrollView.topAnchor.constraint(equalTo: searchBarView.bottomAnchor),
scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
@@ -810,6 +850,7 @@ final class FileExplorerContainerView: NSView {
override func viewWillMove(toWindow newWindow: NSWindow?) {
if newWindow == nil {
cancelPendingSearchRefresh()
searchController.cancel(clear: false)
}
super.viewWillMove(toWindow: newWindow)
@@ -834,24 +875,33 @@ final class FileExplorerContainerView: NSView {
}
override func layout() {
#if DEBUG
let debugLayoutStart = ProcessInfo.processInfo.systemUptime
#endif
super.layout()
registerWithKeyboardFocusCoordinatorIfNeeded()
#if DEBUG
logSearchLayoutIfNeeded(startedAt: debugLayoutStart, reason: "layout")
#endif
}
func updateHeader(store: FileExplorerStore) {
let nextRootPath = store.rootPath
let nextProviderIsLocal = store.provider is LocalFileExplorerProvider
let nextContentRevision = store.contentRevision
let shouldRefreshSearch = nextRootPath != currentRootPath ||
nextProviderIsLocal != currentProviderIsLocal ||
nextContentRevision != currentContentRevision
let searchScopeChanged = nextRootPath != currentRootPath ||
nextProviderIsLocal != currentProviderIsLocal
let contentRevisionChanged = nextContentRevision != currentContentRevision
currentRootPath = nextRootPath
currentProviderIsLocal = nextProviderIsLocal
currentContentRevision = nextContentRevision
headerView.update(displayPath: store.displayRootPath)
if shouldRefreshSearch {
if searchScopeChanged {
pendingSearchRefreshAfterSettled = false
refreshSearchIfNeeded()
} else if contentRevisionChanged {
refreshSearchAfterContentRevisionIfNeeded()
}
}
@@ -864,7 +914,6 @@ final class FileExplorerContainerView: NSView {
if presentation == .find {
isSearchVisible = true
updateSearchLayout()
refreshSearchIfNeeded()
}
return
}
@@ -882,11 +931,17 @@ final class FileExplorerContainerView: NSView {
registerWithKeyboardFocusCoordinatorIfNeeded()
}
func updateVisibility(hasContent: Bool, isLoading: Bool) {
headerView.isHidden = !hasContent
updateSearchLayout(hasContent: hasContent, isLoading: isLoading)
let searchCanShow = isSearchVisible && hasContent && !isLoading
emptyLabel.isHidden = hasContent || searchCanShow
func updateVisibility(hasContent: Bool, isLoading: Bool, statusMessage: String?) {
let normalizedStatus = statusMessage?.trimmingCharacters(in: .whitespacesAndNewlines)
let hasStatus = normalizedStatus?.isEmpty == false
let canShowTree = hasContent && !hasStatus
headerView.isHidden = !hasContent && !hasStatus
updateSearchLayout(hasContent: canShowTree, isLoading: isLoading)
let searchCanShow = isSearchVisible && canShowTree && !isLoading
emptyLabel.stringValue = hasStatus
? normalizedStatus!
: String(localized: "fileExplorer.empty", defaultValue: "No folder open")
emptyLabel.isHidden = canShowTree || searchCanShow || isLoading
loadingIndicator.isHidden = !isLoading
if isLoading {
loadingIndicator.startAnimation(nil)
@@ -981,6 +1036,15 @@ final class FileExplorerContainerView: NSView {
private func refreshSearchIfNeeded() {
guard isSearchVisible else { return }
cancelPendingSearchRefresh()
#if DEBUG
dlog(
"file.search.request queryLen=\(searchField.stringValue.count) " +
"rootReady=\(currentRootPath.isEmpty ? 0 : 1) local=\(currentProviderIsLocal ? 1 : 0) " +
"revision=\(currentContentRevision) results=\(searchSnapshot.results.count) " +
"fieldW=\(debugSearchNumber(searchField.frame.width)) statusW=\(debugSearchNumber(searchStatusLabel.frame.width))"
)
#endif
searchController.search(
query: searchField.stringValue,
rootPath: currentRootPath,
@@ -989,34 +1053,139 @@ final class FileExplorerContainerView: NSView {
)
}
private func refreshSearchAfterContentRevisionIfNeeded() {
guard isSearchVisible else {
pendingSearchRefreshAfterSettled = false
return
}
guard searchSnapshot.isSearching else {
pendingSearchRefreshAfterSettled = false
refreshSearchIfNeeded()
return
}
pendingSearchRefreshAfterSettled = true
#if DEBUG
dlog(
"file.search.contentRevision.defer queryLen=\(searchField.stringValue.count) " +
"revision=\(currentContentRevision) results=\(searchSnapshot.results.count)"
)
#endif
}
private func configureSearchDebounce() {
searchDebounceCancellable = searchDebounceSubject
.debounce(for: .milliseconds(searchDebounceDelayMilliseconds), scheduler: RunLoop.main)
.sink { [weak self] debounceGeneration in
Task { @MainActor [weak self] in
guard let self,
self.isSearchVisible,
self.searchDebounceGeneration == debounceGeneration else { return }
#if DEBUG
dlog(
"file.search.debounce.fire queryLen=\(self.searchField.stringValue.count) " +
"delayMs=\(self.searchDebounceDelayMilliseconds)"
)
#endif
self.refreshSearchIfNeeded()
}
}
}
private func scheduleSearchRefresh() {
guard isSearchVisible else { return }
pendingSearchRefreshAfterSettled = false
searchDebounceGeneration += 1
let debounceGeneration = searchDebounceGeneration
#if DEBUG
dlog(
"file.search.debounce.schedule queryLen=\(searchField.stringValue.count) " +
"delayMs=\(searchDebounceDelayMilliseconds)"
)
#endif
searchDebounceSubject.send(debounceGeneration)
}
private func cancelPendingSearchRefresh() {
searchDebounceGeneration += 1
}
private func updateSearchLayout(hasContent: Bool? = nil, isLoading: Bool? = nil) {
let effectiveHasContent = hasContent ?? !currentRootPath.isEmpty
let effectiveIsLoading = isLoading ?? false
let showSearch = isSearchVisible && effectiveHasContent && !effectiveIsLoading
searchBarView.isHidden = !showSearch
searchBarHeightConstraint.constant = showSearch ? RightSidebarChromeMetrics.secondaryBarHeight : 0
searchBarHeightConstraint.constant = showSearch ? searchBarVisibleHeight : 0
searchScrollView.isHidden = !showSearch
scrollView.isHidden = showSearch || !effectiveHasContent || effectiveIsLoading
needsLayout = true
}
private func applySearchSnapshot(_ snapshot: FileSearchSnapshot) {
#if DEBUG
let debugApplyStart = ProcessInfo.processInfo.systemUptime
let previousStatusName = debugSearchStatusName(searchSnapshot.status)
let previousStatusTextLength = searchStatusLabel.stringValue.count
#endif
let previousSelectedRow = searchResultsView.selectedRow
let previousResults = searchSnapshot.results
searchSnapshot = snapshot
searchStatusLabel.stringValue = statusText(for: snapshot)
searchResultsView.reloadData()
applySearchResultsUpdate(previousResults: previousResults, nextResults: snapshot.results)
#if DEBUG
logSearchSnapshot(
snapshot,
startedAt: debugApplyStart,
previousStatusName: previousStatusName,
previousStatusTextLength: previousStatusTextLength
)
#endif
guard !snapshot.results.isEmpty else { return }
let selectedRow = previousSelectedRow >= 0
? min(previousSelectedRow, snapshot.results.count - 1)
: 0
searchResultsView.selectRowIndexes(IndexSet(integer: selectedRow), byExtendingSelection: false)
let shouldRunDeferredContentRefresh = !snapshot.isSearching && pendingSearchRefreshAfterSettled
if shouldRunDeferredContentRefresh {
pendingSearchRefreshAfterSettled = false
}
if !snapshot.results.isEmpty {
let selectedRow = previousSelectedRow >= 0
? min(previousSelectedRow, snapshot.results.count - 1)
: 0
searchResultsView.selectRowIndexes(IndexSet(integer: selectedRow), byExtendingSelection: false)
}
if shouldRunDeferredContentRefresh {
refreshSearchIfNeeded()
}
}
private func applySearchResultsUpdate(previousResults: [FileSearchResult], nextResults: [FileSearchResult]) {
if previousResults == nextResults {
return
}
if nextResults.count > previousResults.count &&
nextResults.starts(with: previousResults) {
let insertedRange = previousResults.count..<nextResults.count
searchResultsView.insertRows(at: IndexSet(integersIn: insertedRange), withAnimation: [])
return
}
if nextResults.count == previousResults.count {
let changedRows = IndexSet(
nextResults.indices.filter { nextResults[$0] != previousResults[$0] }
)
if !changedRows.isEmpty {
searchResultsView.reloadData(forRowIndexes: changedRows, columnIndexes: IndexSet(integer: 0))
}
return
}
searchResultsView.reloadData()
}
private func statusText(for snapshot: FileSearchSnapshot) -> String {
switch snapshot.status {
case .idle:
return String(localized: "fileExplorer.search.empty", defaultValue: "Type to search")
return ""
case .unsupported:
return String(localized: "fileExplorer.search.unsupported", defaultValue: "Local folders only")
case .searching:
@@ -1044,9 +1213,103 @@ final class FileExplorerContainerView: NSView {
}
}
#if DEBUG
private func debugSearchNumber(_ value: CGFloat) -> String {
String(format: "%.1f", Double(value))
}
private func debugSearchNumber(_ value: Double) -> String {
String(format: "%.1f", value)
}
private func debugSearchStatusName(_ status: FileSearchSnapshot.Status) -> String {
switch status {
case .idle:
return "idle"
case .unsupported:
return "unsupported"
case .searching:
return "searching"
case .noMatches:
return "noMatches"
case .matches:
return "matches"
case .limited(let limit):
return "limited(\(limit))"
case .failed:
return "failed"
}
}
private func logSearchLayoutIfNeeded(startedAt: TimeInterval, reason: String) {
guard isSearchVisible else { return }
let now = ProcessInfo.processInfo.systemUptime
let fieldWidth = searchField.frame.width
let statusWidth = searchStatusLabel.frame.width
let firstLayout = debugLastSearchLayoutFieldWidth < 0 || debugLastSearchLayoutStatusWidth < 0
let fieldDelta = debugLastSearchLayoutFieldWidth >= 0
? abs(fieldWidth - debugLastSearchLayoutFieldWidth)
: 0
let statusDelta = debugLastSearchLayoutStatusWidth >= 0
? abs(statusWidth - debugLastSearchLayoutStatusWidth)
: 0
let layoutMs = max(0, (now - startedAt) * 1000)
let widthChanged = fieldDelta > 0.5 || statusDelta > 0.5
let slowLayout = layoutMs >= 8
guard firstLayout || widthChanged || slowLayout else { return }
debugLastSearchLayoutFieldWidth = fieldWidth
debugLastSearchLayoutStatusWidth = statusWidth
let sinceKeyMs = debugLastSearchTextChangeUptime > 0
? debugSearchNumber((now - debugLastSearchTextChangeUptime) * 1000)
: "n/a"
dlog(
"file.search.layout reason=\(reason) fieldW=\(debugSearchNumber(fieldWidth)) " +
"statusW=\(debugSearchNumber(statusWidth)) fieldDelta=\(debugSearchNumber(fieldDelta)) " +
"statusDelta=\(debugSearchNumber(statusDelta)) layoutMs=\(debugSearchNumber(layoutMs)) " +
"sinceKeyMs=\(sinceKeyMs) queryLen=\(searchField.stringValue.count) " +
"results=\(searchSnapshot.results.count) status=\(debugSearchStatusName(searchSnapshot.status))"
)
}
private func logSearchSnapshot(
_ snapshot: FileSearchSnapshot,
startedAt: TimeInterval,
previousStatusName: String,
previousStatusTextLength: Int
) {
let now = ProcessInfo.processInfo.systemUptime
let applyMs = max(0, (now - startedAt) * 1000)
let statusName = debugSearchStatusName(snapshot.status)
let resultDelta = debugLastLoggedSearchResultCount >= 0
? abs(snapshot.results.count - debugLastLoggedSearchResultCount)
: snapshot.results.count
let shouldLog = statusName != debugLastLoggedSearchStatus ||
resultDelta >= 50 ||
applyMs >= 4
guard shouldLog else { return }
debugLastLoggedSearchStatus = statusName
debugLastLoggedSearchResultCount = snapshot.results.count
let sinceKeyMs = debugLastSearchTextChangeUptime > 0
? debugSearchNumber((now - debugLastSearchTextChangeUptime) * 1000)
: "n/a"
dlog(
"file.search.snapshot status=\(statusName) previousStatus=\(previousStatusName) " +
"results=\(snapshot.results.count) isSearching=\(snapshot.isSearching ? 1 : 0) " +
"applyMs=\(debugSearchNumber(applyMs)) sinceKeyMs=\(sinceKeyMs) " +
"fieldW=\(debugSearchNumber(searchField.frame.width)) statusW=\(debugSearchNumber(searchStatusLabel.frame.width)) " +
"statusIntrinsicW=\(debugSearchNumber(searchStatusLabel.intrinsicContentSize.width)) " +
"statusTextLen=\(searchStatusLabel.stringValue.count) previousStatusTextLen=\(previousStatusTextLength)"
)
}
#endif
private func closeSearchAndFocusOutline() {
if presentation == .find {
let hadQuery = !searchField.stringValue.isEmpty
cancelPendingSearchRefresh()
pendingSearchRefreshAfterSettled = false
searchController.cancel(clear: true)
searchField.stringValue = ""
applySearchSnapshot(.empty)
@@ -1065,6 +1328,7 @@ final class FileExplorerContainerView: NSView {
isSearchVisible = false
searchController.cancel(clear: true)
searchField.stringValue = ""
pendingSearchRefreshAfterSettled = false
searchSnapshot = .empty
searchResultsView.reloadData()
updateSearchLayout()
@@ -1134,7 +1398,33 @@ final class FileExplorerContainerView: NSView {
extension FileExplorerContainerView: NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
func controlTextDidChange(_ notification: Notification) {
guard notification.object as? NSTextField === searchField else { return }
refreshSearchIfNeeded()
scrollSearchFieldEditorToInsertionPoint()
Task { @MainActor [weak self] in
self?.scrollSearchFieldEditorToInsertionPoint()
}
#if DEBUG
let now = ProcessInfo.processInfo.systemUptime
let gapMs = debugLastSearchTextChangeUptime > 0
? debugSearchNumber((now - debugLastSearchTextChangeUptime) * 1000)
: "n/a"
debugLastSearchTextChangeUptime = now
dlog(
"file.search.input.changed queryLen=\(searchField.stringValue.count) gapMs=\(gapMs) " +
"fieldW=\(debugSearchNumber(searchField.frame.width)) statusW=\(debugSearchNumber(searchStatusLabel.frame.width)) " +
"statusIntrinsicW=\(debugSearchNumber(searchStatusLabel.intrinsicContentSize.width)) " +
"results=\(searchSnapshot.results.count) status=\(debugSearchStatusName(searchSnapshot.status)) " +
"fr=\(fileExplorerDebugResponder(window?.firstResponder))"
)
#endif
scheduleSearchRefresh()
}
private func scrollSearchFieldEditorToInsertionPoint() {
guard let editor = searchField.currentEditor() else { return }
let selection = editor.selectedRange
let textLength = (editor.string as NSString).length
let cursorLocation = min(selection.location + selection.length, textLength)
editor.scrollRangeToVisible(NSRange(location: cursorLocation, length: 0))
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
@@ -1207,7 +1497,7 @@ extension FileExplorerContainerView: NSSearchFieldDelegate, NSTableViewDataSourc
let clickedRow = searchResultsView.clickedRow
let row = clickedRow >= 0 ? clickedRow : searchResultsView.selectedRow
guard row >= 0, row < searchSnapshot.results.count else { return }
if clickedRow >= 0 {
if clickedRow >= 0 && !searchResultsView.selectedRowIndexes.contains(clickedRow) {
searchResultsView.selectRowIndexes(IndexSet(integer: clickedRow), byExtendingSelection: false)
}
@@ -1240,6 +1530,8 @@ extension FileExplorerContainerView: NSSearchFieldDelegate, NSTableViewDataSourc
menu.addItem(.separator())
menu.addFileExplorerInsertPathItems(target: self, representedObject: NSNumber(value: row), insertAction: #selector(contextMenuInsertSearchResultPath(_:)), insertRelativeAction: #selector(contextMenuInsertSearchResultRelativePath(_:)))
let copyPathItem = NSMenuItem(
title: String(localized: "fileExplorer.contextMenu.copyPath", defaultValue: "Copy Path"),
action: #selector(contextMenuCopySearchResultPath(_:)),
@@ -1310,7 +1602,7 @@ private final class FileExplorerSearchField: NSSearchField {
}
}
private final class FileExplorerSearchResultsTableView: NSTableView {
final class FileExplorerSearchResultsTableView: NSTableView {
var onCancel: (() -> Void)?
var onMoveSelection: ((Int) -> Void)?
var onCommit: (() -> Void)?
@@ -1486,15 +1778,18 @@ final class FileExplorerHeaderView: NSView {
}
private func applyHeaderState() {
assert(Thread.isMainThread, "AppKit image updates must run on the main thread")
let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .regular)
if let quickSearchQuery {
iconView.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: nil)?
.withSymbolConfiguration(config)
pathLabel.stringValue = "/" + quickSearchQuery
pathLabel.toolTip = pathLabel.stringValue
} else {
iconView.image = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil)?
.withSymbolConfiguration(config)
pathLabel.stringValue = displayPath
pathLabel.toolTip = displayPath
}
}
}
@@ -1580,11 +1875,10 @@ final class FileExplorerCellView: NSTableCellView {
}
func configure(with node: FileExplorerNode, gitStatus: GitFileStatus? = nil) {
assert(Thread.isMainThread, "AppKit image updates must run on the main thread")
let style = FileExplorerStyle.current
nameLabel.stringValue = node.name
nameLabel.font = style.nameFont
iconWidthConstraint.constant = style.iconSize
iconHeightConstraint.constant = style.iconSize
iconToTextConstraint.constant = style.iconToTextSpacing
+150
View File
@@ -0,0 +1,150 @@
import AppKit
import Carbon.HIToolbox
extension GhosttyNSView {
/// Clamps AppKit's marked-text selection into the active preedit buffer.
func normalizedMarkedSelectionRange(_ range: NSRange, markedLength: Int) -> NSRange {
guard markedLength > 0 else {
return NSRange(location: NSNotFound, length: 0)
}
guard range.location != NSNotFound else {
return NSRange(location: markedLength, length: 0)
}
let clampedLocation = min(max(range.location, 0), markedLength)
let clampedLength = min(max(range.length, 0), markedLength - clampedLocation)
return NSRange(location: clampedLocation, length: clampedLength)
}
/// Clamps an AppKit substring query so it can be served from marked text.
func clampedMarkedTextRange(_ range: NSRange, markedLength: Int) -> NSRange? {
guard range.length > 0, range.location != NSNotFound else { return nil }
guard markedLength > 0 else { return nil }
let location = min(max(range.location, 0), markedLength)
let maxLength = markedLength - location
guard maxLength > 0 else { return nil }
let length = min(max(range.length, 0), maxLength)
guard length > 0 else { return nil }
return NSRange(location: location, length: length)
}
/// Returns true when AppKit consumed the key by changing IME composition state.
func shouldSuppressGhosttyKeyForwardingAfterIMEHandling(
before: (text: String, selection: NSRange),
after: (text: String, selection: NSRange),
accumulatedText: [String],
event: NSEvent? = nil,
textInputHandledEvent: Bool = false,
inputSourceId: String? = nil
) -> Bool {
guard accumulatedText.isEmpty else { return false }
let hadMarkedTextBefore = !before.text.isEmpty
let hasMarkedTextAfter = !after.text.isEmpty
guard hadMarkedTextBefore || hasMarkedTextAfter else {
guard textInputHandledEvent, isBopomofoInputSource(inputSourceId) else { return false }
return shouldKeepNoMarkedIMECommandInsideTextInput(event)
}
if before.text != after.text {
return true
}
if before.selection != after.selection {
return true
}
guard let event, isInputMethodSource(inputSourceId) else {
return false
}
return shouldKeepIMECompositionCommandInsideTextInput(event)
}
func isInputMethodSource(_ sourceId: String?) -> Bool {
guard let sourceId else { return false }
return sourceId.localizedCaseInsensitiveContains("inputmethod")
}
func isBopomofoInputSource(_ sourceId: String?) -> Bool {
guard let sourceId else { return false }
return sourceId.localizedCaseInsensitiveContains("Zhuyin")
|| sourceId.localizedCaseInsensitiveContains("Bopomofo")
}
func hasOnlyTextInputCommandModifiers(_ event: NSEvent) -> Bool {
let flags = event.modifierFlags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
return flags.isEmpty || flags == [.shift]
}
func shouldKeepNoMarkedIMECommandInsideTextInput(_ event: NSEvent?) -> Bool {
guard let event else { return false }
guard hasOnlyTextInputCommandModifiers(event) else { return false }
switch Int(event.keyCode) {
case kVK_DownArrow, kVK_PageUp, kVK_PageDown, kVK_Space:
return true
default:
return false
}
}
/// Returns true when a window-level key-equivalent probe should re-enter
/// the terminal's keyDown path so AppKit's text input context sees the key
/// before terminal bindings or cursor escape sequences do.
func shouldRouteTextInputKeyEquivalentToKeyDown(_ event: NSEvent) -> Bool {
shouldRouteTextInputKeyEquivalentToKeyDown(event, inputSourceId: nil)
}
func shouldRouteTextInputKeyEquivalentToKeyDown(_ event: NSEvent, inputSourceId: String?) -> Bool {
guard event.type == .keyDown else { return false }
let resolvedInputSourceId = inputSourceId ?? KeyboardLayout.id
if hasMarkedText() {
return isInputMethodSource(resolvedInputSourceId)
&& shouldKeepIMECompositionCommandInsideTextInput(event)
}
return isBopomofoInputSource(resolvedInputSourceId)
&& shouldKeepNoMarkedIMECommandInsideTextInput(event)
}
/// Returns true for active-composition command keys that belong to AppKit's
/// text input manager even when marked text itself does not change.
func shouldKeepIMECompositionCommandInsideTextInput(_ event: NSEvent) -> Bool {
guard hasOnlyTextInputCommandModifiers(event) else { return false }
switch Int(event.keyCode) {
case kVK_LeftArrow, kVK_RightArrow, kVK_UpArrow, kVK_DownArrow,
kVK_PageUp, kVK_PageDown, kVK_Home, kVK_End,
kVK_Space, kVK_Return, kVK_ANSI_KeypadEnter, kVK_Escape,
kVK_Tab, kVK_Delete, kVK_ForwardDelete:
return true
default:
return false
}
}
#if DEBUG
func shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting(
markedTextBefore: String,
markedSelectionBefore: NSRange,
markedTextAfter: String,
markedSelectionAfter: NSRange,
accumulatedText: [String],
event: NSEvent? = nil,
textInputHandledEvent: Bool = false,
inputSourceId: String? = nil
) -> Bool {
shouldSuppressGhosttyKeyForwardingAfterIMEHandling(
before: (markedTextBefore, markedSelectionBefore),
after: (markedTextAfter, markedSelectionAfter),
accumulatedText: accumulatedText,
event: event,
textInputHandledEvent: textInputHandledEvent,
inputSourceId: inputSourceId
)
}
#endif
}
+468 -147
View File
@@ -299,6 +299,12 @@ enum GhosttyPasteboardHelper {
case rejectedImagePayload
}
enum ImageFileListMaterializationResult {
case saved([URL])
case noDecodableImagePayload
case rejectedImagePayload
}
private static let selectionPasteboard = NSPasteboard(
name: NSPasteboard.Name("com.mitchellh.ghostty.selection")
)
@@ -351,6 +357,14 @@ enum GhosttyPasteboardHelper {
return richText
}
if let plainText,
PasteboardTextFidelity.shouldInspectRichTextForPlainTextLoss(plainText),
types.contains(where: isRichTextType),
let richText = richTextContents(from: pasteboard),
PasteboardTextFidelity.shouldPreferRichText(richText, overPlainText: plainText) {
return richText
}
// Match upstream Ghostty's fast plain-text path for normal text paste.
// Large clipboard payloads often also advertise HTML/RTF variants, and
// eagerly rendering those rich-text flavors makes Cmd-V much slower than
@@ -473,6 +487,10 @@ enum GhosttyPasteboardHelper {
return utType.conforms(to: .plainText)
}
private static func isRichTextType(_ type: NSPasteboard.PasteboardType) -> Bool {
type == .html || type == .rtf || type == .rtfd
}
private static func attributedString(
from pasteboard: NSPasteboard,
type: NSPasteboard.PasteboardType,
@@ -493,20 +511,34 @@ enum GhosttyPasteboardHelper {
)
}
private static func rtfdAttachmentImageRepresentation(
in pasteboard: NSPasteboard
) -> (data: Data, fileExtension: String)? {
guard let attributed = attributedString(
from: pasteboard,
type: .rtfd,
documentType: .rtfd
) else { return nil }
private static func attributedString(
from item: NSPasteboardItem,
type: NSPasteboard.PasteboardType,
documentType: NSAttributedString.DocumentType
) -> NSAttributedString? {
let data =
item.data(forType: type)
?? item.string(forType: type)?.data(using: .utf8)
guard let data else { return nil }
var result: (data: Data, fileExtension: String)?
return try? NSAttributedString(
data: data,
options: [
.documentType: documentType,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
)
}
private static func rtfdAttachmentImageRepresentations(
from attributed: NSAttributedString
) -> [(data: Data, fileExtension: String)] {
var results: [(data: Data, fileExtension: String)] = []
attributed.enumerateAttribute(
.attachment,
in: NSRange(location: 0, length: attributed.length)
) { value, _, stop in
) { value, _, _ in
guard let attachment = value as? NSTextAttachment else { return }
if let fileWrapper = attachment.fileWrapper,
@@ -515,12 +547,33 @@ enum GhosttyPasteboardHelper {
data: data,
preferredFilename: fileWrapper.preferredFilename
) {
result = imageRepresentation
stop.pointee = true
results.append(imageRepresentation)
}
}
return result
return results
}
private static func rtfdAttachmentImageRepresentations(
in pasteboard: NSPasteboard
) -> [(data: Data, fileExtension: String)] {
guard let attributed = attributedString(
from: pasteboard,
type: .rtfd,
documentType: .rtfd
) else { return [] }
return rtfdAttachmentImageRepresentations(from: attributed)
}
private static func rtfdAttachmentImageRepresentations(
in item: NSPasteboardItem
) -> [(data: Data, fileExtension: String)] {
guard let attributed = attributedString(
from: item,
type: .rtfd,
documentType: .rtfd
) else { return [] }
return rtfdAttachmentImageRepresentations(from: attributed)
}
private static func imageAttachmentRepresentation(
@@ -533,6 +586,9 @@ enum GhosttyPasteboardHelper {
if let type = !pathExtension.isEmpty ? UTType(filenameExtension: pathExtension) : nil,
type.conforms(to: .image),
let fileExtension = type.preferredFilenameExtension ?? nonEmpty(pathExtension) {
if isTIFFType(type) {
return normalizedPNGRepresentation(from: data)
}
return (data, fileExtension)
}
@@ -541,9 +597,38 @@ enum GhosttyPasteboardHelper {
let type = UTType(typeIdentifier),
type.conforms(to: .image),
let fileExtension = type.preferredFilenameExtension else { return nil }
if isTIFFType(type) {
return normalizedPNGRepresentation(from: data)
}
return (data, fileExtension)
}
private static func imageDataRepresentation(
data: Data,
type: NSPasteboard.PasteboardType
) -> (data: Data, fileExtension: String)? {
guard let utType = UTType(type.rawValue),
utType.conforms(to: .image),
let fileExtension = utType.preferredFilenameExtension,
!fileExtension.isEmpty else { return nil }
if isTIFFType(utType) {
return normalizedPNGRepresentation(from: data)
}
return (data, fileExtension)
}
private static func isTIFFType(_ type: UTType) -> Bool {
type == .tiff || type.conforms(to: .tiff)
}
private static func normalizedPNGRepresentation(from data: Data) -> (data: Data, fileExtension: String)? {
guard let image = NSImage(data: data),
let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil }
return (pngData, "png")
}
private static func nonEmpty(_ value: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
@@ -570,70 +655,218 @@ enum GhosttyPasteboardHelper {
for type in pasteboard.types ?? [] {
guard type != .png,
type != .tiff,
let utType = UTType(type.rawValue),
utType.conforms(to: .image),
let imageData = pasteboard.data(forType: type),
let fileExtension = utType.preferredFilenameExtension,
!fileExtension.isEmpty else { continue }
return (imageData, fileExtension)
let representation = imageDataRepresentation(data: imageData, type: type) else { continue }
return representation
}
return nil
}
private static func directImageRepresentation(
in item: NSPasteboardItem
) -> (data: Data, fileExtension: String)? {
if let pngData = item.data(forType: .png) {
return (pngData, "png")
}
for type in item.types {
guard type != .png,
let imageData = item.data(forType: type),
let representation = imageDataRepresentation(data: imageData, type: type) else { continue }
return representation
}
return nil
}
private static func fallbackImageRepresentation(
in item: NSPasteboardItem
) -> (data: Data, fileExtension: String)? {
for type in item.types {
guard let utType = UTType(type.rawValue),
utType.conforms(to: .image),
let data = item.data(forType: type),
let normalized = normalizedPNGRepresentation(from: data) else { continue }
return normalized
}
return nil
}
private static func fallbackImageRepresentation(
in pasteboard: NSPasteboard
) -> (data: Data, fileExtension: String)? {
guard hasImageData(in: pasteboard),
let tiffData = NSImage(pasteboard: pasteboard)?.tiffRepresentation else { return nil }
return normalizedPNGRepresentation(from: tiffData)
}
private static func imageRepresentations(
in item: NSPasteboardItem
) -> [(data: Data, fileExtension: String)] {
if let directImage = directImageRepresentation(in: item) {
return [directImage]
}
let rtfdAttachments = rtfdAttachmentImageRepresentations(in: item)
if !rtfdAttachments.isEmpty {
return rtfdAttachments
}
if let fallbackImage = fallbackImageRepresentation(in: item) {
return [fallbackImage]
}
return []
}
private static func pasteboardFallbackImageRepresentations(
for item: NSPasteboardItem
) -> [(data: Data, fileExtension: String)] {
guard let copiedItem = copiedPasteboardItem(from: item) else { return [] }
let pasteboard = NSPasteboard(name: .init("cmux-single-image-item-\(UUID().uuidString)"))
pasteboard.clearContents()
defer {
pasteboard.clearContents()
pasteboard.releaseGlobally()
}
guard pasteboard.writeObjects([copiedItem]) else { return [] }
if let directImage = directImageRepresentation(in: pasteboard) {
return [directImage]
}
let rtfdAttachments = rtfdAttachmentImageRepresentations(in: pasteboard)
if !rtfdAttachments.isEmpty {
return rtfdAttachments
}
if let fallbackImage = fallbackImageRepresentation(in: pasteboard) {
return [fallbackImage]
}
return []
}
private static func copiedPasteboardItem(from item: NSPasteboardItem) -> NSPasteboardItem? {
let copiedItem = NSPasteboardItem()
var copiedAnyType = false
for type in item.types {
if let data = item.data(forType: type) {
copiedAnyType = copiedItem.setData(data, forType: type) || copiedAnyType
continue
}
if let string = item.string(forType: type) {
copiedAnyType = copiedItem.setString(string, forType: type) || copiedAnyType
}
}
return copiedAnyType ? copiedItem : nil
}
private static func imageRepresentations(
in pasteboard: NSPasteboard
) -> [(data: Data, fileExtension: String)] {
let itemRepresentations = (pasteboard.pasteboardItems ?? [])
.flatMap { item in
let representations = imageRepresentations(in: item)
if !representations.isEmpty {
return representations
}
return pasteboardFallbackImageRepresentations(for: item)
}
if !itemRepresentations.isEmpty {
return itemRepresentations
}
if let directImage = directImageRepresentation(in: pasteboard) {
return [directImage]
}
let rtfdAttachments = rtfdAttachmentImageRepresentations(in: pasteboard)
if !rtfdAttachments.isEmpty {
return rtfdAttachments
}
if let fallbackImage = fallbackImageRepresentation(in: pasteboard) {
return [fallbackImage]
}
return []
}
private static func materializeImageFileURLs(
from representations: [(data: Data, fileExtension: String)]
) -> ImageFileListMaterializationResult {
guard !representations.isEmpty else { return .noDecodableImagePayload }
let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB
var fileURLs: [URL] = []
for representation in representations {
guard representation.data.count <= maxClipboardImageSize else {
#if DEBUG
cmuxDebugLog("terminal.paste.image.rejected reason=tooLarge bytes=\(representation.data.count)")
#endif
cleanupTransferredTemporaryImageFiles(fileURLs)
return .rejectedImagePayload
}
let fileURL = temporaryImageFileURL(fileExtension: representation.fileExtension)
do {
try representation.data.write(to: fileURL)
} catch {
#if DEBUG
cmuxDebugLog("terminal.paste.image.writeFailed error=\(error.localizedDescription)")
#endif
try? FileManager.default.removeItem(at: fileURL)
cleanupTransferredTemporaryImageFiles(fileURLs)
return .rejectedImagePayload
}
registerOwnedTemporaryImageFile(fileURL)
fileURLs.append(fileURL)
}
return .saved(fileURLs)
}
private static func temporaryImageFileURL(fileExtension: String) -> URL {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
formatter.locale = Locale(identifier: "en_US_POSIX")
let timestamp = formatter.string(from: Date())
let filename = "\(temporaryImageFilenamePrefix)\(timestamp)-\(UUID().uuidString.prefix(8)).\(fileExtension)"
return FileManager.default.temporaryDirectory.appendingPathComponent(filename)
}
/// Attempts to materialize a decodable pasteboard image into a temporary file.
/// `rejectedImagePayload` means a real image was found but could not be used,
/// so callers should not fall back to auxiliary plain text or URLs.
static func materializeImageFileURLIfNeeded(
from pasteboard: NSPasteboard = .general
) -> ImageFileMaterializationResult {
let imageData: Data
let fileExtension: String
if let directImage = directImageRepresentation(in: pasteboard) {
imageData = directImage.data
fileExtension = directImage.fileExtension
} else if let rtfdAttachment = rtfdAttachmentImageRepresentation(in: pasteboard) {
imageData = rtfdAttachment.data
fileExtension = rtfdAttachment.fileExtension
} else {
guard hasImageData(in: pasteboard),
let image = NSImage(pasteboard: pasteboard),
let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let pngData = bitmap.representation(using: .png, properties: [:]) else {
return .noDecodableImagePayload
}
imageData = pngData
fileExtension = "png"
}
let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB
guard imageData.count <= maxClipboardImageSize else {
#if DEBUG
cmuxDebugLog("terminal.paste.image.rejected reason=tooLarge bytes=\(imageData.count)")
#endif
let representations = Array(imageRepresentations(in: pasteboard).prefix(1))
switch materializeImageFileURLs(from: representations) {
case .saved(let fileURLs):
guard let fileURL = fileURLs.first else { return .noDecodableImagePayload }
return .saved(fileURL)
case .noDecodableImagePayload:
return .noDecodableImagePayload
case .rejectedImagePayload:
return .rejectedImagePayload
}
}
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
formatter.locale = Locale(identifier: "en_US_POSIX")
let timestamp = formatter.string(from: Date())
let filename = "\(temporaryImageFilenamePrefix)\(timestamp)-\(UUID().uuidString.prefix(8)).\(fileExtension)"
let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
static func materializeImageFileURLsIfNeeded(
from pasteboard: NSPasteboard = .general
) -> ImageFileListMaterializationResult {
materializeImageFileURLs(from: imageRepresentations(in: pasteboard))
}
do {
try imageData.write(to: fileURL)
} catch {
#if DEBUG
cmuxDebugLog("terminal.paste.image.writeFailed error=\(error.localizedDescription)")
#endif
return .rejectedImagePayload
static func saveImageFileURLsIfNeeded(
from pasteboard: NSPasteboard = .general,
assumeNoText: Bool = false
) -> [URL] {
if !assumeNoText && stringContents(from: pasteboard) != nil { return [] }
guard case .saved(let fileURLs) = materializeImageFileURLsIfNeeded(from: pasteboard) else {
return []
}
registerOwnedTemporaryImageFile(fileURL)
return .saved(fileURL)
return fileURLs
}
/// When the clipboard contains only image data (or rich text that resolves to
@@ -673,6 +906,17 @@ enum GhosttyPasteboardHelper {
}
}
static func cleanupAllOwnedTemporaryImageFiles() {
temporaryImageOwnershipLock.lock()
let paths = ownedTemporaryImagePaths
ownedTemporaryImagePaths.removeAll()
temporaryImageOwnershipLock.unlock()
for path in paths {
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path))
}
}
private static func registerOwnedTemporaryImageFile(_ fileURL: URL) {
let normalizedPath = fileURL.standardizedFileURL.path
temporaryImageOwnershipLock.lock()
@@ -1866,8 +2110,9 @@ class GhosttyApp {
return
}
let fallbackShouldUseHostLayerBackground = usesHostLayerBackground(for: fallbackConfig)
loadInlineGhosttyConfig(
"macos-background-from-layer = true",
"macos-background-from-layer = \(fallbackShouldUseHostLayerBackground)",
into: fallbackConfig,
prefix: "cmux-renderer-bg",
logLabel: "renderer background (fallback)"
@@ -1880,7 +2125,7 @@ class GhosttyApp {
)
loadCmuxOwnedGhosttyKeybindOverrides(fallbackConfig)
let fallbackRenderingModeChanged = setUsesHostLayerBackground(
true,
fallbackShouldUseHostLayerBackground,
source: "initialize.fallbackConfig"
)
ghostty_config_finalize(fallbackConfig)
@@ -2016,15 +2261,17 @@ class GhosttyApp {
}
#endif
loadCJKFontFallbackIfNeeded(config)
let shouldUseHostLayerBackground = usesHostLayerBackground(for: config)
let renderingModeChanged = setUsesHostLayerBackground(
true,
shouldUseHostLayerBackground,
source: "loadDefaultConfigFilesWithLegacyFallback"
)
// Let cmux own the window-level backdrop once, while Ghostty keeps
// rendering text, cell backgrounds, and background images. This avoids
// separate translucent fills for terminal and chrome surfaces.
// Let Ghostty paint solid opaque terminal backgrounds so default cells
// and explicit ANSI background cells share one renderer/compositor path.
// Host-layer ownership remains required for translucent and blurred
// terminal backgrounds.
loadInlineGhosttyConfig(
"macos-background-from-layer = true",
"macos-background-from-layer = \(shouldUseHostLayerBackground)",
into: config,
prefix: "cmux-renderer-bg",
logLabel: "renderer background"
@@ -2055,17 +2302,21 @@ class GhosttyApp {
}
private func loadCmuxOwnedGhosttyKeybindOverrides(_ config: ghostty_config_t) {
// cmux owns these split shortcuts through KeyboardShortcutSettings.
// cmux owns these split and close shortcuts through KeyboardShortcutSettings.
// Remove Ghostty's default fallbacks so remapped or cleared shortcuts
// can reach the focused terminal instead of creating a split.
// can reach the focused terminal instead of splitting or closing outside
// the remappable shortcut layer.
loadInlineGhosttyConfig(
"""
keybind = super+d=unbind
keybind = super+shift+d=unbind
keybind = super+w=unbind
keybind = super+alt+w=unbind
keybind = super+shift+w=unbind
""",
into: config,
prefix: "cmux-owned-split-keybind-overrides",
logLabel: "cmux-owned split keybind overrides"
prefix: "cmux-owned-keybind-overrides",
logLabel: "cmux-owned keybind overrides"
)
}
@@ -2978,10 +3229,7 @@ class GhosttyApp {
let resolvedCursorText = ghosttyColorValue(from: config, key: "cursor-text", fallback: baseline.cursorTextColor)
let resolvedSelectionBackground = ghosttyColorValue(from: config, key: "selection-background", fallback: baseline.selectionBackground)
let resolvedSelectionForeground = ghosttyColorValue(from: config, key: "selection-foreground", fallback: baseline.selectionForeground)
var opacity = baseline.backgroundOpacity
let opacityKey = "background-opacity"
_ = ghostty_config_get(config, &opacity, opacityKey, UInt(opacityKey.lengthOfBytes(using: .utf8)))
opacity = min(1.0, max(0.0, opacity))
let opacity = defaultBackgroundOpacityValue(from: config)
let backgroundBlur = defaultBackgroundBlurValue(from: config)
applyDefaultBackground(
color: resolvedColor,
@@ -2998,6 +3246,20 @@ class GhosttyApp {
)
}
private func defaultBackgroundOpacityValue(from config: ghostty_config_t) -> Double {
var opacity = Self.fallbackAppearanceConfig.backgroundOpacity
let key = "background-opacity"
_ = ghostty_config_get(config, &opacity, key, UInt(key.lengthOfBytes(using: .utf8)))
return Double(WindowAppearanceSnapshot.clampedOpacity(opacity))
}
private func usesHostLayerBackground(for config: ghostty_config_t) -> Bool {
WindowAppearanceSnapshot.usesHostLayerBackground(
backgroundOpacity: defaultBackgroundOpacityValue(from: config),
backgroundBlur: defaultBackgroundBlurValue(from: config)
)
}
private func defaultBackgroundBlurValue(from config: ghostty_config_t) -> GhosttyBackgroundBlur {
var value: Int16 = 0
let key = "background-blur"
@@ -4180,19 +4442,19 @@ final class TerminalSurface: Identifiable, ObservableObject {
var portOrdinal: Int = 0
/// Snapshotted once per app session so all workspaces use consistent values
private static let sessionPortBase: Int = {
let val = UserDefaults.standard.integer(forKey: "cmuxPortBase")
return val > 0 ? val : 9100
let val = UserDefaults.standard.integer(forKey: AutomationSettings.portBaseKey)
return val > 0 ? val : AutomationSettings.defaultPortBase
}()
private static let sessionPortRangeSize: Int = {
let val = UserDefaults.standard.integer(forKey: "cmuxPortRange")
return val > 0 ? val : 10
let val = UserDefaults.standard.integer(forKey: AutomationSettings.portRangeKey)
return val > 0 ? val : AutomationSettings.defaultPortRange
}()
private let surfaceContext: ghostty_surface_context_e
private let configTemplate: CmuxSurfaceConfigTemplate?
private let workingDirectory: String?
private let initialCommand: String?
private let tmuxStartCommand: String?
private let initialInput: String?
let initialCommand: String?
let tmuxStartCommand: String?
let initialInput: String?
private let initialEnvironmentOverrides: [String: String]
var requestedWorkingDirectory: String? { workingDirectory }
let focusPlacement: TerminalSurfaceFocusPlacement
@@ -4295,6 +4557,10 @@ final class TerminalSurface: Identifiable, ObservableObject {
additionalEnvironment: [String: String] = [:],
focusPlacement: TerminalSurfaceFocusPlacement = .workspace
) {
#if DEBUG
dispatchPrecondition(condition: .onQueue(.main))
#endif
self.id = UUID()
self.tabId = tabId
self.surfaceContext = context
@@ -4383,14 +4649,6 @@ final class TerminalSurface: Identifiable, ObservableObject {
cmuxSurfaceContextName(surfaceContext)
}
func debugInitialCommand() -> String? {
initialCommand
}
func debugTmuxStartCommand() -> String? {
tmuxStartCommand
}
func debugPortalHostLease() -> (hostId: String?, paneId: UUID?, inWindow: Bool?, area: CGFloat?) {
guard let activePortalHostLease else {
return (nil, nil, nil, nil)
@@ -5938,6 +6196,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe")
}()
static var debugGhosttySurfaceKeyEventObserver: ((ghostty_input_key_s) -> Void)?
@MainActor static var debugTextInputEventHandler: ((GhosttyNSView, NSEvent) -> Bool)?
#endif
private var eventMonitor: Any?
private var trackingArea: NSTrackingArea?
@@ -6942,6 +7201,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
let result = super.becomeFirstResponder()
var shouldApplySurfaceFocus = false
if result {
imeSuppressedKeyUpKeyCodes.removeAll()
if let terminalSurface,
AppDelegate.shared?.allowsTerminalKeyboardFocus(
workspaceId: terminalSurface.tabId,
@@ -7021,6 +7281,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
if let displayID = window?.screen?.displayID, displayID != 0 {
ghostty_surface_set_display_id(surface, displayID)
}
terminalSurface?.forceRefresh(reason: "focus.firstResponder")
}
return result
}
@@ -7030,6 +7291,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
if result {
desiredFocus = false
terminalSurface?.recordExternalFocusState(false)
imeSuppressedKeyUpKeyCodes.removeAll()
}
if result, let surface = surface {
let now = CACurrentMediaTime()
@@ -7042,7 +7304,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// For NSTextInputClient - accumulates text during key events
private(set) var keyTextAccumulator: [String]? = nil
private var imeSuppressedKeyUpKeyCodes: Set<UInt16> = []
private var markedText = NSMutableAttributedString()
private var markedSelectedRange = NSRange(location: NSNotFound, length: 0)
private var lastPerformKeyEvent: TimeInterval?
private(set) var externalCommittedTextDepth = 0
var numpadIMECommitDeduplicator = NumpadIMECommitDeduplicator()
@@ -7060,20 +7324,19 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
var keyTextAccumulatorForTesting: [String]? {
keyTextAccumulator
}
func setIMETransientStateForTesting(suppressedKeyUpKeyCodes: Set<UInt16>) {
imeSuppressedKeyUpKeyCodes = suppressedKeyUpKeyCodes
}
var imeSuppressedKeyUpKeyCodesForTesting: Set<UInt16> {
imeSuppressedKeyUpKeyCodes
}
func shouldSuppressShiftSpaceFallbackTextForTesting(event: NSEvent, markedTextBefore: Bool) -> Bool {
shouldSuppressShiftSpaceFallbackText(event: event, markedTextBefore: markedTextBefore)
}
// Test-only IME point override so firstRect behavior can be regression tested.
private var imePointOverrideForTesting: (x: Double, y: Double, width: Double, height: Double)?
func setIMEPointForTesting(x: Double, y: Double, width: Double, height: Double) {
imePointOverrideForTesting = (x, y, width, height)
}
func clearIMEPointForTesting() {
imePointOverrideForTesting = nil
}
func setIMEPointForTesting(x: Double, y: Double, width: Double, height: Double) { imePointOverrideForTesting = (x, y, width, height) }
func clearIMEPointForTesting() { imePointOverrideForTesting = nil }
#endif
#if DEBUG
@@ -7121,10 +7384,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
fr === self || fr.isDescendant(of: self) else { return false }
guard let surface = ensureSurfaceReadyForInput() else { return false }
// If the IME is composing (marked text present) and the key has no Cmd
// modifier, don't intercept let it flow through to keyDown so the input
// method can process it normally. Cmd-based shortcuts should still work
// during composition since Cmd is never part of IME input sequences.
// Let non-Cmd keys flow to keyDown while IME is composing; Cmd shortcuts still work.
if hasMarkedText(), !event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) {
return false
}
@@ -7467,25 +7727,19 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
keyTextAccumulator = []
defer { keyTextAccumulator = nil }
// Track whether we had marked text (IME preedit) before this event,
// so we can detect when composition ends.
let markedTextBefore = markedText.length > 0
let markedStateBefore = (markedText.string, markedSelectedRange)
// Capture the keyboard layout ID before interpretation so we can
// detect if an IME changed it (e.g. toggling input methods).
// We only check when not already in a preedit state.
let keyboardIdBefore: String? = if (!markedTextBefore) {
KeyboardLayout.id
} else {
nil
}
// Capture the keyboard layout ID before interpretation so the IME
// forwarding decision uses the source that saw this key.
let keyboardIdBefore = KeyboardLayout.id
// Let the input system handle the event (for IME, dead keys, etc.)
#if DEBUG
let interpretTimingStart = CmuxTypingTiming.start()
let interpretPhaseStart = ProcessInfo.processInfo.systemUptime
#endif
interpretKeyEvents([translationEvent])
let textInputHandledEvent = handleTextInputKeyEvent(translationEvent)
#if DEBUG
interpretMs = (ProcessInfo.processInfo.systemUptime - interpretPhaseStart) * 1000.0
CmuxTypingTiming.logDuration(
@@ -7497,7 +7751,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// If the keyboard layout changed, an input method grabbed the event.
// Sync preedit and return without sending the key to Ghostty.
if !markedTextBefore, let kbBefore = keyboardIdBefore, kbBefore != KeyboardLayout.id {
if !markedTextBefore, keyboardIdBefore != KeyboardLayout.id {
imeSuppressedKeyUpKeyCodes.insert(event.keyCode)
#if DEBUG
let syncPreeditStart = ProcessInfo.processInfo.systemUptime
#endif
@@ -7508,8 +7763,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return
}
// Sync the preedit state with Ghostty so it can render the IME
// composition overlay (e.g. for Korean, Japanese, Chinese input).
// Sync preedit so Ghostty can render the IME composition overlay.
#if DEBUG
let syncPreeditStart = ProcessInfo.processInfo.systemUptime
#endif
@@ -7518,6 +7772,23 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0
#endif
let accumulatedText = keyTextAccumulator ?? []
if shouldSuppressGhosttyKeyForwardingAfterIMEHandling(
before: markedStateBefore,
after: (markedText.string, markedSelectedRange),
accumulatedText: accumulatedText,
event: translationEvent,
textInputHandledEvent: textInputHandledEvent,
inputSourceId: keyboardIdBefore
) {
imeSuppressedKeyUpKeyCodes.insert(event.keyCode)
return
}
// A forwarded keyDown owns its keyUp. Clear any stale IME suppression
// entry left by an earlier suppressed repeat for the same physical key.
imeSuppressedKeyUpKeyCodes.remove(event.keyCode)
// Build the key event
var keyEvent = ghostty_input_key_s()
keyEvent.action = action
@@ -7527,16 +7798,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
keyEvent.consumed_mods = consumedModsFromFlags(translationMods)
keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event)
// We're composing if we have preedit (the obvious case). But we're also
// composing if we don't have preedit and we had marked text before,
// because this input probably just reset the preedit state. It shouldn't
// be encoded. Example: Japanese begin composing, then press backspace.
// This should only cancel the composing state but not actually delete
// the prior input characters (prior to the composing).
// Treat cleared preedit as composing too, so a composing Backspace cancels
// composition without deleting the preceding terminal input.
keyEvent.composing = markedText.length > 0 || markedTextBefore
// Use accumulated text from insertText (for IME), or compute text for key
let accumulatedText = keyTextAccumulator ?? []
var shouldRefreshAfterTextInput = false
if !accumulatedText.isEmpty {
// Accumulated text comes from insertText (IME composition result).
@@ -7703,6 +7969,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// Rendering is driven by Ghostty's wakeups/renderer.
}
@discardableResult
private func handleTextInputKeyEvent(_ event: NSEvent) -> Bool {
#if DEBUG
if let debugTextInputEventHandler = Self.debugTextInputEventHandler {
return debugTextInputEventHandler(self, event)
}
#endif
guard let inputContext else {
interpretKeyEvents([event])
return false
}
return inputContext.handleEvent(event)
}
@discardableResult
private func sendGhosttyKey(_ surface: ghostty_surface_t, _ keyEvent: ghostty_input_key_s) -> Bool {
#if DEBUG
@@ -7735,6 +8015,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
#endif
override func keyUp(with event: NSEvent) {
if imeSuppressedKeyUpKeyCodes.remove(event.keyCode) != nil {
return
}
guard let surface = ensureSurfaceReadyForInput() else {
super.keyUp(with: event)
return
@@ -9090,6 +9374,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
) {
case .insertText(let text):
return .insertText(text)
case .insertTextSegments(let segments, _):
return .insertText(segments.joined())
case .uploadFiles(let fileURLs, _):
return .uploadFiles(fileURLs)
case .reject:
@@ -9240,7 +9526,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
}
fileprivate func handleDroppedFileURLs(_ urls: [URL]) -> Bool {
func handleDroppedFileURLs(_ urls: [URL]) -> Bool {
executePreparedImageTransfer(
.fileURLs(urls),
onCancel: {}
@@ -9272,24 +9558,54 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
case .fileURLs(let fileURLs):
let plan = TerminalImageTransferPlanner.plan(
fileURLs: fileURLs,
target: resolvedImageTransferTarget()
target: resolvedImageTransferTarget(),
mode: .drop
)
return executeImageTransferPlan(plan, onCancel: onCancel)
}
}
#if DEBUG
fileprivate enum DebugDropPayloadKind {
case fileURLs
case imageData
}
@discardableResult
fileprivate func debugSimulateFileDrop(paths: [String]) -> Bool {
fileprivate func debugSimulateFileDrop(
paths: [String],
asImageData: Bool = false
) -> Bool {
guard !paths.isEmpty else { return false }
let urls = paths.map { URL(fileURLWithPath: $0) as NSURL }
let pbName = NSPasteboard.Name("cmux.debug.drop.\(UUID().uuidString)")
let pasteboard = NSPasteboard(name: pbName)
pasteboard.clearContents()
pasteboard.writeObjects(urls)
switch asImageData ? DebugDropPayloadKind.imageData : .fileURLs {
case .fileURLs:
let urls = paths.map { URL(fileURLWithPath: $0) as NSURL }
pasteboard.writeObjects(urls)
case .imageData:
let items = paths.compactMap { path -> NSPasteboardItem? in
let url = URL(fileURLWithPath: path)
guard let data = try? Data(contentsOf: url),
let type = debugImagePasteboardType(for: url) else { return nil }
let item = NSPasteboardItem()
item.setData(data, forType: type)
return item
}
guard items.count == paths.count else { return false }
pasteboard.writeObjects(items)
}
return insertDroppedPasteboard(pasteboard)
}
private func debugImagePasteboardType(for url: URL) -> NSPasteboard.PasteboardType? {
let pathExtension = url.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines)
guard let utType = UTType(filenameExtension: pathExtension),
utType.conforms(to: .image) else { return nil }
return NSPasteboard.PasteboardType(utType.identifier)
}
fileprivate func debugRegisteredDropTypes() -> [String] {
(registeredDraggedTypes ?? []).map(\.rawValue)
}
@@ -9678,6 +9994,10 @@ final class GhosttySurfaceScrollView: NSView {
}
init(surfaceView: GhosttyNSView) {
#if DEBUG
dispatchPrecondition(condition: .onQueue(.main))
#endif
self.surfaceView = surfaceView
backgroundView = NSView(frame: .zero)
scrollView = GhosttyScrollView()
@@ -10786,20 +11106,7 @@ final class GhosttySurfaceScrollView: NSView {
}
private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect {
let padding: CGFloat = 4
let localFrame: CGRect
switch zone {
case .center:
localFrame = CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2)
case .left:
localFrame = CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
case .right:
localFrame = CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
case .top:
localFrame = CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding)
case .bottom:
localFrame = CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding)
}
let localFrame = PaneDropRouting.compactOverlayFrame(for: zone, in: size)
let container = dropZoneOverlayView.superview ?? superview
guard let container, container !== self else { return localFrame }
@@ -11163,8 +11470,8 @@ final class GhosttySurfaceScrollView: NSView {
#if DEBUG
@discardableResult
func debugSimulateFileDrop(paths: [String]) -> Bool {
surfaceView.debugSimulateFileDrop(paths: paths)
func debugSimulateFileDrop(paths: [String], asImageData: Bool = false) -> Bool {
surfaceView.debugSimulateFileDrop(paths: paths, asImageData: asImageData)
}
func debugPendingSurfaceSize() -> CGSize? {
@@ -12724,7 +13031,13 @@ extension GhosttyNSView: NSTextInputClient {
}
func selectedRange() -> NSRange {
readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0)
if markedText.length > 0 {
#if DEBUG
assert(markedSelectedRange.location != NSNotFound, "markedSelectedRange must be valid")
#endif
return markedSelectedRange
}
return readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0)
}
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
@@ -12744,8 +13057,9 @@ extension GhosttyNSView: NSTextInputClient {
case let v as String:
markedText = NSMutableAttributedString(string: v)
default:
break
return
}
markedSelectedRange = normalizedMarkedSelectionRange(selectedRange, markedLength: markedText.length)
// If we're not in a keyDown event, sync preedit immediately.
// This can happen due to external events like changing keyboard layouts
@@ -12770,6 +13084,7 @@ extension GhosttyNSView: NSTextInputClient {
#endif
if markedText.length > 0 {
markedText.mutableString.setString("")
markedSelectedRange = NSRange(location: NSNotFound, length: 0)
syncPreedit()
invalidateTextInputCoordinates(selectionChanged: true)
}
@@ -12812,6 +13127,12 @@ extension GhosttyNSView: NSTextInputClient {
}
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
if markedText.length > 0 {
guard let substringRange = clampedMarkedTextRange(range, markedLength: markedText.length) else { return nil }
actualRange?.pointee = substringRange
return markedText.attributedSubstring(from: substringRange)
}
guard range.length > 0,
let snapshot = readSelectionSnapshot() else { return nil }
actualRange?.pointee = snapshot.range
+15
View File
@@ -29,3 +29,18 @@ func shouldAllowEnsureFocusWindowActivation(
return true
}
extension TerminalSurface {
func debugInitialCommand() -> String? {
initialCommand
}
func debugTmuxStartCommand() -> String? {
tmuxStartCommand
}
func debugInitialInputMetadata() -> (hasInitialInput: Bool, byteCount: Int) {
let byteCount = initialInput?.utf8.count ?? 0
return (byteCount > 0, byteCount)
}
}

Some files were not shown because too many files have changed in this diff Show More