diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index cd77ac231108..7dae51f6b35c 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -69,10 +69,6 @@ jobs: run: | ./scripts/install-zig-ci.sh - - name: Install Rust - run: | - ./scripts/install-rust-ci.sh - - name: Cache DerivedData uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21a2e60dff66..454194ce1505 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -200,10 +200,6 @@ jobs: run: | ./scripts/install-zig-ci.sh - - name: Install Rust - run: | - ./scripts/install-rust-ci.sh - - name: Cache Zig packages uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: @@ -409,10 +405,6 @@ jobs: run: | ./scripts/install-zig-ci.sh - - name: Install Rust - run: | - ./scripts/install-rust-ci.sh - - name: Cache Zig packages uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: @@ -596,10 +588,6 @@ jobs: run: | ./scripts/install-zig-ci.sh - - name: Install Rust - run: | - ./scripts/install-rust-ci.sh - - name: Cache Zig packages uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: @@ -670,10 +658,6 @@ jobs: run: | ./scripts/install-zig-ci.sh - - name: Install Rust - run: | - ./scripts/install-rust-ci.sh - - name: Cache Zig packages uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2d97259cf717..1fa921343529 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -153,7 +153,6 @@ jobs: - name: Install build deps if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' run: | - ./scripts/install-rust-ci.sh ./scripts/install-zig-ci.sh npm install --global "create-dmg@${CREATE_DMG_VERSION}" diff --git a/.github/workflows/perf-activation.yml b/.github/workflows/perf-activation.yml index fd2967833239..7a4130aadf0a 100644 --- a/.github/workflows/perf-activation.yml +++ b/.github/workflows/perf-activation.yml @@ -77,16 +77,12 @@ jobs: run: | ./scripts/install-zig-ci.sh - - name: Install Rust - run: | - ./scripts/install-rust-ci.sh - - name: Cache Swift packages uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .ci-source-packages - key: spm-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} - restore-keys: spm- + key: spm-activation-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm-activation- - name: Resolve Swift packages run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c842a3496972..8143b08a500c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,19 +111,6 @@ jobs: - name: Install build deps if: steps.guard_release_assets.outputs.skip_all != 'true' run: | - export PATH="$HOME/.cargo/bin:$PATH" - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - if ! command -v rustup >/dev/null 2>&1; then - export HOMEBREW_NO_AUTO_UPDATE=1 - export HOMEBREW_NO_INSTALL_CLEANUP=1 - export HOMEBREW_NO_ENV_HINTS=1 - brew install rustup-init - rustup-init -y --profile minimal --default-toolchain stable - fi - if ! rustup show active-toolchain >/dev/null 2>&1; then - rustup default stable - fi - rustup target add aarch64-apple-darwin x86_64-apple-darwin ./scripts/install-zig-ci.sh npm install --global "create-dmg@${CREATE_DMG_VERSION}" diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml index d81dd94bcb9e..a530bd69f6da 100644 --- a/.github/workflows/test-depot.yml +++ b/.github/workflows/test-depot.yml @@ -88,10 +88,6 @@ jobs: run: | ./scripts/install-zig-ci.sh - - name: Install Rust - run: | - ./scripts/install-rust-ci.sh - - name: Create virtual display run: | set -euo pipefail diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index bbf8aee9c0e4..a8cae9e0a262 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -96,10 +96,6 @@ jobs: run: | ./scripts/install-zig-ci.sh - - name: Install Rust - run: | - ./scripts/install-rust-ci.sh - - name: Install Bun for Feed sidebar tests if: ${{ inputs.test_filter == 'FeedSidebarUITests' || startsWith(inputs.test_filter, 'FeedSidebarUITests/') }} uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 diff --git a/CLI/cmux.swift b/CLI/cmux.swift index eefb3a698918..8d6d4cc3e206 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -14056,7 +14056,7 @@ struct CMUXCLI { var parts = ["process", pid, label] let attributionReason = topLabelText(process["attribution_reason"] as? String) if !attributionReason.isEmpty { - parts.append("[\(attributionReason)]") + parts.append("reason=\(attributionReason)") } return parts.joined(separator: " ") } @@ -21824,7 +21824,7 @@ struct CMUXCLI { let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "cmux") var tail = original.tail if tail.first == "codex-teams" { tail.removeFirst() } - argv = AgentLaunchSanitizer.preservedCodexForkArguments(args: tail).map { + argv = AgentLaunchSanitizer.preservedArguments(kind: "codex", args: tail).map { [original.executable, "codex-teams", "resume", normalizedSessionId] + $0 } case "omx", "omc": @@ -21850,7 +21850,7 @@ struct CMUXCLI { return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "claude", option: "--resume", sessionId: sessionId) case "codex": let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "codex") - return AgentLaunchSanitizer.preservedCodexForkArguments(args: original.tail).map { + return AgentLaunchSanitizer.preservedArguments(kind: "codex", args: original.tail).map { [original.executable, "resume", sessionId] + $0 } case "pi": @@ -28035,7 +28035,6 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { \(bold)\u{2318}\u{21E7}R\(reset)\(subdued) Rename workspace\(reset) \(bold)\u{2318}\u{21E7}L\(reset)\(subdued) New browser\(reset) \(bold)\u{2318}\u{21E7}U\(reset)\(subdued) Jump to latest unread\(reset) - \(bold)\u{2325}\u{2318}U\(reset)\(subdued) Toggle unread\(reset) """ print() @@ -28354,7 +28353,6 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { shortcuts disable-browser | enable-browser | browser-status restore-session - open ... [--workspace ] [--surface ] [--pane ] [--window ] [--focus ] [--no-focus] feedback [--email --body [--image ...]] feed tui|clear themes [list|set|clear] diff --git a/Native/CommandPaletteNucleoFFI/.gitignore b/Native/CommandPaletteNucleoFFI/.gitignore deleted file mode 100644 index 2f7896d1d136..000000000000 --- a/Native/CommandPaletteNucleoFFI/.gitignore +++ /dev/null @@ -1 +0,0 @@ -target/ diff --git a/Native/CommandPaletteNucleoFFI/Cargo.lock b/Native/CommandPaletteNucleoFFI/Cargo.lock deleted file mode 100644 index 629450175f6a..000000000000 --- a/Native/CommandPaletteNucleoFFI/Cargo.lock +++ /dev/null @@ -1,171 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cmux-command-palette-nucleo-ffi" -version = "0.1.0" -dependencies = [ - "nucleo", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "nucleo" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4" -dependencies = [ - "nucleo-matcher", - "parking_lot", - "rayon", -] - -[[package]] -name = "nucleo-matcher" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" -dependencies = [ - "memchr", - "unicode-segmentation", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" diff --git a/Native/CommandPaletteNucleoFFI/Cargo.toml b/Native/CommandPaletteNucleoFFI/Cargo.toml deleted file mode 100644 index b1c19321e810..000000000000 --- a/Native/CommandPaletteNucleoFFI/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "cmux-command-palette-nucleo-ffi" -version = "0.1.0" -edition = "2021" -publish = false - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nucleo = "0.5" - -[profile.dev] -panic = "abort" - -[profile.release] -panic = "abort" diff --git a/Native/CommandPaletteNucleoFFI/src/lib.rs b/Native/CommandPaletteNucleoFFI/src/lib.rs deleted file mode 100644 index d50ce2388937..000000000000 --- a/Native/CommandPaletteNucleoFFI/src/lib.rs +++ /dev/null @@ -1,601 +0,0 @@ -use std::cell::RefCell; -use std::cmp::Ordering; -use std::collections::BinaryHeap; -use std::slice; -use std::str; - -use nucleo::pattern::{AtomKind, CaseMatching, Normalization, Pattern}; -use nucleo::{Config, Matcher, Utf32Str}; - -#[repr(C)] -pub struct CmuxNucleoCandidateSpan { - title_offset: usize, - title_len: usize, - search_offset: usize, - search_len: usize, - rank: i32, -} - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct CmuxNucleoMatch { - index: usize, - score: f64, - rank: i32, -} - -struct Candidate { - title: String, - search_text: String, - search_lines: Vec, - rank: i32, - ascii_prefilter_safe: bool, - ascii_mask_low: u64, - ascii_mask_high: u64, - title_initials: Vec, - title_char_count: usize, -} - -struct ScoredCandidate { - index: usize, - score: f64, - rank: i32, -} - -struct InitialismQuery { - chars: Vec, -} - -struct SearchToken { - text: String, - pattern: Pattern, - initialism_query: Option, -} - -struct WorstFirstScoredCandidate(ScoredCandidate); - -impl PartialEq for WorstFirstScoredCandidate { - fn eq(&self, other: &Self) -> bool { - scored_candidate_order(&self.0, &other.0) == Ordering::Equal - } -} - -impl Eq for WorstFirstScoredCandidate {} - -impl PartialOrd for WorstFirstScoredCandidate { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for WorstFirstScoredCandidate { - fn cmp(&self, other: &Self) -> Ordering { - scored_candidate_order(&self.0, &other.0) - } -} - -struct SearchState { - matcher: Matcher, - utf32_buf: Vec, -} - -impl SearchState { - fn score_text(&mut self, pattern: &Pattern, text: &str) -> Option { - self.utf32_buf.clear(); - pattern.score(Utf32Str::new(text, &mut self.utf32_buf), &mut self.matcher) - } -} - -pub struct CmuxNucleoIndex { - candidates: Vec, -} - -thread_local! { - static SEARCH_STATE: RefCell = RefCell::new(SearchState { - matcher: Matcher::new(Config::DEFAULT), - utf32_buf: Vec::new(), - }); -} - -#[no_mangle] -pub extern "C" fn cmux_nucleo_ffi_version() -> u32 { - 2 -} - -#[no_mangle] -pub unsafe extern "C" fn cmux_nucleo_index_create( - blob_ptr: *const u8, - blob_len: usize, - spans_ptr: *const CmuxNucleoCandidateSpan, - span_count: usize, -) -> *mut CmuxNucleoIndex { - if blob_ptr.is_null() || spans_ptr.is_null() { - return std::ptr::null_mut(); - } - - let blob = slice::from_raw_parts(blob_ptr, blob_len); - let spans = slice::from_raw_parts(spans_ptr, span_count); - let mut candidates = Vec::with_capacity(span_count); - - for span in spans { - let Some(title) = text_from_blob(blob, span.title_offset, span.title_len) else { - return std::ptr::null_mut(); - }; - let Some(search_text) = text_from_blob(blob, span.search_offset, span.search_len) else { - return std::ptr::null_mut(); - }; - let ascii_prefilter_safe = title.is_ascii() && search_text.is_ascii(); - let (title_low, title_high) = ascii_mask(title); - let (search_low, search_high) = ascii_mask(search_text); - let title_initials = title_word_initials(title); - let title_char_count = title.chars().count(); - let search_lines = search_text.lines().map(str::to_owned).collect(); - candidates.push(Candidate { - title: title.to_owned(), - search_text: search_text.to_owned(), - search_lines, - rank: span.rank, - ascii_prefilter_safe, - ascii_mask_low: title_low | search_low, - ascii_mask_high: title_high | search_high, - title_initials, - title_char_count, - }); - } - - Box::into_raw(Box::new(CmuxNucleoIndex { candidates })) -} - -#[no_mangle] -pub unsafe extern "C" fn cmux_nucleo_index_destroy(index: *mut CmuxNucleoIndex) { - if !index.is_null() { - drop(Box::from_raw(index)); - } -} - -#[no_mangle] -pub unsafe extern "C" fn cmux_nucleo_index_search( - index: *mut CmuxNucleoIndex, - query_ptr: *const u8, - query_len: usize, - result_limit: usize, - out_matches: *mut CmuxNucleoMatch, - out_capacity: usize, - out_count: *mut usize, -) -> i32 { - cmux_nucleo_index_search_impl( - index, - query_ptr, - query_len, - result_limit, - std::ptr::null(), - 0, - out_matches, - out_capacity, - out_count, - ) -} - -#[no_mangle] -pub unsafe extern "C" fn cmux_nucleo_index_search_with_boosts( - index: *mut CmuxNucleoIndex, - query_ptr: *const u8, - query_len: usize, - result_limit: usize, - boosts_ptr: *const i32, - boosts_count: usize, - out_matches: *mut CmuxNucleoMatch, - out_capacity: usize, - out_count: *mut usize, -) -> i32 { - cmux_nucleo_index_search_impl( - index, - query_ptr, - query_len, - result_limit, - boosts_ptr, - boosts_count, - out_matches, - out_capacity, - out_count, - ) -} - -unsafe fn cmux_nucleo_index_search_impl( - index: *mut CmuxNucleoIndex, - query_ptr: *const u8, - query_len: usize, - result_limit: usize, - boosts_ptr: *const i32, - boosts_count: usize, - out_matches: *mut CmuxNucleoMatch, - out_capacity: usize, - out_count: *mut usize, -) -> i32 { - if index.is_null() || out_count.is_null() { - return -1; - } - *out_count = 0; - if result_limit == 0 || out_capacity == 0 { - return 0; - } - if out_matches.is_null() || (query_ptr.is_null() && query_len > 0) { - return -1; - } - - let index = &*index; - let query_bytes = if query_len == 0 { - &[] - } else { - slice::from_raw_parts(query_ptr, query_len) - }; - let Ok(query) = str::from_utf8(query_bytes) else { - return -2; - }; - let boosts = if boosts_ptr.is_null() && boosts_count == 0 { - None - } else if boosts_ptr.is_null() || boosts_count != index.candidates.len() { - return -3; - } else { - Some(slice::from_raw_parts(boosts_ptr, boosts_count)) - }; - - let normalized_query = query.split_whitespace().collect::>().join(" "); - let output_limit = result_limit.min(out_capacity); - let query_is_empty = normalized_query.is_empty(); - let output: Vec; - - if query_is_empty { - let mut best_matches = BinaryHeap::with_capacity(output_limit); - for (candidate_index, candidate) in index.candidates.iter().enumerate() { - append_scored_candidate( - ScoredCandidate { - index: candidate_index, - score: candidate_boost(boosts, candidate_index), - rank: candidate.rank, - }, - &mut best_matches, - output_limit, - ); - } - output = sorted_output(best_matches); - } else { - let query_mask = ascii_mask_query(&normalized_query); - let search_tokens = search_tokens(&normalized_query); - let mut best_matches = BinaryHeap::with_capacity(output_limit); - - SEARCH_STATE.with(|state| { - let mut state = state.borrow_mut(); - for (candidate_index, candidate) in index.candidates.iter().enumerate() { - if candidate.ascii_prefilter_safe { - if let Some((query_low, query_high)) = query_mask { - if query_low & !candidate.ascii_mask_low != 0 - || query_high & !candidate.ascii_mask_high != 0 - { - continue; - } - } - } - - let Some(score) = - weighted_query_score(&mut state, &normalized_query, &search_tokens, candidate) - else { - continue; - }; - append_scored_candidate( - ScoredCandidate { - index: candidate_index, - score: score + candidate_boost(boosts, candidate_index), - rank: candidate.rank, - }, - &mut best_matches, - output_limit, - ); - } - }); - - output = sorted_output(best_matches); - } - - let count = output.len(); - std::ptr::copy_nonoverlapping(output.as_ptr(), out_matches, count); - *out_count = count; - 0 -} - -fn candidate_boost(boosts: Option<&[i32]>, candidate_index: usize) -> f64 { - boosts - .and_then(|values| values.get(candidate_index)) - .map(|boost| f64::from(*boost)) - .unwrap_or(0.0) -} - -fn sorted_output(best_matches: BinaryHeap) -> Vec { - let mut scored: Vec = best_matches - .into_iter() - .map(|candidate| candidate.0) - .collect(); - scored.sort_by(scored_candidate_order); - scored - .into_iter() - .map(|candidate| CmuxNucleoMatch { - index: candidate.index, - score: candidate.score, - rank: candidate.rank, - }) - .collect() -} - -fn append_scored_candidate( - candidate: ScoredCandidate, - best_matches: &mut BinaryHeap, - output_limit: usize, -) { - if best_matches.len() < output_limit { - best_matches.push(WorstFirstScoredCandidate(candidate)); - return; - } - - let Some(worst_candidate) = best_matches.peek() else { - return; - }; - if scored_candidate_order(&candidate, &worst_candidate.0) != Ordering::Less { - return; - } - best_matches.pop(); - best_matches.push(WorstFirstScoredCandidate(candidate)); -} - -fn text_from_blob(blob: &[u8], offset: usize, len: usize) -> Option<&str> { - let end = offset.checked_add(len)?; - if end > blob.len() { - return None; - } - str::from_utf8(&blob[offset..end]).ok() -} - -fn search_tokens(query: &str) -> Vec { - query - .split_whitespace() - .map(|token| SearchToken { - text: token.to_owned(), - pattern: Pattern::new( - token, - CaseMatching::Ignore, - Normalization::Smart, - AtomKind::Fuzzy, - ), - initialism_query: initialism_query(token), - }) - .collect() -} - -fn weighted_query_score( - state: &mut SearchState, - query: &str, - tokens: &[SearchToken], - candidate: &Candidate, -) -> Option { - if tokens.len() == 1 { - return weighted_token_score(state, &tokens[0], candidate); - } - - let mut total_score = 0.0; - for token in tokens { - total_score += weighted_token_score(state, token, candidate)?; - } - - if let Some(exact_query_line_score) = exact_search_text_line_score(candidate, query) { - total_score = total_score.max(exact_query_line_score); - } - Some(total_score) -} - -fn weighted_token_score( - state: &mut SearchState, - token: &SearchToken, - candidate: &Candidate, -) -> Option { - let title_score = state.score_text(&token.pattern, &candidate.title); - let search_score = best_search_line_score(state, &token.pattern, candidate); - weighted_score( - &token.text, - token.initialism_query.as_ref(), - candidate, - title_score, - search_score, - ) -} - -fn best_search_line_score( - state: &mut SearchState, - pattern: &Pattern, - candidate: &Candidate, -) -> Option { - let mut best_score: Option = None; - for line in &candidate.search_lines { - if line.trim().is_empty() { - continue; - } - if let Some(score) = state.score_text(pattern, line) { - best_score = Some(best_score.map_or(score, |best| best.max(score))); - } - } - best_score -} - -fn weighted_score( - query: &str, - initialism_query: Option<&InitialismQuery>, - candidate: &Candidate, - title_score: Option, - search_score: Option, -) -> Option { - let initialism_score = - initialism_query.and_then(|query| title_initialism_score(query, candidate)); - let exact_search_text_score = exact_search_text_line_score(candidate, query); - match (title_score, search_score) { - (Some(title), Some(search)) => Some( - f64::from(search) - .max(f64::from(title) + 2_000.0) - .max(exact_search_text_score.unwrap_or(f64::NEG_INFINITY)) - .max(initialism_score.unwrap_or(f64::NEG_INFINITY)), - ), - (Some(title), None) => Some( - (f64::from(title) + 2_000.0) - .max(exact_search_text_score.unwrap_or(f64::NEG_INFINITY)) - .max(initialism_score.unwrap_or(f64::NEG_INFINITY)), - ), - (None, Some(search)) => Some( - f64::from(search) - .max(exact_search_text_score.unwrap_or(f64::NEG_INFINITY)) - .max(initialism_score.unwrap_or(f64::NEG_INFINITY)), - ), - (None, None) => exact_search_text_score.or(initialism_score), - } -} - -fn exact_search_text_line_score(candidate: &Candidate, query: &str) -> Option { - if query.is_empty() { - return None; - } - - let query_char_count = query.chars().count(); - let mut keyword_exact_score = None; - for line in candidate.search_text.lines() { - let trimmed = line.trim(); - if !trimmed.eq_ignore_ascii_case(query) { - continue; - } - if trimmed.eq_ignore_ascii_case(candidate.title.trim()) { - return Some(30_000.0 + f64::from(query_char_count as u32) * 10.0); - } - keyword_exact_score = Some(keyword_exact_line_score(query_char_count)); - } - keyword_exact_score -} - -fn keyword_exact_line_score(query_char_count: usize) -> f64 { - if query_char_count <= 3 { - return 30_000.0 + f64::from(query_char_count as u32) * 10.0; - } - 1_800.0 + f64::from(query_char_count as u32) * 10.0 -} - -fn initialism_query(query: &str) -> Option { - if query.bytes().any(|byte| byte.is_ascii_whitespace()) { - return None; - } - - let query_chars: Vec = query.chars().flat_map(char::to_lowercase).collect(); - if !(2..=8).contains(&query_chars.len()) || query_chars.iter().any(|c| !c.is_alphanumeric()) { - return None; - } - - Some(InitialismQuery { chars: query_chars }) -} - -fn title_initialism_score(query: &InitialismQuery, candidate: &Candidate) -> Option { - let title_initials = &candidate.title_initials; - if query.chars.len() > title_initials.len() { - return None; - } - - let mut next_word_index = 0; - let mut first_word: Option = None; - let mut last_word = 0; - let mut matched_count = 0; - - for query_char in &query.chars { - let mut found_word_index = None; - while next_word_index < title_initials.len() { - let word_index = next_word_index; - let word_char = title_initials[next_word_index]; - next_word_index += 1; - if word_char == *query_char { - found_word_index = Some(word_index); - break; - } - } - let word_index = found_word_index?; - first_word = first_word.or(Some(word_index)); - last_word = word_index; - matched_count += 1; - } - - let first_word = first_word.unwrap_or(0); - let skipped_before = first_word; - let skipped_between = last_word + 1 - matched_count - first_word; - let skipped_after = title_initials.len().saturating_sub(last_word + 1); - let exact_word_count_bonus = if query.chars.len() == title_initials.len() { - 1_200.0 - } else { - 0.0 - }; - - Some( - 14_000.0 + f64::from(query.chars.len() as u32) * 420.0 + exact_word_count_bonus - - f64::from(skipped_before as u32) * 260.0 - - f64::from(skipped_between as u32) * 180.0 - - f64::from(skipped_after as u32) * 80.0 - - f64::from(candidate.title_char_count as u32) * 2.0, - ) -} - -fn title_word_initials(title: &str) -> Vec { - let mut starts = Vec::new(); - let mut previous_was_boundary = true; - for character in title.chars() { - let is_boundary = is_title_word_boundary(character); - if !is_boundary && previous_was_boundary { - if let Some(lowercase) = character.to_lowercase().next() { - starts.push(lowercase); - } - } - previous_was_boundary = is_boundary; - } - starts -} - -fn is_title_word_boundary(character: char) -> bool { - matches!(character, ' ' | '-' | '_' | '/' | '\\' | '.' | ':') -} - -fn scored_candidate_order(lhs: &ScoredCandidate, rhs: &ScoredCandidate) -> Ordering { - rhs.score - .total_cmp(&lhs.score) - .then_with(|| lhs.rank.cmp(&rhs.rank)) - .then_with(|| lhs.index.cmp(&rhs.index)) -} - -fn ascii_mask_query(text: &str) -> Option<(u64, u64)> { - if !text.is_ascii() { - return None; - } - let mut low = 0; - let mut high = 0; - for byte in text.bytes() { - if byte.is_ascii_whitespace() { - continue; - } - set_ascii_mask_bit(byte.to_ascii_lowercase(), &mut low, &mut high); - } - Some((low, high)) -} - -fn ascii_mask(text: &str) -> (u64, u64) { - let mut low = 0; - let mut high = 0; - for byte in text.bytes().filter(|byte| byte.is_ascii()) { - set_ascii_mask_bit(byte.to_ascii_lowercase(), &mut low, &mut high); - } - (low, high) -} - -fn set_ascii_mask_bit(byte: u8, low: &mut u64, high: &mut u64) { - if byte < 64 { - *low |= 1_u64 << u64::from(byte); - } else if byte < 128 { - *high |= 1_u64 << u64::from(byte - 64); - } -} diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index 6e73408d6c0a..ff0f90b4c9d5 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -17,6 +17,7 @@ public enum AgentLaunchSanitizer { var droppedOptionPrefixes: [String] = [] var rejectOptions: Set = [] var resumeSubcommand: String? + var sessionSubcommands: Set = [] var preserveFirstPositional: Bool = false var skipClaudeHookSettings: Bool = false } @@ -233,7 +234,7 @@ public enum AgentLaunchSanitizer { var result: [String] = [] var index = 0 var consumedFirstPositional = false - var skippingResumePositionals = false + var sessionPositionalsToSkip = 0 while index < args.count { let arg = args[index] @@ -242,13 +243,14 @@ public enum AgentLaunchSanitizer { } if !arg.hasPrefix("-") || arg == "-" { - if let resumeSubcommand = policy.resumeSubcommand, arg == resumeSubcommand { - skippingResumePositionals = true + if isSessionSubcommandStart(args, index: index, policy: policy) { + let hasSessionID = index + 1 < args.count && !args[index + 1].hasPrefix("-") + sessionPositionalsToSkip = hasSessionID ? 1 : 0 index += 1 continue } - if skippingResumePositionals { - skippingResumePositionals = false + if sessionPositionalsToSkip > 0 { + sessionPositionalsToSkip -= 1 index += 1 continue } @@ -358,12 +360,7 @@ public enum AgentLaunchSanitizer { return runtimeOnlyOptionWidths[String(arg[.. = [] - ) -> Int { + private static func optionWidth(_ args: [String], index: Int, policy: Policy) -> Int { let arg = args[index] if arg.contains("=") { return 1 @@ -383,9 +380,10 @@ public enum AgentLaunchSanitizer { } if policy.variadicOptions.contains(arg) { var end = index + 1 - while end < args.count, - !args[end].hasPrefix("-"), - !stopVariadicAtPositionals.contains(args[end]) { + while end < args.count, !args[end].hasPrefix("-") { + if isSessionSubcommandStart(args, index: end, policy: policy) { + break + } end += 1 } return max(1, end - index) @@ -393,6 +391,21 @@ public enum AgentLaunchSanitizer { return 2 } + private static func isSessionSubcommandStart( + _ args: [String], + index: Int, + policy: Policy + ) -> Bool { + let arg = args[index] + if let resumeSubcommand = policy.resumeSubcommand, arg == resumeSubcommand { + return index + 1 < args.count && !args[index + 1].hasPrefix("-") + } + guard policy.sessionSubcommands.contains(arg) else { + return false + } + return index + 1 < args.count && !args[index + 1].hasPrefix("-") + } + private static func looksLikeOptionalValue(_ value: String, following: String?) -> Bool { guard !value.isEmpty, !value.hasPrefix("-"), diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift index a16f64053cf3..1e54eb7b814c 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift @@ -17,6 +17,7 @@ extension AgentLaunchSanitizer { "--effort", "--fallback-model", "--file", + "--fork-session", "--from-pr", "--input-format", "--json-schema", @@ -127,7 +128,8 @@ extension AgentLaunchSanitizer { ], variadicOptions: [ "--image", - "-i" + "-i", + "--add-dir" ], nonRestorableCommands: [ "exec", @@ -144,7 +146,6 @@ extension AgentLaunchSanitizer { "debug", "apply", "a", - "fork", "cloud", "exec-server", "features", @@ -162,7 +163,10 @@ extension AgentLaunchSanitizer { "--remote=", "--remote-auth-token-env=" ], - resumeSubcommand: "resume" + resumeSubcommand: "resume", + sessionSubcommands: [ + "fork" + ] ) static let grokPolicy = Policy( diff --git a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift index 7bc0851006a8..77750aa8e283 100644 --- a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift +++ b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift @@ -474,6 +474,91 @@ struct AgentLaunchSanitizerTests { ) } + @Test("Stops Codex variadic add-dir before resume subcommand") + func stopsCodexVariadicAddDirBeforeResumeSubcommand() { + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + [ + "codex", + "--add-dir", + "/tmp/shared", + "resume", + "019dad34-d218-7943-b81a-eddac5c87951", + "--model", + "gpt-5.4", + "--sandbox", + "danger-full-access", + ], + launcher: "codex", + fallbackKind: "codex" + ) == [ + "codex", + "--add-dir", + "/tmp/shared", + "--model", + "gpt-5.4", + "--sandbox", + "danger-full-access", + ] + ) + } + + @Test("Preserves Codex variadic add-dir literal resume value") + func preservesCodexVariadicAddDirLiteralResumeValue() { + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + [ + "codex", + "--add-dir", + "resume", + "--model", + "gpt-5.4", + "--sandbox", + "danger-full-access", + ], + launcher: "codex", + fallbackKind: "codex" + ) == [ + "codex", + "--add-dir", + "resume", + "--model", + "gpt-5.4", + "--sandbox", + "danger-full-access", + ] + ) + } + + @Test("Stops Codex variadic add-dir before fork session subcommand") + func stopsCodexVariadicAddDirBeforeForkSessionSubcommand() { + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + [ + "codex", + "--add-dir", + "/tmp/shared", + "fork", + "019dad34-d218-7943-b81a-eddac5c87951", + "--model", + "gpt-5.4", + "--sandbox", + "danger-full-access", + ], + launcher: "codex", + fallbackKind: "codex" + ) == [ + "codex", + "--add-dir", + "/tmp/shared", + "--model", + "gpt-5.4", + "--sandbox", + "danger-full-access", + ] + ) + } + @Test("Drops Pi session selectors and prompt while preserving configuration") func dropsPiSessionSelectorsAndPrompt() { #expect( diff --git a/README.md b/README.md index 2dd588da552d..d6fd4355a56e 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ 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 | -| ⌥ ⌘ B | Toggle right sidebar | +| ⌥ ⌘ B | Toggle right sidebar outside focused terminals | | ⌘ ⇧ E | Toggle right sidebar focus | ### Surfaces @@ -172,6 +172,7 @@ For more info on how to configure cmux, [head over to our docs](https://cmux.com | ⌃ 1–8 | Jump to surface 1–8 | | ⌃ 9 | Jump to last surface | | ⌘ W | Close surface | +| ⌥ ⌘ B | Toggle terminal sidekick in a focused terminal | ### Split Panes @@ -203,7 +204,6 @@ Command palette navigation shortcuts, including ⌃ P, are also customizable and |----------|--------| | ⌘ I | Show notifications panel | | ⌘ ⇧ U | Jump to latest unread | -| ⌥ ⌘ U | Toggle current item unread state | | ⌃ ⌘ U | Mark current item as oldest unread and jump to next latest unread | ### Find diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index db674105e604..ea19db15be01 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -53,6 +53,13 @@ "menu.view.toggleRightSidebar": { "localizations": {"ar":{"stringUnit":{"state":"translated","value":"تبديل الشريط الجانبي الأيمن"}},"bs":{"stringUnit":{"state":"translated","value":"Prebaci desnu bočnu traku"}},"da":{"stringUnit":{"state":"translated","value":"Slå højre sidebjælke til/fra"}},"de":{"stringUnit":{"state":"translated","value":"Rechte Seitenleiste ein-/ausblenden"}},"en":{"stringUnit":{"state":"translated","value":"Toggle Right Sidebar"}},"es":{"stringUnit":{"state":"translated","value":"Alternar barra lateral derecha"}},"fr":{"stringUnit":{"state":"translated","value":"Afficher/masquer la barre latérale droite"}},"it":{"stringUnit":{"state":"translated","value":"Attiva/Disattiva barra laterale destra"}},"ja":{"stringUnit":{"state":"translated","value":"右サイドバーを切り替え"}},"ko":{"stringUnit":{"state":"translated","value":"오른쪽 사이드바 전환"}},"nb":{"stringUnit":{"state":"translated","value":"Slå høyre sidepanel av/på"}},"pl":{"stringUnit":{"state":"translated","value":"Przełącz prawy pasek boczny"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Alternar barra lateral direita"}},"ru":{"stringUnit":{"state":"translated","value":"Переключить правую боковую панель"}},"th":{"stringUnit":{"state":"translated","value":"สลับแถบด้านข้างขวา"}},"tr":{"stringUnit":{"state":"translated","value":"Sağ Kenar Çubuğunu Aç/Kapat"}},"uk":{"stringUnit":{"state":"translated","value":"Перемкнути праву бічну панель"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"切换右侧边栏"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"切換右側邊欄"}}} }, "shortcut.toggleLeftSidebar.label": { "localizations": {"ar":{"stringUnit":{"state":"translated","value":"تبديل الشريط الجانبي الأيسر"}},"bs":{"stringUnit":{"state":"translated","value":"Prebaci lijevu bočnu traku"}},"da":{"stringUnit":{"state":"translated","value":"Slå venstre sidebjælke til/fra"}},"de":{"stringUnit":{"state":"translated","value":"Linke Seitenleiste ein-/ausblenden"}},"en":{"stringUnit":{"state":"translated","value":"Toggle Left Sidebar"}},"es":{"stringUnit":{"state":"translated","value":"Alternar barra lateral izquierda"}},"fr":{"stringUnit":{"state":"translated","value":"Afficher/masquer la barre latérale gauche"}},"it":{"stringUnit":{"state":"translated","value":"Attiva/Disattiva barra laterale sinistra"}},"ja":{"stringUnit":{"state":"translated","value":"左サイドバーを切り替え"}},"ko":{"stringUnit":{"state":"translated","value":"왼쪽 사이드바 전환"}},"nb":{"stringUnit":{"state":"translated","value":"Slå venstre sidepanel av/på"}},"pl":{"stringUnit":{"state":"translated","value":"Przełącz lewy pasek boczny"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Alternar barra lateral esquerda"}},"ru":{"stringUnit":{"state":"translated","value":"Переключить левую боковую панель"}},"th":{"stringUnit":{"state":"translated","value":"สลับแถบด้านข้างซ้าย"}},"tr":{"stringUnit":{"state":"translated","value":"Sol Kenar Çubuğunu Aç/Kapat"}},"uk":{"stringUnit":{"state":"translated","value":"Перемкнути ліву бічну панель"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"切换左侧边栏"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"切換左側邊欄"}}} }, "shortcut.toggleRightSidebar.label": { "localizations": {"ar":{"stringUnit":{"state":"translated","value":"تبديل الشريط الجانبي الأيمن"}},"bs":{"stringUnit":{"state":"translated","value":"Prebaci desnu bočnu traku"}},"da":{"stringUnit":{"state":"translated","value":"Slå højre sidebjælke til/fra"}},"de":{"stringUnit":{"state":"translated","value":"Rechte Seitenleiste ein-/ausblenden"}},"en":{"stringUnit":{"state":"translated","value":"Toggle Right Sidebar"}},"es":{"stringUnit":{"state":"translated","value":"Alternar barra lateral derecha"}},"fr":{"stringUnit":{"state":"translated","value":"Afficher/masquer la barre latérale droite"}},"it":{"stringUnit":{"state":"translated","value":"Attiva/Disattiva barra laterale destra"}},"ja":{"stringUnit":{"state":"translated","value":"右サイドバーを切り替え"}},"ko":{"stringUnit":{"state":"translated","value":"오른쪽 사이드바 전환"}},"nb":{"stringUnit":{"state":"translated","value":"Slå høyre sidepanel av/på"}},"pl":{"stringUnit":{"state":"translated","value":"Przełącz prawy pasek boczny"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Alternar barra lateral direita"}},"ru":{"stringUnit":{"state":"translated","value":"Переключить правую боковую панель"}},"th":{"stringUnit":{"state":"translated","value":"สลับแถบด้านข้างขวา"}},"tr":{"stringUnit":{"state":"translated","value":"Sağ Kenar Çubuğunu Aç/Kapat"}},"uk":{"stringUnit":{"state":"translated","value":"Перемкнути праву бічну панель"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"切换右侧边栏"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"切換右側邊欄"}}} }, + "shortcut.toggleTerminalSidekick.label": {"localizations":{"ar":{"stringUnit":{"state":"translated","value":"Toggle Terminal Sidekick"}},"bs":{"stringUnit":{"state":"translated","value":"Toggle Terminal Sidekick"}},"da":{"stringUnit":{"state":"translated","value":"Toggle Terminal Sidekick"}},"de":{"stringUnit":{"state":"translated","value":"Terminal-Sidekick ein-/ausblenden"}},"en":{"stringUnit":{"state":"translated","value":"Toggle Terminal Sidekick"}},"es":{"stringUnit":{"state":"translated","value":"Alternar Sidekick del terminal"}},"fr":{"stringUnit":{"state":"translated","value":"Afficher/masquer le sidekick du terminal"}},"it":{"stringUnit":{"state":"translated","value":"Attiva/Disattiva Sidekick del terminale"}},"ja":{"stringUnit":{"state":"translated","value":"ターミナルサイドキックを切り替え"}},"ko":{"stringUnit":{"state":"translated","value":"터미널 사이드킥 전환"}},"nb":{"stringUnit":{"state":"translated","value":"Slå terminal-sidekick av/på"}},"pl":{"stringUnit":{"state":"translated","value":"Toggle Terminal Sidekick"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Alternar Sidekick do terminal"}},"ru":{"stringUnit":{"state":"translated","value":"Переключить помощник терминала"}},"th":{"stringUnit":{"state":"translated","value":"Toggle Terminal Sidekick"}},"tr":{"stringUnit":{"state":"translated","value":"Toggle Terminal Sidekick"}},"uk":{"stringUnit":{"state":"translated","value":"Перемкнути помічник термінала"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"切换终端 Sidekick"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"切換終端 Sidekick"}}}}, + "terminalSidekick.accessibilityLabel": {"localizations":{"ar":{"stringUnit":{"state":"translated","value":"Terminal sidekick browser"}},"bs":{"stringUnit":{"state":"translated","value":"Terminal sidekick browser"}},"da":{"stringUnit":{"state":"translated","value":"Terminal sidekick browser"}},"de":{"stringUnit":{"state":"translated","value":"Terminal-Sidekick-Browser"}},"en":{"stringUnit":{"state":"translated","value":"Terminal sidekick browser"}},"es":{"stringUnit":{"state":"translated","value":"Navegador auxiliar del terminal"}},"fr":{"stringUnit":{"state":"translated","value":"Navigateur sidekick du terminal"}},"it":{"stringUnit":{"state":"translated","value":"Browser Sidekick del terminale"}},"ja":{"stringUnit":{"state":"translated","value":"ターミナルサイドキックブラウザ"}},"ko":{"stringUnit":{"state":"translated","value":"터미널 사이드킥 브라우저"}},"nb":{"stringUnit":{"state":"translated","value":"Terminal-sidekick-nettleser"}},"pl":{"stringUnit":{"state":"translated","value":"Terminal sidekick browser"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Navegador Sidekick do terminal"}},"ru":{"stringUnit":{"state":"translated","value":"Браузер-помощник терминала"}},"th":{"stringUnit":{"state":"translated","value":"Terminal sidekick browser"}},"tr":{"stringUnit":{"state":"translated","value":"Terminal sidekick browser"}},"uk":{"stringUnit":{"state":"translated","value":"Браузер-помічник термінала"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"终端 Sidekick 浏览器"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"終端 Sidekick 瀏覽器"}}}}, + "terminalSidekick.addressPlaceholder": {"localizations":{"ar":{"stringUnit":{"state":"translated","value":"Enter URL or search"}},"bs":{"stringUnit":{"state":"translated","value":"Enter URL or search"}},"da":{"stringUnit":{"state":"translated","value":"Enter URL or search"}},"de":{"stringUnit":{"state":"translated","value":"URL oder Suche eingeben"}},"en":{"stringUnit":{"state":"translated","value":"Enter URL or search"}},"es":{"stringUnit":{"state":"translated","value":"Introduce una URL o búsqueda"}},"fr":{"stringUnit":{"state":"translated","value":"Saisissez une URL ou une recherche"}},"it":{"stringUnit":{"state":"translated","value":"Inserisci URL o ricerca"}},"ja":{"stringUnit":{"state":"translated","value":"URLまたは検索語を入力"}},"ko":{"stringUnit":{"state":"translated","value":"URL 또는 검색어 입력"}},"nb":{"stringUnit":{"state":"translated","value":"Skriv inn URL eller søk"}},"pl":{"stringUnit":{"state":"translated","value":"Enter URL or search"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Digite uma URL ou pesquisa"}},"ru":{"stringUnit":{"state":"translated","value":"Введите URL или поисковый запрос"}},"th":{"stringUnit":{"state":"translated","value":"Enter URL or search"}},"tr":{"stringUnit":{"state":"translated","value":"Enter URL or search"}},"uk":{"stringUnit":{"state":"translated","value":"Введіть URL або пошуковий запит"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"输入 URL 或搜索内容"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"輸入 URL 或搜尋內容"}}}}, + "terminalSidekick.addressLabel": {"localizations":{"ar":{"stringUnit":{"state":"translated","value":"Sidekick address"}},"bs":{"stringUnit":{"state":"translated","value":"Sidekick address"}},"da":{"stringUnit":{"state":"translated","value":"Sidekick address"}},"de":{"stringUnit":{"state":"translated","value":"Sidekick-Adresse"}},"en":{"stringUnit":{"state":"translated","value":"Sidekick address"}},"es":{"stringUnit":{"state":"translated","value":"Dirección de Sidekick"}},"fr":{"stringUnit":{"state":"translated","value":"Adresse du sidekick"}},"it":{"stringUnit":{"state":"translated","value":"Indirizzo Sidekick"}},"ja":{"stringUnit":{"state":"translated","value":"サイドキックアドレス"}},"ko":{"stringUnit":{"state":"translated","value":"사이드킥 주소"}},"nb":{"stringUnit":{"state":"translated","value":"Sidekick-adresse"}},"pl":{"stringUnit":{"state":"translated","value":"Sidekick address"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Endereço do Sidekick"}},"ru":{"stringUnit":{"state":"translated","value":"Адрес помощника"}},"th":{"stringUnit":{"state":"translated","value":"Sidekick address"}},"tr":{"stringUnit":{"state":"translated","value":"Sidekick address"}},"uk":{"stringUnit":{"state":"translated","value":"Адреса помічника"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Sidekick 地址"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Sidekick 位址"}}}}, + "terminalSidekick.open": {"localizations":{"ar":{"stringUnit":{"state":"translated","value":"Open in Sidekick"}},"bs":{"stringUnit":{"state":"translated","value":"Open in Sidekick"}},"da":{"stringUnit":{"state":"translated","value":"Open in Sidekick"}},"de":{"stringUnit":{"state":"translated","value":"In Sidekick öffnen"}},"en":{"stringUnit":{"state":"translated","value":"Open in Sidekick"}},"es":{"stringUnit":{"state":"translated","value":"Abrir en Sidekick"}},"fr":{"stringUnit":{"state":"translated","value":"Ouvrir dans Sidekick"}},"it":{"stringUnit":{"state":"translated","value":"Apri in Sidekick"}},"ja":{"stringUnit":{"state":"translated","value":"サイドキックで開く"}},"ko":{"stringUnit":{"state":"translated","value":"사이드킥에서 열기"}},"nb":{"stringUnit":{"state":"translated","value":"Åpne i Sidekick"}},"pl":{"stringUnit":{"state":"translated","value":"Open in Sidekick"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Abrir no Sidekick"}},"ru":{"stringUnit":{"state":"translated","value":"Открыть в помощнике"}},"th":{"stringUnit":{"state":"translated","value":"Open in Sidekick"}},"tr":{"stringUnit":{"state":"translated","value":"Open in Sidekick"}},"uk":{"stringUnit":{"state":"translated","value":"Відкрити в помічнику"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"在 Sidekick 中打开"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"在 Sidekick 中開啟"}}}}, + "terminalSidekick.collapse": {"localizations":{"ar":{"stringUnit":{"state":"translated","value":"Collapse Sidekick"}},"bs":{"stringUnit":{"state":"translated","value":"Collapse Sidekick"}},"da":{"stringUnit":{"state":"translated","value":"Collapse Sidekick"}},"de":{"stringUnit":{"state":"translated","value":"Sidekick einklappen"}},"en":{"stringUnit":{"state":"translated","value":"Collapse Sidekick"}},"es":{"stringUnit":{"state":"translated","value":"Contraer Sidekick"}},"fr":{"stringUnit":{"state":"translated","value":"Réduire Sidekick"}},"it":{"stringUnit":{"state":"translated","value":"Comprimi Sidekick"}},"ja":{"stringUnit":{"state":"translated","value":"サイドキックを折りたたむ"}},"ko":{"stringUnit":{"state":"translated","value":"사이드킥 접기"}},"nb":{"stringUnit":{"state":"translated","value":"Skjul Sidekick"}},"pl":{"stringUnit":{"state":"translated","value":"Collapse Sidekick"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Recolher Sidekick"}},"ru":{"stringUnit":{"state":"translated","value":"Свернуть помощник"}},"th":{"stringUnit":{"state":"translated","value":"Collapse Sidekick"}},"tr":{"stringUnit":{"state":"translated","value":"Collapse Sidekick"}},"uk":{"stringUnit":{"state":"translated","value":"Згорнути помічника"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"折叠 Sidekick"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"收合 Sidekick"}}}}, + "terminalSidekick.resizeDivider": {"localizations":{"ar":{"stringUnit":{"state":"translated","value":"Resize Sidekick"}},"bs":{"stringUnit":{"state":"translated","value":"Resize Sidekick"}},"da":{"stringUnit":{"state":"translated","value":"Resize Sidekick"}},"de":{"stringUnit":{"state":"translated","value":"Sidekick-Größe ändern"}},"en":{"stringUnit":{"state":"translated","value":"Resize Sidekick"}},"es":{"stringUnit":{"state":"translated","value":"Cambiar tamaño de Sidekick"}},"fr":{"stringUnit":{"state":"translated","value":"Redimensionner Sidekick"}},"it":{"stringUnit":{"state":"translated","value":"Ridimensiona Sidekick"}},"ja":{"stringUnit":{"state":"translated","value":"サイドキックのサイズを変更"}},"ko":{"stringUnit":{"state":"translated","value":"사이드킥 크기 조절"}},"nb":{"stringUnit":{"state":"translated","value":"Endre størrelse på Sidekick"}},"pl":{"stringUnit":{"state":"translated","value":"Resize Sidekick"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Redimensionar Sidekick"}},"ru":{"stringUnit":{"state":"translated","value":"Изменить размер помощника"}},"th":{"stringUnit":{"state":"translated","value":"Resize Sidekick"}},"tr":{"stringUnit":{"state":"translated","value":"Resize Sidekick"}},"uk":{"stringUnit":{"state":"translated","value":"Змінити розмір помічника"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"调整 Sidekick 大小"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"調整 Sidekick 大小"}}}}, "shortcut.commandPaletteNext.label": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ja": { "stringUnit": { "state": "translated", "value": "コマンドパレット: 次へ" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ko": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "de": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "es": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "fr": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "it": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "da": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "pl": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ru": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "bs": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ar": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "nb": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "th": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "tr": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "uk": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } } } }, "shortcut.commandPalettePrevious.label": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ja": { "stringUnit": { "state": "translated", "value": "コマンドパレット: 前へ" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ko": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "de": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "es": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "fr": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "it": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "da": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "pl": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ru": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "bs": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ar": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "nb": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "th": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "tr": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "uk": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } } } }, "settings.shortcuts.managedByFile": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ja": { "stringUnit": { "state": "translated", "value": "cmux.json で管理" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ko": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "de": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "es": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "fr": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "it": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "da": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "pl": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ru": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "bs": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ar": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "nb": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "th": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "tr": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "uk": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } } } }, @@ -12990,125 +12997,6 @@ } } }, - "browser.externalOpenFailure.copyLink": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Copy Link" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リンクをコピー" - } - } - } - }, - "browser.externalOpenFailure.message": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "cmux could not open this link. You can copy it and open it in another app." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "cmux はこのリンクを開けませんでした。リンクをコピーして別のアプリで開けます。" - } - } - } - }, - "browser.externalOpenFailure.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cannot Open Link" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リンクを開けません" - } - } - } - }, - "browser.externalOpenPrompt.message": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "A web page in cmux wants to open a link in another app. You can stay in the browser instead." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "cmux のウェブページが別のアプリでリンクを開こうとしています。代わりにブラウザに留まることもできます。" - } - } - } - }, - "browser.externalOpenPrompt.openApp": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open App" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリを開く" - } - } - } - }, - "browser.externalOpenPrompt.stayInBrowser": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Stay in Browser" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ブラウザに留まる" - } - } - } - }, - "browser.externalOpenPrompt.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open External App?" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "外部アプリを開きますか?" - } - } - } - }, "browser.error.insecure.message": { "extractionState": "manual", "localizations": { @@ -22861,6 +22749,7 @@ } } }, + "command.supportedFileRouting.subtitle": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"File Preview"}},"bs":{"stringUnit":{"state":"translated","value":"File Preview"}},"da":{"stringUnit":{"state":"translated","value":"File Preview"}},"de":{"stringUnit":{"state":"translated","value":"Dateivorschau"}},"en":{"stringUnit":{"state":"translated","value":"File Preview"}},"es":{"stringUnit":{"state":"translated","value":"Vista previa de archivo"}},"fr":{"stringUnit":{"state":"translated","value":"Aperçu du fichier"}},"it":{"stringUnit":{"state":"translated","value":"Anteprima file"}},"ja":{"stringUnit":{"state":"translated","value":"ファイルプレビュー"}},"ko":{"stringUnit":{"state":"translated","value":"파일 미리보기"}},"nb":{"stringUnit":{"state":"translated","value":"Filforhåndsvisning"}},"pl":{"stringUnit":{"state":"translated","value":"File Preview"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Prévia do arquivo"}},"ru":{"stringUnit":{"state":"translated","value":"Предпросмотр файла"}},"th":{"stringUnit":{"state":"translated","value":"File Preview"}},"tr":{"stringUnit":{"state":"translated","value":"File Preview"}},"uk":{"stringUnit":{"state":"translated","value":"Попередній перегляд файлу"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"文件预览"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"檔案預覽"}}}}, "command.disableBrowser.title": { "extractionState": "manual", "localizations": { @@ -22878,6 +22767,7 @@ } } }, + "command.disableSupportedFileRouting.title": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Disable Cmd-click File Previews"}},"bs":{"stringUnit":{"state":"translated","value":"Disable Cmd-click File Previews"}},"da":{"stringUnit":{"state":"translated","value":"Disable Cmd-click File Previews"}},"de":{"stringUnit":{"state":"translated","value":"Cmd-Klick-Dateivorschau deaktivieren"}},"en":{"stringUnit":{"state":"translated","value":"Disable Cmd-click File Previews"}},"es":{"stringUnit":{"state":"translated","value":"Desactivar vistas previas con Cmd-clic"}},"fr":{"stringUnit":{"state":"translated","value":"Désactiver les aperçus de fichiers Cmd-clic"}},"it":{"stringUnit":{"state":"translated","value":"Disattiva anteprime file con Cmd-clic"}},"ja":{"stringUnit":{"state":"translated","value":"Cmd+クリックのファイルプレビューを無効にする"}},"ko":{"stringUnit":{"state":"translated","value":"Cmd-클릭 파일 미리보기 비활성화"}},"nb":{"stringUnit":{"state":"translated","value":"Slå av filforhåndsvisning med Cmd-klikk"}},"pl":{"stringUnit":{"state":"translated","value":"Disable Cmd-click File Previews"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Desativar prévias de arquivo com Cmd-clique"}},"ru":{"stringUnit":{"state":"translated","value":"Отключить предпросмотр файлов по Cmd-клику"}},"th":{"stringUnit":{"state":"translated","value":"Disable Cmd-click File Previews"}},"tr":{"stringUnit":{"state":"translated","value":"Disable Cmd-click File Previews"}},"uk":{"stringUnit":{"state":"translated","value":"Вимкнути попередній перегляд файлів за Cmd-кліком"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"停用 Cmd 点按文件预览"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"停用 Cmd 點按檔案預覽"}}}}, "command.disableMinimalMode.title": { "extractionState": "manual", "localizations": { @@ -22924,6 +22814,7 @@ } } }, + "command.enableSupportedFileRouting.title": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Enable Cmd-click File Previews"}},"bs":{"stringUnit":{"state":"translated","value":"Enable Cmd-click File Previews"}},"da":{"stringUnit":{"state":"translated","value":"Enable Cmd-click File Previews"}},"de":{"stringUnit":{"state":"translated","value":"Cmd-Klick-Dateivorschau aktivieren"}},"en":{"stringUnit":{"state":"translated","value":"Enable Cmd-click File Previews"}},"es":{"stringUnit":{"state":"translated","value":"Activar vistas previas con Cmd-clic"}},"fr":{"stringUnit":{"state":"translated","value":"Activer les aperçus de fichiers Cmd-clic"}},"it":{"stringUnit":{"state":"translated","value":"Attiva anteprime file con Cmd-clic"}},"ja":{"stringUnit":{"state":"translated","value":"Cmd+クリックのファイルプレビューを有効にする"}},"ko":{"stringUnit":{"state":"translated","value":"Cmd-클릭 파일 미리보기 활성화"}},"nb":{"stringUnit":{"state":"translated","value":"Slå på filforhåndsvisning med Cmd-klikk"}},"pl":{"stringUnit":{"state":"translated","value":"Enable Cmd-click File Previews"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Ativar prévias de arquivo com Cmd-clique"}},"ru":{"stringUnit":{"state":"translated","value":"Включить предпросмотр файлов по Cmd-клику"}},"th":{"stringUnit":{"state":"translated","value":"Enable Cmd-click File Previews"}},"tr":{"stringUnit":{"state":"translated","value":"Enable Cmd-click File Previews"}},"uk":{"stringUnit":{"state":"translated","value":"Увімкнути попередній перегляд файлів за Cmd-кліком"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"启用 Cmd 点按文件预览"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"啟用 Cmd 點按檔案預覽"}}}}, "command.installCLI.subtitle": { "extractionState": "manual", "localizations": { @@ -23400,23 +23291,6 @@ } } }, - "command.toggleUnread.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Toggle Unread" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "未読を切り替え" - } - } - } - }, "command.markOldestUnreadAndJumpNext.title": { "extractionState": "manual", "localizations": { @@ -28393,91 +28267,6 @@ } } }, - "command.forkAgentConversationRight.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Fork Conversation to the Right" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "会話を右にフォーク" - } - } - } - }, - "command.forkAgentConversationLeft.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Fork Conversation to the Left" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "会話を左にフォーク" - } - } - } - }, - "command.forkAgentConversationTop.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Fork Conversation to the Top" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "会話を上にフォーク" - } - } - } - }, - "command.forkAgentConversationBottom.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Fork Conversation to the Bottom" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "会話を下にフォーク" - } - } - } - }, - "command.forkAgentConversationNewWorkspace.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Fork Conversation to New Workspace" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "会話を新規ワークスペースにフォーク" - } - } - } - }, "command.terminalSplitDown.subtitle": { "extractionState": "manual", "localizations": { @@ -46123,23 +45912,6 @@ } } }, - "menu.notifications.toggleUnread": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Toggle Unread" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "未読を切り替え" - } - } - } - }, "menu.notifications.markAllRead": { "extractionState": "manual", "localizations": { @@ -85263,23 +85035,6 @@ } } }, - "shortcut.toggleUnread.label": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Toggle Unread" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "未読を切り替え" - } - } - } - }, "shortcut.markOldestUnreadAndJumpNext.label": { "extractionState": "manual", "localizations": { diff --git a/Sources/App/MenuBarExtraController.swift b/Sources/App/MenuBarExtraController.swift index d12cfe807321..9e1c8889b75a 100644 --- a/Sources/App/MenuBarExtraController.swift +++ b/Sources/App/MenuBarExtraController.swift @@ -332,19 +332,18 @@ enum NotificationMenuSnapshotBuilder { static func make( notifications: [TerminalNotification], - workspaceUnreadIndicatorCount: Int = 0, maxInlineNotificationItems: Int = defaultInlineNotificationLimit ) -> NotificationMenuSnapshot { let unreadCount = notifications.reduce(into: 0) { count, notification in if !notification.isRead { count += 1 } - } + workspaceUnreadIndicatorCount + } let inlineLimit = max(0, maxInlineNotificationItems) return NotificationMenuSnapshot( unreadCount: unreadCount, - hasNotifications: !notifications.isEmpty || workspaceUnreadIndicatorCount > 0, + hasNotifications: !notifications.isEmpty, recentNotifications: Array(notifications.prefix(inlineLimit)) ) } diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index 62969f121c14..d202b8e143c4 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -2,14 +2,15 @@ import AppKit import Bonsplit import Foundation -func browserOmnibarSelectionDeltaForControlNavigation( +func browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: Bool, flags: NSEvent.ModifierFlags, chars: String ) -> Int? { guard hasFocusedAddressBar else { return nil } let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) - guard normalizedFlags == [.control] else { return nil } + let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] + guard isCommandOrControlOnly else { return nil } if chars == "n" { return 1 } if chars == "p" { return -1 } return nil @@ -46,7 +47,8 @@ func browserOmnibarNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NS } func browserOmnibarShouldContinueControlNavigationRepeat(flags: NSEvent.ModifierFlags) -> Bool { - browserOmnibarNormalizedModifierFlags(flags) == [.control] + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + return normalizedFlags == [.control] || normalizedFlags == [.command] } func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { @@ -119,31 +121,6 @@ func shouldDispatchBrowserOmnibarArrowViaFirstResponderKeyDown( return normalizedFlags.isEmpty } -struct BrowserAddressBarTrackingContext { - let trackedPanelMatchesWebView: Bool - let omnibarResponderActive: Bool - let preferredFocusIntentIsAddressBar: Bool - let suppressesWebViewFocus: Bool - let pointerInitiatedWebFocus: Bool - let liveOmnibarFieldExists: Bool -} - -/// Decision order: -/// 1. Reject WebView focus from another panel. -/// 2. Preserve if an omnibar responder is already active. -/// 3. Require address-bar focus intent. -/// 4. Let pointer-initiated WebView focus clear tracking. -/// 5. Preserve if WebView focus is suppressed or a live omnibar field exists. -func shouldPreserveBrowserAddressBarTrackingDuringWebViewFocus( - _ context: BrowserAddressBarTrackingContext -) -> Bool { - guard context.trackedPanelMatchesWebView else { return false } - if context.omnibarResponderActive { return true } - guard context.preferredFocusIntentIsAddressBar else { return false } - guard !context.pointerInitiatedWebFocus else { return false } - return context.suppressesWebViewFocus || context.liveOmnibarFieldExists -} - func shouldDispatchCommandPaletteHorizontalArrowViaFirstResponderKeyDown( keyCode: UInt16, firstResponderIsCommandPaletteFieldEditor: Bool, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 438df7b3d609..8a51b4e2c9d3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -734,9 +734,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var browserAddressBarFocusedPanelId: UUID? private var browserOmnibarRepeatStartWorkItem: DispatchWorkItem? private var browserOmnibarRepeatTickWorkItem: DispatchWorkItem? - private var browserOmnibarRepeatPanelId: UUID? private var browserOmnibarRepeatKeyCode: UInt16? private var browserOmnibarRepeatDelta: Int = 0 + private var browserOmnibarRepeatModifierFlags: NSEvent.ModifierFlags = [] private var browserAddressBarFocusObserver: NSObjectProtocol? private var browserAddressBarBlurObserver: NSObjectProtocol? private var browserWebViewFirstResponderObserver: NSObjectProtocol? @@ -2118,7 +2118,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return workspace.panels.values .compactMap { $0 as? TerminalPanel } .first { panel in - panel.surface.isViewInWindow && + panel.hostedView.window != nil && panel.hostedView.debugPortalVisibleInUI && !panel.hostedView.debugPortalFrameInWindow.isEmpty } @@ -2252,7 +2252,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let currentTabManager = self.tabManager let workspace = currentTabManager?.selectedWorkspace ?? currentTabManager?.tabs.first let terminalPanel = cmdClickUITestTerminalPanel(in: workspace) - let mainWindow = terminalPanel?.surface.uiWindow + let mainWindow = terminalPanel?.hostedView.window ?? currentTabManager.flatMap { self.windowId(for: $0).flatMap { self.mainWindow(for: $0) } } if Date() >= deadline { let textSnapshot = terminalPanel @@ -2355,7 +2355,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let terminalFrame = terminalPanel.hostedView.debugPortalFrameInWindow let terminalReady = terminalPanel.surface.surface != nil - let terminalVisible = terminalPanel.surface.isViewInWindow && + let terminalVisible = terminalPanel.hostedView.window != nil && terminalPanel.hostedView.debugPortalVisibleInUI && !terminalFrame.isEmpty && terminalFrame.width > 0 && @@ -5973,6 +5973,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return preferredRegisteredMainWindowContext() } + private func terminalSidekickShortcutTarget( + preferredWindow: NSWindow? = nil + ) -> (context: MainWindowContext, terminalPanel: TerminalPanel)? { + guard let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow), + let terminalPanel = context.tabManager.selectedWorkspace?.focusedTerminalPanel else { + return nil + } + + guard BrowserAvailabilitySettings.isEnabled() else { return nil } + + return (context, terminalPanel) + } + + func canToggleTerminalSidekickInActiveMainWindow(preferredWindow: NSWindow? = nil) -> Bool { + terminalSidekickShortcutTarget(preferredWindow: preferredWindow) != nil + } + + @discardableResult + func toggleTerminalSidekickInActiveMainWindow(preferredWindow: NSWindow? = nil) -> Bool { + guard let target = terminalSidekickShortcutTarget(preferredWindow: preferredWindow) else { + return false + } + + let window = target.context.window ?? windowForMainWindowId(target.context.windowId) + if let window { + setActiveMainWindow(window) + } + + return target.terminalPanel.toggleSidekick() + } + @discardableResult func closeRightSidebarInActiveMainWindow(preferredWindow: NSWindow? = nil) -> Bool { guard let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow) else { @@ -8751,7 +8782,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let hostedView = terminalPanel.hostedView let shouldReconcileVisibleSelection = target.workspace.id == selectedWorkspaceId && - terminalPanel.surface.isViewInWindow && + hostedView.window != nil && hostedView.superview != nil if shouldReconcileVisibleSelection { @@ -10925,7 +10956,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return notificationStore.notifications.first(where: { $0.id == notification.id }) ?? notification } } - _ = openLatestWorkspaceUnread() return nil } @@ -11026,13 +11056,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let target = focusedNotificationTarget(preferredWindow: preferredWindow) else { return nil } - return markFocusedNotificationAsOldestUnread(target: target, notificationStore: notificationStore) - } - - private func markFocusedNotificationAsOldestUnread( - target: FocusedNotificationTarget, - notificationStore: TerminalNotificationStore - ) -> FocusedNotificationMarkResult? { if let notificationId = notificationStore.markLatestNotificationAsOldestUnread( forTabId: target.tabId, surfaceId: target.surfaceId @@ -11735,8 +11758,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent stopBrowserOmnibarSelectionRepeat() } - let focusedAddressBarPanelIdInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) - let hasFocusedAddressBarInShortcutContext = focusedAddressBarPanelIdInShortcutContext != nil + let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil if shouldRouteConfiguredPaletteSelection, activeConfiguredShortcutChordPrefixForCurrentEvent == nil, armConfiguredShortcutChordIfNeeded(event: event, actions: [.commandPaletteNext, .commandPalettePrevious]) { return true @@ -11852,18 +11874,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Ctrl+D belongs to the focused terminal surface; never treat it as an app shortcut. return false } - // Chrome-like omnibar navigation while holding Ctrl+N / Ctrl+P. - if let delta = controlOmnibarSelectionDelta( + // Chrome-like omnibar navigation while holding Cmd+N / Ctrl+N / Cmd+P / Ctrl+P. + if let delta = commandOmnibarSelectionDelta( hasFocusedAddressBar: hasFocusedAddressBarInShortcutContext, flags: flags, chars: chars - ), - let focusedAddressBarPanelIdInShortcutContext { - dispatchBrowserOmnibarSelectionMove(panelId: focusedAddressBarPanelIdInShortcutContext, delta: delta) + ) { + dispatchBrowserOmnibarSelectionMove(delta: delta) startBrowserOmnibarSelectionRepeatIfNeeded( - panelId: focusedAddressBarPanelIdInShortcutContext, keyCode: event.keyCode, - delta: delta + delta: delta, + flags: flags ) return true } @@ -11872,9 +11893,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent hasFocusedAddressBar: hasFocusedAddressBarInShortcutContext, flags: event.modifierFlags, keyCode: event.keyCode - ), - let focusedAddressBarPanelIdInShortcutContext { - dispatchBrowserOmnibarSelectionMove(panelId: focusedAddressBarPanelIdInShortcutContext, delta: delta) + ) { + dispatchBrowserOmnibarSelectionMove(delta: delta) return true } @@ -11985,6 +12005,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchConfiguredShortcut(event: event, action: .toggleTerminalSidekick) { + let preferredWindow = mainWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + if canToggleTerminalSidekickInActiveMainWindow(preferredWindow: preferredWindow) { + // Escape AppKit's performKeyEquivalent animation context before resizing + // the terminal portal and sidekick WKWebView host. + Task { @MainActor [weak self, weak preferredWindow] in + _ = self?.toggleTerminalSidekickInActiveMainWindow(preferredWindow: preferredWindow) + } + return true + } + } + if matchConfiguredShortcut(event: event, action: .toggleRightSidebar) { // Escape AppKit's performKeyEquivalent animation context. Without // deferring the toggle, NSAnimationContext implicitly animates the @@ -12044,13 +12076,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchConfiguredShortcut(event: event, action: .toggleUnread) { - toggleFocusedNotificationUnread( - preferredWindow: mainWindowForShortcutEvent(event) - ) - return true - } - if matchConfiguredShortcut(event: event, action: .markOldestUnreadAndJumpNext) { markFocusedNotificationAsOldestUnreadAndJumpToNextLatestUnread( preferredWindow: mainWindowForShortcutEvent(event) @@ -12514,7 +12539,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let hostedView = terminalPanel.hostedView let hostedSize = hostedView.bounds.size let hostedHiddenInHierarchy = hostedView.isHiddenOrHasHiddenAncestor - let hostedAttachedToWindow = terminalPanel.surface.isViewInWindow + let hostedAttachedToWindow = hostedView.window != nil let firstResponderIsWindow = NSApp.keyWindow?.firstResponder is NSWindow let shouldSuppress = shouldSuppressSplitShortcutForTransientTerminalFocusInputs( @@ -12704,11 +12729,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent browserAddressBarFocusedPanelId } - func focusedBrowserOmnibarField(for event: NSEvent, in window: NSWindow?) -> OmnibarNativeTextField? { - let panelId = focusedBrowserAddressBarPanelIdForShortcutEvent(event) - return browserOmnibarField(panelId: panelId, in: window) - } - func clearBrowserAddressBarFocus(panelId: UUID, reason: String) { guard browserAddressBarFocusedPanelId == panelId else { return } browserAddressBarFocusedPanelId = nil @@ -12719,27 +12739,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { - let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow - let shortcutResponder = shortcutWindow?.firstResponder - let responderPanelId = isBrowserOmnibarResponder(shortcutResponder) - ? browserOmnibarPanelId(for: shortcutResponder) - : nil + guard let panelId = browserAddressBarFocusedPanelId else { return nil } guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { #if DEBUG - let candidatePanelId = responderPanelId ?? browserAddressBarFocusedPanelId - guard let candidatePanelId else { return nil } cmuxDebugLog( - "browser.focus.addressBar.shortcutContext panel=\(candidatePanelId.uuidString.prefix(5)) " + + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + "accepted=0 reason=no_context event=\(NSWindow.keyDescription(event))" ) #endif return nil } - let intentPanelId = browserAddressBarIntentPanelId(in: context, window: shortcutWindow) - guard let panelId = responderPanelId ?? browserAddressBarFocusedPanelId ?? intentPanelId else { return nil } - guard let workspace = context.tabManager.selectedWorkspace else { #if DEBUG cmuxDebugLog( @@ -12761,144 +12772,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } - if let responderPanelId { -#if DEBUG - cmuxDebugLog( - "browser.focus.addressBar.shortcutContext panel=\(responderPanelId.uuidString.prefix(5)) " + - "accepted=1 reason=omnibar_responder workspace=\(workspace.id.uuidString.prefix(5)) " + - "event=\(NSWindow.keyDescription(event))" - ) -#endif - return responderPanelId - } - - if intentPanelId == panelId, browserAddressBarFocusedPanelId == nil { -#if DEBUG - cmuxDebugLog( - "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=1 reason=addressbar_intent workspace=\(workspace.id.uuidString.prefix(5)) " + - "event=\(NSWindow.keyDescription(event))" - ) -#endif - return panelId - } + let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let shortcutResponder = shortcutWindow?.firstResponder - let liveOmnibarFieldExists = browserOmnibarField(panelId: panelId, in: shortcutWindow) != nil - let trackedPanelMatchesShortcutResponder = browserPanel(panel, ownsShortcutResponder: shortcutResponder, in: shortcutWindow) - let trackingContext = BrowserAddressBarTrackingContext( - trackedPanelMatchesWebView: trackedPanelMatchesShortcutResponder, - omnibarResponderActive: false, - preferredFocusIntentIsAddressBar: panel.preferredFocusIntent == .addressBar, - suppressesWebViewFocus: panel.shouldSuppressWebViewFocus(), - pointerInitiatedWebFocus: false, - liveOmnibarFieldExists: liveOmnibarFieldExists - ) - if shouldPreserveBrowserAddressBarTrackingDuringWebViewFocus(trackingContext) { + guard isBrowserOmnibarResponder(shortcutResponder) else { #if DEBUG + let focusedPanel = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" cmuxDebugLog( "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=1 reason=tracked_omnibar_field workspace=\(workspace.id.uuidString.prefix(5)) " + + "accepted=0 reason=responder_not_omnibar responder=\(shortcutResponder.map { String(describing: type(of: $0)) } ?? "nil") " + + "pending=\(panel.pendingAddressBarFocusRequestId != nil ? 1 : 0) focusedPanel=\(focusedPanel) " + "event=\(NSWindow.keyDescription(event))" ) #endif - return panelId + return nil } - if shouldPreserveBrowserAddressBarTrackingDuringTransientShortcutResponder( - for: panel, - responder: shortcutResponder, - in: shortcutWindow, - liveOmnibarFieldExists: liveOmnibarFieldExists - ) { + guard browserOmnibarPanelId(for: shortcutResponder) == panelId else { #if DEBUG + let responderPanel = browserOmnibarPanelId(for: shortcutResponder) + .map { String($0.uuidString.prefix(5)) } ?? "nil" cmuxDebugLog( "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=1 reason=transient_omnibar_focus workspace=\(workspace.id.uuidString.prefix(5)) " + - "event=\(NSWindow.keyDescription(event))" + "accepted=0 reason=responder_panel_mismatch responderPanel=\(responderPanel) " + + "workspace=\(workspace.id.uuidString.prefix(5)) event=\(NSWindow.keyDescription(event))" ) #endif - return panelId + return nil } #if DEBUG - let focusedPanel = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" cmuxDebugLog( "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=0 reason=responder_not_omnibar responder=\(shortcutResponder.map { String(describing: type(of: $0)) } ?? "nil") " + - "pending=\(panel.pendingAddressBarFocusRequestId != nil ? 1 : 0) focusedPanel=\(focusedPanel) " + + "accepted=1 reason=omnibar_responder workspace=\(workspace.id.uuidString.prefix(5)) " + "event=\(NSWindow.keyDescription(event))" ) #endif - return nil - } - - private func shouldPreserveBrowserAddressBarTrackingDuringTransientShortcutResponder( - for panel: BrowserPanel, - responder: NSResponder?, - in window: NSWindow?, - liveOmnibarFieldExists: Bool - ) -> Bool { - guard browserAddressBarFocusedPanelId == panel.id else { return false } - guard panel.preferredFocusIntent == .addressBar else { return false } - guard panel.shouldSuppressWebViewFocus() || - liveOmnibarFieldExists || - panel.pendingAddressBarFocusRequestId != nil else { - return false - } - - guard let responder else { return true } - if let window, responder === window { - return true - } - if responder is NSWindow { - return true - } - if browserOmnibarPanelId(for: responder) == panel.id { - return true - } - if cmuxOwningGhosttyView(for: responder) != nil { - return false - } - if responder is NSTextView || responder is NSTextField { - return false - } - if let window, panel.ownedFocusIntent(for: responder, in: window) != nil { - return false - } - return false - } - - private func browserAddressBarIntentPanelId( - in context: MainWindowContext, - window: NSWindow? - ) -> UUID? { - guard let workspace = context.tabManager.selectedWorkspace, - let focusedPanelId = workspace.focusedPanelId, - let panel = workspace.browserPanel(for: focusedPanelId), - panel.preferredFocusIntent == .addressBar, - let field = browserOmnibarField(panelId: panel.id, in: window) else { - return nil - } - - guard panel.shouldSuppressWebViewFocus() || field.currentEditor() != nil else { - return nil - } - return panel.id - } - - private func browserPanel( - _ panel: BrowserPanel, - ownsShortcutResponder responder: NSResponder?, - in window: NSWindow? - ) -> Bool { - guard let responder, let window else { return false } - if browserOmnibarPanelId(for: responder) == panel.id { - return true - } - if case .browser(.webView)? = panel.ownedFocusIntent(for: responder, in: window) { - return true - } - return false + return panelId } private func browserOmnibarOwnerView(for responder: NSResponder?) -> NSView? { @@ -12927,23 +12837,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - private func shouldPreserveBrowserAddressBarTracking( - for panel: BrowserPanel, - trackedPanelMatchesWebView: Bool, - pointerInitiatedWebFocus: Bool = false, - in window: NSWindow? = nil - ) -> Bool { + private func shouldPreserveBrowserAddressBarTracking(for panel: BrowserPanel) -> Bool { guard browserAddressBarFocusedPanelId == panel.id else { return false } - let resolvedWindow = window ?? panel.webView.window - let trackingContext = BrowserAddressBarTrackingContext( - trackedPanelMatchesWebView: trackedPanelMatchesWebView, - omnibarResponderActive: isBrowserOmnibarResponder(resolvedWindow?.firstResponder), - preferredFocusIntentIsAddressBar: panel.preferredFocusIntent == .addressBar, - suppressesWebViewFocus: panel.shouldSuppressWebViewFocus(), - pointerInitiatedWebFocus: pointerInitiatedWebFocus, - liveOmnibarFieldExists: browserOmnibarField(panelId: panel.id, in: resolvedWindow) != nil - ) - return shouldPreserveBrowserAddressBarTrackingDuringWebViewFocus(trackingContext) + if isBrowserOmnibarResponder(panel.webView.window?.firstResponder) { + return true + } + return panel.preferredFocusIntent == .addressBar && panel.shouldSuppressWebViewFocus() } @discardableResult @@ -12951,20 +12850,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focusBrowserAddressBar(panelId: panelId) } - private func controlOmnibarSelectionDelta( + private func commandOmnibarSelectionDelta( hasFocusedAddressBar: Bool, flags: NSEvent.ModifierFlags, chars: String ) -> Int? { - browserOmnibarSelectionDeltaForControlNavigation( + browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: hasFocusedAddressBar, flags: flags, chars: chars ) } - private func dispatchBrowserOmnibarSelectionMove(panelId: UUID, delta: Int) { + private func dispatchBrowserOmnibarSelectionMove(delta: Int) { guard delta != 0 else { return } + guard let panelId = browserAddressBarFocusedPanelId else { return } #if DEBUG cmuxDebugLog( "browser.focus.omnibar.selectionMove panel=\(panelId.uuidString.prefix(5)) " + @@ -12978,15 +12878,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } - private func startBrowserOmnibarSelectionRepeatIfNeeded(panelId: UUID, keyCode: UInt16, delta: Int) { + private func startBrowserOmnibarSelectionRepeatIfNeeded( + keyCode: UInt16, + delta: Int, + flags: NSEvent.ModifierFlags + ) { guard delta != 0 else { return } + guard browserAddressBarFocusedPanelId != nil else { +#if DEBUG + cmuxDebugLog( + "browser.focus.omnibar.repeat.start key=\(keyCode) delta=\(delta) " + + "result=skip_no_focused_address_bar" + ) +#endif + return + } - if browserOmnibarRepeatPanelId == panelId, - browserOmnibarRepeatKeyCode == keyCode, - browserOmnibarRepeatDelta == delta { + let repeatModifierFlags = browserOmnibarNormalizedModifierFlags(flags) + if browserOmnibarRepeatKeyCode == keyCode, + browserOmnibarRepeatDelta == delta, + browserOmnibarRepeatModifierFlags == repeatModifierFlags { #if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" cmuxDebugLog( - "browser.focus.omnibar.repeat.start panel=\(panelId.uuidString.prefix(5)) " + + "browser.focus.omnibar.repeat.start panel=\(panelToken) " + "key=\(keyCode) delta=\(delta) result=reuse" ) #endif @@ -12994,12 +12909,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } stopBrowserOmnibarSelectionRepeat() - browserOmnibarRepeatPanelId = panelId browserOmnibarRepeatKeyCode = keyCode browserOmnibarRepeatDelta = delta + browserOmnibarRepeatModifierFlags = repeatModifierFlags #if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" cmuxDebugLog( - "browser.focus.omnibar.repeat.start panel=\(panelId.uuidString.prefix(5)) " + + "browser.focus.omnibar.repeat.start panel=\(panelToken) " + "key=\(keyCode) delta=\(delta) result=armed" ) #endif @@ -13013,7 +12929,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func scheduleBrowserOmnibarSelectionRepeatTick() { browserOmnibarRepeatStartWorkItem = nil - guard let panelId = browserOmnibarRepeatPanelId else { + guard browserAddressBarFocusedPanelId != nil else { #if DEBUG cmuxDebugLog("browser.focus.omnibar.repeat.tick result=stop_no_focused_address_bar") #endif @@ -13023,12 +12939,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard browserOmnibarRepeatKeyCode != nil else { return } #if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" cmuxDebugLog( - "browser.focus.omnibar.repeat.tick panel=\(panelId.uuidString.prefix(5)) " + + "browser.focus.omnibar.repeat.tick panel=\(panelToken) " + "delta=\(browserOmnibarRepeatDelta)" ) #endif - dispatchBrowserOmnibarSelectionMove(panelId: panelId, delta: browserOmnibarRepeatDelta) + dispatchBrowserOmnibarSelectionMove(delta: browserOmnibarRepeatDelta) let tick = DispatchWorkItem { [weak self] in self?.scheduleBrowserOmnibarSelectionRepeatTick() @@ -13039,7 +12956,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func stopBrowserOmnibarSelectionRepeat() { #if DEBUG - let previousPanelId = browserOmnibarRepeatPanelId let previousKeyCode = browserOmnibarRepeatKeyCode let previousDelta = browserOmnibarRepeatDelta #endif @@ -13047,14 +12963,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent browserOmnibarRepeatTickWorkItem?.cancel() browserOmnibarRepeatStartWorkItem = nil browserOmnibarRepeatTickWorkItem = nil - browserOmnibarRepeatPanelId = nil browserOmnibarRepeatKeyCode = nil browserOmnibarRepeatDelta = 0 + browserOmnibarRepeatModifierFlags = [] #if DEBUG if previousKeyCode != nil || previousDelta != 0 { cmuxDebugLog( - "browser.focus.omnibar.repeat.stop panel=\(previousPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil") " + - "key=\(previousKeyCode.map(String.init) ?? "nil") " + + "browser.focus.omnibar.repeat.stop key=\(previousKeyCode.map(String.init) ?? "nil") " + "delta=\(previousDelta)" ) } @@ -13077,7 +12992,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } case .flagsChanged: let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if !browserOmnibarShouldContinueControlNavigationRepeat(flags: flags) { + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let commandWasReleased = browserOmnibarRepeatModifierFlags.contains(.command) && + !normalizedFlags.contains(.command) + let controlWasReleased = browserOmnibarRepeatModifierFlags.contains(.control) && + !normalizedFlags.contains(.control) + if commandWasReleased || controlWasReleased { #if DEBUG cmuxDebugLog( "browser.focus.omnibar.repeat.lifecycle event=flagsChanged " + @@ -14344,63 +14264,55 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] notification in - MainActor.assumeIsolated { - self?.handleBrowserWebViewFirstResponderNotification(notification) - } - } - } - - @MainActor - private func handleBrowserWebViewFirstResponderNotification(_ notification: Notification) { - guard let webView = notification.object as? CmuxWebView, - let panel = browserPanelOwning(webView) else { return } - let pointerInitiatedKey = BrowserFirstResponderNotificationUserInfoKey.pointerInitiated - let pointerInitiated = notification.userInfo?[pointerInitiatedKey] as? Bool ?? false - - if let trackedPanelId = browserAddressBarFocusedPanelId, - trackedPanelId != panel.id, - let trackedPanel = browserPanel(for: trackedPanelId), - !shouldPreserveBrowserAddressBarTracking( - for: trackedPanel, - trackedPanelMatchesWebView: false, - pointerInitiatedWebFocus: pointerInitiated, - in: trackedPanel.webView.window - ) { - trackedPanel.endSuppressWebViewFocusForAddressBar() - browserAddressBarFocusedPanelId = nil - stopBrowserOmnibarSelectionRepeat() -#if DEBUG - cmuxDebugLog( - "addressBar CLEAR panelId=\(trackedPanelId.uuidString.prefix(8)) " + - "reason=stale_other_panel_webViewFirstResponder" - ) + guard let self else { return } + guard let webView = notification.object as? CmuxWebView, + let panel = self.browserPanelOwning(webView) else { return } + + if let trackedPanelId = self.browserAddressBarFocusedPanelId, + trackedPanelId != panel.id { + let trackedPanel = self.browserPanel(for: trackedPanelId) + let shouldPreserveTrackedPanel = trackedPanel.map { + self.shouldPreserveBrowserAddressBarTracking(for: $0) + } ?? false + if !shouldPreserveTrackedPanel { + trackedPanel?.endSuppressWebViewFocusForAddressBar() + self.browserAddressBarFocusedPanelId = nil + self.stopBrowserOmnibarSelectionRepeat() + } +#if DEBUG + let reason = trackedPanel == nil + ? "stale_missing_panel_webViewFirstResponder" + : (shouldPreserveTrackedPanel + ? "stale_other_panel_preserved_webViewFirstResponder" + : "stale_other_panel_webViewFirstResponder") + let action = shouldPreserveTrackedPanel ? "PRESERVE" : "CLEAR" + cmuxDebugLog( + "addressBar \(action) panelId=\(trackedPanelId.uuidString.prefix(8)) " + + "reason=\(reason)" + ) #endif - } + } - guard !shouldPreserveBrowserAddressBarTracking( - for: panel, - trackedPanelMatchesWebView: panel.webView === webView, - pointerInitiatedWebFocus: pointerInitiated, - in: webView.window - ) else { + guard !self.shouldPreserveBrowserAddressBarTracking(for: panel) else { #if DEBUG - cmuxDebugLog( - "addressBar CLEAR panelId=\(panel.id.uuidString.prefix(8)) " + - "reason=skip_preserve_omnibar_handoff pointer=\(pointerInitiated ? 1 : 0)" - ) + cmuxDebugLog( + "addressBar CLEAR panelId=\(panel.id.uuidString.prefix(8)) " + + "reason=skip_preserve_omnibar_handoff" + ) #endif - return - } - panel.endSuppressWebViewFocusForAddressBar() - if browserAddressBarFocusedPanelId == panel.id { - browserAddressBarFocusedPanelId = nil - stopBrowserOmnibarSelectionRepeat() + return + } + panel.endSuppressWebViewFocusForAddressBar() + if self.browserAddressBarFocusedPanelId == panel.id { + self.browserAddressBarFocusedPanelId = nil + self.stopBrowserOmnibarSelectionRepeat() #if DEBUG - cmuxDebugLog( - "addressBar CLEAR panelId=\(panel.id.uuidString.prefix(8)) " + - "reason=webViewFirstResponder" - ) + cmuxDebugLog( + "addressBar CLEAR panelId=\(panel.id.uuidString.prefix(8)) " + + "reason=webViewFirstResponder" + ) #endif + } } } @@ -15753,51 +15665,6 @@ private extension NSWindow { firstResponderHasMarkedText: firstResponderHasMarkedText, flags: event.modifierFlags ) { - if let focusedOmnibarField = AppDelegate.shared?.focusedBrowserOmnibarField(for: event, in: self), - browserOmnibarPanelId(for: self.firstResponder) == nil, - focusedOmnibarField.window === self { - if cmuxBrowserArrowForwardingDepth > 0 { -#if DEBUG - cmuxDebugLog(" → browser arrow omnibar restore reentry; using normal dispatch") -#endif - return cmux_performKeyEquivalent(with: event) - } - cmuxBrowserArrowForwardingDepth += 1 - defer { cmuxBrowserArrowForwardingDepth = max(0, cmuxBrowserArrowForwardingDepth - 1) } - - var currentEditorResponder: NSResponder? = focusedOmnibarField.currentEditor() - if currentEditorResponder == nil || self.firstResponder !== currentEditorResponder { - guard self.makeFirstResponder(focusedOmnibarField) else { -#if DEBUG - cmuxDebugLog(" → browser arrow omnibar restore rejected") -#endif - return false - } - currentEditorResponder = focusedOmnibarField.currentEditor() - } - - let omnibarResponder: NSResponder - if let currentEditorResponder, self.firstResponder === currentEditorResponder { - omnibarResponder = currentEditorResponder - } else if self.firstResponder === focusedOmnibarField { - omnibarResponder = focusedOmnibarField - } else { -#if DEBUG - cmuxDebugLog(" → browser arrow omnibar restore did not become first responder") -#endif - return false - } -#if DEBUG - if browserResponderHasMarkedText(omnibarResponder) { - cmuxDebugLog(" → browser arrow restored focused omnibar with marked text before keyDown") - } else { - cmuxDebugLog(" → browser arrow restored focused omnibar before keyDown") - } -#endif - omnibarResponder.keyDown(with: event) - return true - } - // Match the Return/Enter forwarding guard: AppKit/WebKit can re-enter // performKeyEquivalent while the synthesized keyDown is in flight. if cmuxBrowserArrowForwardingDepth > 0 { @@ -15951,10 +15818,6 @@ private extension NSWindow { in window: NSWindow, event: NSEvent? ) -> CmuxWebView? { - if browserOmnibarPanelId(for: responder) != nil { - return nil - } - // Browser find runs in the portal slot alongside the hosted WKWebView. // Treat its native field editor chain as browser chrome, not as web content, // so Cmd+F can move first responder into the find field while web focus is suppressed. diff --git a/Sources/BrowserPaneDropTargetView.swift b/Sources/BrowserPaneDropTargetView.swift index dad8031b172f..438379684de7 100644 --- a/Sources/BrowserPaneDropTargetView.swift +++ b/Sources/BrowserPaneDropTargetView.swift @@ -370,6 +370,7 @@ final class BrowserPaneDropTargetView: NSView { } private func focusBrowserPanelAfterSuccessfulFileDrop(context: BrowserPaneDropContext) { + guard context.allowsPanelFocusAfterFileDrop else { return } guard let workspace = AppDelegate.shared?.workspaceFor(tabId: context.workspaceId) else { return } FileDropTextDropController.focusPanelAfterSuccessfulTextDrop( workspace: workspace, diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index eec4e28bf908..81bb0421543a 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1334,6 +1334,19 @@ struct BrowserPaneDropContext: Equatable { let workspaceId: UUID let panelId: UUID let paneId: PaneID + let allowsPanelFocusAfterFileDrop: Bool + + init( + workspaceId: UUID, + panelId: UUID, + paneId: PaneID, + allowsPanelFocusAfterFileDrop: Bool = true + ) { + self.workspaceId = workspaceId + self.panelId = panelId + self.paneId = paneId + self.allowsPanelFocusAfterFileDrop = allowsPanelFocusAfterFileDrop + } } struct BrowserPaneDragTransfer: Equatable { @@ -3557,6 +3570,7 @@ final class WindowBrowserPortal: NSObject { scheduleDeferredFullSynchronizeAll() } containerView.setPaneTopChromeHeight(0) + containerView.setOmnibarSuggestions(nil) return } let oldFrame = containerView.frame diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index 62f912205d60..a40c63d19632 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -122,7 +122,7 @@ nonisolated struct CmuxTopProcessScope: Sendable { let surfaceID: UUID? let attributionReason: String - init(workspaceID: UUID?, surfaceID: UUID?, attributionReason: String) { + init(workspaceID: UUID?, surfaceID: UUID?, attributionReason: String = "cmux-environment") { self.workspaceID = workspaceID self.surfaceID = surfaceID self.attributionReason = attributionReason diff --git a/Sources/CommandPalette/CommandPaletteNucleoSearch.swift b/Sources/CommandPalette/CommandPaletteNucleoSearch.swift deleted file mode 100644 index 7208161340b8..000000000000 --- a/Sources/CommandPalette/CommandPaletteNucleoSearch.swift +++ /dev/null @@ -1,391 +0,0 @@ -import Darwin -import Foundation - -struct CommandPaletteNucleoSearchResult: Sendable where Payload: Sendable { - let payload: Payload - let rank: Int - let title: String - let score: Int - let titleMatchIndices: Set -} - -// Sendable is safe here because the dlopen handle and C function pointers are -// immutable after initialization. The Rust side owns synchronization for index -// search state. -final class CommandPaletteNucleoSearchLibrary: @unchecked Sendable { - private typealias CreateIndex = @convention(c) ( - UnsafePointer?, - Int, - UnsafeRawPointer?, - Int - ) -> OpaquePointer? - private typealias DestroyIndex = @convention(c) (OpaquePointer?) -> Void - private typealias SearchIndexWithBoosts = @convention(c) ( - OpaquePointer?, - UnsafePointer?, - Int, - Int, - UnsafePointer?, - Int, - UnsafeMutableRawPointer?, - Int, - UnsafeMutablePointer? - ) -> Int32 - private typealias Version = @convention(c) () -> UInt32 - - private static let supportedVersion: UInt32 = 2 - static let shared = CommandPaletteNucleoSearchLibrary.loadDefault() - - private let handle: UnsafeMutableRawPointer - private let createIndex: CreateIndex - private let destroyIndex: DestroyIndex - private let searchIndexWithBoosts: SearchIndexWithBoosts - let version: UInt32 - - private init( - handle: UnsafeMutableRawPointer, - createIndex: @escaping CreateIndex, - destroyIndex: @escaping DestroyIndex, - searchIndexWithBoosts: @escaping SearchIndexWithBoosts, - version: UInt32 - ) { - self.handle = handle - self.createIndex = createIndex - self.destroyIndex = destroyIndex - self.searchIndexWithBoosts = searchIndexWithBoosts - self.version = version - } - - deinit { - dlclose(handle) - } - - static func loadDefault() -> CommandPaletteNucleoSearchLibrary? { - for path in defaultLibraryPaths() where FileManager.default.fileExists(atPath: path) { - if let library = load(path: path) { - return library - } - } - return nil - } - - private static func load(path: String) -> CommandPaletteNucleoSearchLibrary? { - guard let handle = dlopen(path, RTLD_NOW | RTLD_LOCAL) else { - return nil - } - - guard let createIndex = symbol("cmux_nucleo_index_create", from: handle, as: CreateIndex.self), - let destroyIndex = symbol("cmux_nucleo_index_destroy", from: handle, as: DestroyIndex.self), - let searchIndexWithBoosts = symbol( - "cmux_nucleo_index_search_with_boosts", - from: handle, - as: SearchIndexWithBoosts.self - ), - let version = symbol("cmux_nucleo_ffi_version", from: handle, as: Version.self) else { - dlclose(handle) - return nil - } - let resolvedVersion = version() - guard resolvedVersion == supportedVersion else { - dlclose(handle) - return nil - } - - CommandPaletteNucleoABI.assertCompatibleLayout() - - return CommandPaletteNucleoSearchLibrary( - handle: handle, - createIndex: createIndex, - destroyIndex: destroyIndex, - searchIndexWithBoosts: searchIndexWithBoosts, - version: resolvedVersion - ) - } - - private static func symbol(_ name: String, from handle: UnsafeMutableRawPointer, as _: T.Type) -> T? { - guard let pointer = dlsym(handle, name) else { return nil } - return unsafeBitCast(pointer, to: T.self) - } - - private static func defaultLibraryPaths() -> [String] { - var paths: [String] = [] - let environmentPath = ProcessInfo.processInfo.environment["CMUX_NUCLEO_FFI_LIB"] - if let environmentPath, !environmentPath.isEmpty { - paths.append(environmentPath) - } - - if let privateFrameworksPath = Bundle.main.privateFrameworksPath { - paths.append( - URL(fileURLWithPath: privateFrameworksPath) - .appendingPathComponent(Self.libraryFileName) - .path - ) - } - - let sourceRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - paths.append( - sourceRoot - .appendingPathComponent("Native/CommandPaletteNucleoFFI/target/cmux-nucleo-ffi") - .appendingPathComponent(Self.libraryFileName) - .path - ) - paths.append( - sourceRoot - .appendingPathComponent("Native/CommandPaletteNucleoFFI/target/release") - .appendingPathComponent(Self.libraryFileName) - .path - ) - paths.append( - sourceRoot - .appendingPathComponent("Native/CommandPaletteNucleoFFI/target/aarch64-apple-darwin/release") - .appendingPathComponent(Self.libraryFileName) - .path - ) - paths.append( - sourceRoot - .appendingPathComponent("Native/CommandPaletteNucleoFFI/target/x86_64-apple-darwin/release") - .appendingPathComponent(Self.libraryFileName) - .path - ) - - return paths - } - - private static let libraryFileName = "libcmux_command_palette_nucleo_ffi.dylib" - - func createIndex( - entries: [CommandPaletteSearchCorpusEntry] - ) -> OpaquePointer? { - var blob: [UInt8] = [] - var spans: [CommandPaletteNucleoCandidateSpan] = [] - blob.reserveCapacity(entries.reduce(0) { total, entry in - total + entry.title.utf8.count + entry.nucleoSearchText.utf8.count - }) - spans.reserveCapacity(entries.count) - - for entry in entries { - let titleOffset = blob.count - blob.append(contentsOf: entry.title.utf8) - let titleLength = blob.count - titleOffset - - let searchOffset = blob.count - blob.append(contentsOf: entry.nucleoSearchText.utf8) - let searchLength = blob.count - searchOffset - - spans.append( - CommandPaletteNucleoCandidateSpan( - titleOffset: titleOffset, - titleLength: titleLength, - searchOffset: searchOffset, - searchLength: searchLength, - rank: Int32(clamping: entry.rank) - ) - ) - } - - return blob.withUnsafeBufferPointer { blobBuffer in - spans.withUnsafeBufferPointer { spanBuffer in - createIndex( - blobBuffer.baseAddress, - blobBuffer.count, - UnsafeRawPointer(spanBuffer.baseAddress), - spanBuffer.count - ) - } - } - } - - func destroy(index: OpaquePointer?) { - destroyIndex(index) - } - - fileprivate func search( - index: OpaquePointer, - query: String, - resultLimit: Int, - boosts: [Int32]? - ) -> [CommandPaletteNucleoRawMatch]? { - guard resultLimit > 0 else { return [] } - - var matches = Array( - repeating: CommandPaletteNucleoRawMatch(index: 0, score: 0, rank: 0), - count: max(1, resultLimit) - ) - var count = 0 - let queryBytes = Array(query.utf8) - let boostsCount = boosts?.count ?? 0 - let status = queryBytes.withUnsafeBufferPointer { queryBuffer in - matches.withUnsafeMutableBufferPointer { matchesBuffer in - if let boosts { - return boosts.withUnsafeBufferPointer { boostsBuffer in - searchIndexWithBoosts( - index, - queryBuffer.baseAddress, - queryBuffer.count, - resultLimit, - boostsBuffer.baseAddress, - boostsCount, - UnsafeMutableRawPointer(matchesBuffer.baseAddress), - matchesBuffer.count, - &count - ) - } - } else { - return searchIndexWithBoosts( - index, - queryBuffer.baseAddress, - queryBuffer.count, - resultLimit, - nil, - 0, - UnsafeMutableRawPointer(matchesBuffer.baseAddress), - matchesBuffer.count, - &count - ) - } - } - } - guard status == 0 else { return nil } - return Array(matches.prefix(count)) - } -} - -// Sendable is safe here because the Swift payload entries are immutable, the -// raw index pointer is destroyed only in deinit, and Rust keeps per-thread -// matcher scratch state outside the immutable index. -final class CommandPaletteNucleoSearchIndex: @unchecked Sendable where Payload: Sendable { - private let library: CommandPaletteNucleoSearchLibrary - private let pointer: OpaquePointer - private let entries: [CommandPaletteSearchCorpusEntry] - - init?(entries: [CommandPaletteSearchCorpusEntry]) { - guard let library = CommandPaletteNucleoSearchLibrary.shared, - let pointer = library.createIndex(entries: entries) else { - return nil - } - self.library = library - self.pointer = pointer - self.entries = entries - } - - deinit { - library.destroy(index: pointer) - } - - func search( - query: String, - resultLimit: Int, - historyBoost: ((Payload, Bool) -> Int)? = nil, - shouldCancel: () -> Bool = { false } - ) -> [CommandPaletteNucleoSearchResult]? { - guard resultLimit > 0 else { return [] } - if shouldCancel() { return [] } - - let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) - let queryIsEmpty = preparedQuery.isEmpty - let boosts: [Int32]? - if let historyBoost { - var values: [Int32] = [] - values.reserveCapacity(entries.count) - var hasNonZeroBoost = false - for entry in entries { - let boost = Int32(clamping: historyBoost(entry.payload, queryIsEmpty)) - hasNonZeroBoost = hasNonZeroBoost || boost != 0 - values.append(boost) - } - boosts = hasNonZeroBoost ? values : nil - } else { - boosts = nil - } - guard let rawMatches = library.search( - index: pointer, - query: query, - resultLimit: min(resultLimit, entries.count), - boosts: boosts - ) else { - return nil - } - if shouldCancel() { return [] } - - var results: [CommandPaletteNucleoSearchResult] = [] - results.reserveCapacity(rawMatches.count) - for rawMatch in rawMatches { - guard entries.indices.contains(rawMatch.index) else { continue } - let entry = entries[rawMatch.index] - let titleMatchIndices: Set - if queryIsEmpty { - titleMatchIndices = [] - } else { - titleMatchIndices = entry.preparedTitle.map { - CommandPaletteFuzzyMatcher.matchCharacterIndices( - preparedQuery: preparedQuery, - preparedCandidate: $0 - ) - } ?? [] - } - results.append( - CommandPaletteNucleoSearchResult( - payload: entry.payload, - rank: entry.rank, - title: entry.title, - score: Self.clampedRoundedScore(rawMatch.score), - titleMatchIndices: titleMatchIndices - ) - ) - } - return results - } - - private static func clampedRoundedScore(_ score: Double) -> Int { - let rounded = score.rounded() - guard rounded.isFinite else { - if rounded == .infinity { return Int.max } - if rounded == -.infinity { return Int.min } - return 0 - } - if rounded >= Double(Int.max) { return Int.max } - if rounded <= Double(Int.min) { return Int.min } - return Int(rounded) - } -} - -fileprivate struct CommandPaletteNucleoCandidateSpan { - let titleOffset: Int - let titleLength: Int - let searchOffset: Int - let searchLength: Int - let rank: Int32 -} - -fileprivate struct CommandPaletteNucleoRawMatch { - var index: Int - var score: Double - var rank: Int32 -} - -private enum CommandPaletteNucleoABI { - static func assertCompatibleLayout() { - _ = checked - } - - private static let checked: Void = { - precondition(MemoryLayout.size == MemoryLayout.size) - precondition(MemoryLayout.size == 36) - precondition(MemoryLayout.stride == 40) - precondition(MemoryLayout.alignment == 8) - precondition(MemoryLayout.size == 20) - precondition(MemoryLayout.stride == 24) - precondition(MemoryLayout.alignment == 8) - precondition(MemoryLayout.offset(of: \.titleOffset) == 0) - precondition(MemoryLayout.offset(of: \.titleLength) == 8) - precondition(MemoryLayout.offset(of: \.searchOffset) == 16) - precondition(MemoryLayout.offset(of: \.searchLength) == 24) - precondition(MemoryLayout.offset(of: \.rank) == 32) - precondition(MemoryLayout.offset(of: \.index) == 0) - precondition(MemoryLayout.offset(of: \.score) == 8) - precondition(MemoryLayout.offset(of: \.rank) == 16) - }() -} diff --git a/Sources/CommandPalette/CommandPaletteOverlay.swift b/Sources/CommandPalette/CommandPaletteOverlay.swift deleted file mode 100644 index 266afcb08965..000000000000 --- a/Sources/CommandPalette/CommandPaletteOverlay.swift +++ /dev/null @@ -1,159 +0,0 @@ -import Observation -import SwiftUI - -enum CommandPaletteRenderTrailingLabelStyle: Equatable { - case shortcut - case kind -} - -struct CommandPaletteRenderTrailingLabel: Equatable { - let text: String - let style: CommandPaletteRenderTrailingLabelStyle -} - -struct CommandPaletteRenderResultRow: Identifiable, Equatable { - let id: String - let title: String - let matchedIndices: Set - let trailingLabel: CommandPaletteRenderTrailingLabel? -} - -struct CommandPaletteCommandListRenderState: Equatable { - var resultsVersion: UInt64 = 0 - var emptyStateText: String = "" - var listIdentity: String = "switcher" - var rows: [CommandPaletteRenderResultRow] = [] - var selectedIndex: Int = 0 - var shouldShowEmptyState = false - var scrollTargetID: String? - var scrollTargetAnchor: UnitPoint? - - static let empty = CommandPaletteCommandListRenderState() -} - -@MainActor -@Observable -final class CommandPaletteOverlayRenderModel { - private(set) var commandList = CommandPaletteCommandListRenderState.empty - @ObservationIgnored private var scheduledCommandListSequence: UInt64 = 0 - @ObservationIgnored private var appliedCommandListSequence: UInt64 = 0 - @ObservationIgnored private var appliedCommandListResultsVersion: UInt64 = 0 - - deinit {} - - func scheduleCommandListUpdate(_ state: CommandPaletteCommandListRenderState) { - scheduledCommandListSequence &+= 1 - let sequence = scheduledCommandListSequence - - Task { @MainActor in - await Task.yield() - guard sequence >= appliedCommandListSequence else { return } - guard state.resultsVersion >= appliedCommandListResultsVersion else { return } - appliedCommandListSequence = sequence - appliedCommandListResultsVersion = max(appliedCommandListResultsVersion, state.resultsVersion) - updateCommandList(state) - } - } - - private func updateCommandList(_ state: CommandPaletteCommandListRenderState) { - guard commandList != state else { return } - commandList = state - } -} - -struct CommandPaletteCommandListRenderView: View { - let renderModel: CommandPaletteOverlayRenderModel - let onRunResult: (String) -> Void - - var body: some View { - CommandPaletteCommandListRowsView( - state: renderModel.commandList, - onRunResult: onRunResult - ) - } -} - -struct CommandPaletteCommandListRowsView: View { - let state: CommandPaletteCommandListRenderState - let onRunResult: (String) -> Void - @State private var hoveredIndex: Int? - - private static let listMaxHeight: CGFloat = 450 - private static let rowHeight: CGFloat = 24 - private static let emptyStateHeight: CGFloat = 44 - - var body: some View { - let contentHeight = state.rows.isEmpty - ? Self.emptyStateHeight - : CGFloat(state.rows.count) * Self.rowHeight - let listHeight = min(Self.listMaxHeight, contentHeight) - - ScrollView { - LazyVStack(spacing: 0) { - if state.rows.isEmpty { - if state.shouldShowEmptyState { - Text(state.emptyStateText) - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 12) - } else { - Color.clear - .frame(maxWidth: .infinity) - .frame(height: Self.emptyStateHeight) - } - } else { - ForEach(Array(state.rows.enumerated()), id: \.element.id) { index, row in - let isSelected = index == state.selectedIndex - let isHovered = hoveredIndex == index - let rowBackground: Color = isSelected - ? cmuxAccentColor().opacity(0.12) - : (isHovered ? Color.primary.opacity(0.08) : .clear) - - Button { - onRunResult(row.id) - } label: { - ContentView.commandPaletteRenderResultLabelContent( - title: row.title, - matchedIndices: row.matchedIndices, - trailingLabel: row.trailingLabel - ) - .padding(.horizontal, 9) - .padding(.vertical, 2) - .frame(maxWidth: .infinity, alignment: .leading) - .background(rowBackground) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityIdentifier("CommandPaletteResultRow.\(index)") - .accessibilityValue(row.id) - .onHover { hovering in - if hovering { - hoveredIndex = index - } else if hoveredIndex == index { - hoveredIndex = nil - } - } - } - } - } - .scrollTargetLayout() - } - .id(state.listIdentity) - .frame(height: listHeight) - .scrollPosition( - id: Binding( - get: { state.scrollTargetID }, - // Ignore passive readback so manual scrolling doesn't mutate selection-follow state. - set: { _ in } - ), - anchor: state.scrollTargetAnchor - ) - .onChange(of: state.rows.count) { _, count in - if let hoveredIndex, hoveredIndex >= count { - self.hoveredIndex = nil - } - } - } -} diff --git a/Sources/CommandPalette/CommandPaletteSearch.swift b/Sources/CommandPalette/CommandPaletteSearch.swift index 10c057b7d111..ed1d66da7574 100644 --- a/Sources/CommandPalette/CommandPaletteSearch.swift +++ b/Sources/CommandPalette/CommandPaletteSearch.swift @@ -137,81 +137,6 @@ enum CommandPaletteSwitcherSearchIndexer { enum CommandPaletteFuzzyMatcher { private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] - struct WordSegment: Hashable, Sendable { - let start: Int - let end: Int - } - - struct ASCIIScalarMask: Equatable, Sendable { - let low: UInt64 - let high: UInt64 - - init(low: UInt64, high: UInt64) { - self.low = low - self.high = high - } - - init(_ text: String) { - var low: UInt64 = 0 - var high: UInt64 = 0 - for scalar in text.unicodeScalars where scalar.isASCII { - let value = Int(scalar.value) - if value < 64 { - low |= UInt64(1) << UInt64(value) - } else { - high |= UInt64(1) << UInt64(value - 64) - } - } - self.low = low - self.high = high - } - - func missingBitCount(from candidate: ASCIIScalarMask) -> Int { - (low & ~candidate.low).nonzeroBitCount + (high & ~candidate.high).nonzeroBitCount - } - } - - struct PreparedToken: Equatable, Sendable { - let normalizedText: String - let characters: [Character] - let asciiMask: ASCIIScalarMask - let allowsSingleEdit: Bool - let containsTokenBoundaryCharacter: Bool - let scoreUpperBound: Int - let scoreUpperBoundWithoutExactMatch: Int - - init(_ normalizedText: String) { - self.normalizedText = normalizedText - self.characters = Array(normalizedText) - self.asciiMask = ASCIIScalarMask(normalizedText) - self.allowsSingleEdit = characters.count >= 4 - self.containsTokenBoundaryCharacter = characters.contains { - CommandPaletteFuzzyMatcher.tokenBoundaryChars.contains($0) - } - self.scoreUpperBound = max(8000, 3500 + (characters.count * 300)) - self.scoreUpperBoundWithoutExactMatch = max(6799, 3500 + (characters.count * 300)) - } - - func couldMatch(_ candidate: PreparedCandidateText) -> Bool { - let missingCharacters = asciiMask.missingBitCount(from: candidate.asciiMask) - return missingCharacters <= (allowsSingleEdit ? 1 : 0) - } - } - - struct PreparedCandidateText: Sendable { - let normalizedText: String - let characters: [Character] - let wordSegments: [WordSegment] - let asciiMask: ASCIIScalarMask - - init(normalizedText: String) { - self.normalizedText = normalizedText - self.characters = Array(normalizedText) - self.wordSegments = CommandPaletteFuzzyMatcher.wordSegments(characters) - self.asciiMask = ASCIIScalarMask(normalizedText) - } - } - private enum SingleEditWordPrefixEditKind { case candidateExtraCharacter case tokenExtraCharacter @@ -223,7 +148,7 @@ enum CommandPaletteFuzzyMatcher { case .candidateExtraCharacter: return 0 case .tokenExtraCharacter: - return 240 + return 10 case .transposedCharacters: return 24 case .substitutedCharacter: @@ -243,7 +168,7 @@ enum CommandPaletteFuzzyMatcher { struct PreparedQuery { let normalizedText: String - let tokens: [PreparedToken] + let tokens: [String] var isEmpty: Bool { tokens.isEmpty @@ -254,11 +179,7 @@ enum CommandPaletteFuzzyMatcher { let normalizedQuery = normalizeForSearch(query) return PreparedQuery( normalizedText: normalizedQuery, - tokens: normalizedQuery - .split(separator: " ") - .map(String.init) - .filter { !$0.isEmpty } - .map(PreparedToken.init) + tokens: normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } ) } @@ -269,97 +190,29 @@ enum CommandPaletteFuzzyMatcher { .lowercased() } - static func prepareCandidateText(_ candidate: String) -> PreparedCandidateText? { - let normalizedCandidate = normalizeForSearch(candidate) - guard !normalizedCandidate.isEmpty else { return nil } - return PreparedCandidateText(normalizedText: normalizedCandidate) - } - - static func prepareNormalizedCandidateText(_ normalizedCandidate: String) -> PreparedCandidateText? { - guard !normalizedCandidate.isEmpty else { return nil } - return PreparedCandidateText(normalizedText: normalizedCandidate) - } - static func score(query: String, candidate: String) -> Int? { score(query: query, candidates: [candidate]) } static func score(query: String, candidates: [String]) -> Int? { - let preparedQuery = preparedQuery(query) - var normalizedCandidates: [String] = [] - normalizedCandidates.reserveCapacity(candidates.count) - for candidate in candidates { - let normalizedCandidate = normalizeForSearch(candidate) - guard !normalizedCandidate.isEmpty else { continue } - normalizedCandidates.append(normalizedCandidate) - } - return score( - preparedQuery: preparedQuery, - normalizedCandidates: normalizedCandidates - ) - } - - static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { score( - preparedQuery: preparedQuery, - preparedCandidates: normalizedCandidates.compactMap(prepareNormalizedCandidateText), - exactCandidateTexts: Set(normalizedCandidates) - ) - } - - static func score(preparedQuery: PreparedQuery, preparedCandidates: [PreparedCandidateText]) -> Int? { - score( - preparedQuery: preparedQuery, - preparedCandidates: preparedCandidates, - exactCandidateTexts: nil + preparedQuery: preparedQuery(query), + normalizedCandidates: candidates + .map(normalizeForSearch) + .filter { !$0.isEmpty } ) } - static func score(preparedQuery: PreparedQuery, preparedCandidate: PreparedCandidateText) -> Int? { - guard !preparedQuery.isEmpty else { return 0 } - - var totalScore = 0 - for token in preparedQuery.tokens { - guard token.couldMatch(preparedCandidate) else { return nil } - guard let tokenScore = scoreToken(token, in: preparedCandidate) else { return nil } - totalScore += tokenScore - } - return totalScore - } - - static func score( - preparedQuery: PreparedQuery, - preparedCandidates: [PreparedCandidateText], - exactCandidateTexts: Set?, - wholeCandidatePrefixScoreByToken: [String: Int]? = nil - ) -> Int? { + static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { guard !preparedQuery.isEmpty else { return 0 } - guard !preparedCandidates.isEmpty else { return nil } + guard !normalizedCandidates.isEmpty else { return nil } var totalScore = 0 for token in preparedQuery.tokens { - let hasExactCandidateText = exactCandidateTexts?.contains(token.normalizedText) == true - if token.scoreUpperBound == 8000, hasExactCandidateText { - totalScore += 8000 - continue - } - if exactCandidateTexts != nil, - !hasExactCandidateText, - let prefixScore = wholeCandidatePrefixScoreByToken?[token.normalizedText] - ?? bestWholeCandidatePrefixScore(token: token, preparedCandidates: preparedCandidates), - prefixScore >= token.scoreUpperBoundWithoutExactMatch { - totalScore += prefixScore - continue - } - var bestTokenScore: Int? - for candidate in preparedCandidates { - guard token.couldMatch(candidate) else { continue } + for candidate in normalizedCandidates { guard let candidateScore = scoreToken(token, in: candidate) else { continue } bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) - if bestTokenScore ?? 0 >= token.scoreUpperBound { - break - } } guard let bestTokenScore else { return nil } totalScore += bestTokenScore @@ -367,38 +220,6 @@ enum CommandPaletteFuzzyMatcher { return totalScore } - private static func bestWholeCandidatePrefixScore( - token: PreparedToken, - preparedCandidates: [PreparedCandidateText] - ) -> Int? { - var bestScore: Int? - for candidate in preparedCandidates where candidate.normalizedText.hasPrefix(token.normalizedText) { - let score = 6800 - max(0, candidate.characters.count - token.characters.count) - bestScore = max(bestScore ?? score, score) - } - return bestScore - } - - static func wholeCandidatePrefixScoreByToken( - preparedCandidates: [PreparedCandidateText], - maxPrefixLength: Int = 16 - ) -> [String: Int] { - var scores: [String: Int] = [:] - for candidate in preparedCandidates { - let prefixLimit = min(candidate.characters.count, maxPrefixLength) - guard prefixLimit > 0 else { continue } - - for prefixLength in 1...prefixLimit { - let prefix = String(candidate.characters.prefix(prefixLength)) - let score = 6800 - max(0, candidate.characters.count - prefixLength) - if score > (scores[prefix] ?? Int.min) { - scores[prefix] = score - } - } - } - return scores - } - static func matchCharacterIndices(query: String, candidate: String) -> Set { matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) } @@ -406,69 +227,47 @@ enum CommandPaletteFuzzyMatcher { static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { guard !preparedQuery.isEmpty else { return [] } - guard let preparedCandidate = prepareCandidateText(candidate) else { return [] } - return matchCharacterIndices(preparedQuery: preparedQuery, preparedCandidate: preparedCandidate) - } - - static func matchCharacterIndices( - preparedQuery: PreparedQuery, - preparedCandidate: PreparedCandidateText - ) -> Set { - guard !preparedQuery.isEmpty else { return [] } + let loweredCandidate = normalizeForSearch(candidate) + guard !loweredCandidate.isEmpty else { return [] } - let loweredCandidate = preparedCandidate.normalizedText - let candidateChars = preparedCandidate.characters + let candidateChars = Array(loweredCandidate) var matched: Set = [] for token in preparedQuery.tokens { - guard token.couldMatch(preparedCandidate) else { continue } - - if token.normalizedText == loweredCandidate { + if token == loweredCandidate { matched.formUnion(0.. Bool { - guard !token.normalizedText.isEmpty else { return true } - - let candidateText = candidate.normalizedText - if token.normalizedText == candidateText { - return true - } - if candidateText.hasPrefix(token.normalizedText) { - return true - } - if candidateText.range(of: token.normalizedText) != nil { - return true - } - - guard !token.containsTokenBoundaryCharacter else { - return token.characters.count <= 3 && subsequenceScore(token: token, candidate: candidate) != nil - } - - if bestWordScore(tokenChars: token.characters, candidate: candidate) != nil { - return true - } - if initialismScore(tokenChars: token.characters, candidate: candidate) != nil { - return true - } - if stitchedWordPrefixScore(tokenChars: token.characters, candidate: candidate) != nil { - return true - } - if token.characters.count <= 3, subsequenceScore(token: token, candidate: candidate) != nil { - return true - } - return false - } - - static func usesSingleEditWordPrefix( - preparedQuery: PreparedQuery, - preparedCandidates: [PreparedCandidateText] - ) -> Bool { - for token in preparedQuery.tokens where token.allowsSingleEdit && !token.containsTokenBoundaryCharacter { - for candidate in preparedCandidates { - guard !tokenCanMatchWithoutSingleEdit(token, preparedCandidate: candidate) else { continue } - if singleEditWordPrefixMatch( - tokenChars: token.characters, - candidateChars: candidate.characters, - segments: candidate.wordSegments - ) != nil { - return true - } - } - } - return false - } - - private static func scoreToken(_ token: PreparedToken, in candidate: PreparedCandidateText) -> Int? { - guard !token.normalizedText.isEmpty else { return 0 } + private static func scoreToken(_ token: String, in candidate: String) -> Int? { + guard !token.isEmpty else { return 0 } - let candidateText = candidate.normalizedText - let candidateChars = candidate.characters - let tokenChars = token.characters + let candidateChars = Array(candidate) + let tokenChars = Array(token) guard tokenChars.count <= candidateChars.count else { return nil } - if token.normalizedText == candidateText { + if token == candidate { return 8000 } - if candidateText.hasPrefix(token.normalizedText) { - return 6800 - max(0, candidateChars.count - tokenChars.count) + if candidate.hasPrefix(token) { + return 6800 - max(0, candidate.count - token.count) } var bestScore: Int? - if !token.containsTokenBoundaryCharacter { - if let wordScore = bestWordScore(tokenChars: tokenChars, candidate: candidate) { - bestScore = max(bestScore ?? wordScore, wordScore) - } - if let singleEditPrefixScore = singleEditWordPrefixScore( - tokenChars: tokenChars, - candidate: candidate - ) { - bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) - } + if let wordExactScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: true) { + bestScore = max(bestScore ?? wordExactScore, wordExactScore) + } + if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) { + bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore) + } + if let singleEditPrefixScore = singleEditWordPrefixScore( + tokenChars: tokenChars, + candidateChars: candidateChars + ) { + bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) } - if let range = candidateText.range(of: token.normalizedText) { - let distance = candidateText.distance(from: candidateText.startIndex, to: range.lowerBound) - let lengthPenalty = max(0, candidateChars.count - tokenChars.count) + if let range = candidate.range(of: token) { + let distance = candidate.distance(from: candidate.startIndex, to: range.lowerBound) + let lengthPenalty = max(0, candidate.count - token.count) let boundaryBoost: Int = { guard distance > 0 else { return 220 } let prior = candidateChars[distance - 1] @@ -571,14 +315,12 @@ enum CommandPaletteFuzzyMatcher { bestScore = max(bestScore ?? containsScore, containsScore) } - if !token.containsTokenBoundaryCharacter { - if let initialismScore = initialismScore(tokenChars: tokenChars, candidate: candidate) { - bestScore = max(bestScore ?? initialismScore, initialismScore) - } + if let initialismScore = initialismScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? initialismScore, initialismScore) + } - if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidate: candidate) { - bestScore = max(bestScore ?? stitchedScore, stitchedScore) - } + if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? stitchedScore, stitchedScore) } if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { @@ -591,13 +333,13 @@ enum CommandPaletteFuzzyMatcher { private static func bestWordScore( tokenChars: [Character], - candidate: PreparedCandidateText + candidateChars: [Character], + requireExactWord: Bool ) -> Int? { guard !tokenChars.isEmpty else { return nil } - let candidateChars = candidate.characters var best: Int? - for segment in candidate.wordSegments { + for segment in wordSegments(candidateChars) { let wordLength = segment.end - segment.start guard tokenChars.count <= wordLength else { continue } @@ -607,16 +349,14 @@ enum CommandPaletteFuzzyMatcher { break } guard matchesPrefix else { continue } + if requireExactWord && tokenChars.count != wordLength { continue } let lengthPenalty = max(0, wordLength - tokenChars.count) * 6 let distancePenalty = segment.start * 8 let trailingPenalty = max(0, candidateChars.count - wordLength) - let prefixScore = 5600 - distancePenalty - lengthPenalty - trailingPenalty - best = max(best ?? prefixScore, prefixScore) - if tokenChars.count == wordLength { - let exactScore = 6200 - distancePenalty - trailingPenalty - best = max(best ?? exactScore, exactScore) - } + let scoreBase = requireExactWord ? 6200 : 5600 + let score = scoreBase - distancePenalty - lengthPenalty - trailingPenalty + best = max(best ?? score, score) } return best @@ -624,16 +364,15 @@ enum CommandPaletteFuzzyMatcher { private static func singleEditWordPrefixScore( tokenChars: [Character], - candidate: PreparedCandidateText + candidateChars: [Character] ) -> Int? { guard let match = singleEditWordPrefixMatch( tokenChars: tokenChars, - candidateChars: candidate.characters, - segments: candidate.wordSegments + candidateChars: candidateChars ) else { return nil } - return singleEditWordPrefixScore(match: match, candidateLength: candidate.characters.count) + return singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) } private static func singleEditWordPrefixScore( @@ -652,10 +391,9 @@ enum CommandPaletteFuzzyMatcher { - editPositionPenalty } - private static func initialismScore(tokenChars: [Character], candidate: PreparedCandidateText) -> Int? { + private static func initialismScore(tokenChars: [Character], candidateChars: [Character]) -> Int? { guard !tokenChars.isEmpty else { return nil } - let candidateChars = candidate.characters - let segments = candidate.wordSegments + let segments = wordSegments(candidateChars) guard tokenChars.count <= segments.count else { return nil } var matchedStarts: [Int] = [] @@ -698,10 +436,9 @@ enum CommandPaletteFuzzyMatcher { return true } - private static func stitchedWordPrefixScore(tokenChars: [Character], candidate: PreparedCandidateText) -> Int? { + private static func stitchedWordPrefixScore(tokenChars: [Character], candidateChars: [Character]) -> Int? { guard tokenChars.count >= 4 else { return nil } - let candidateChars = candidate.characters - let segments = candidate.wordSegments + let segments = wordSegments(candidateChars) guard segments.count >= 2 else { return nil } struct StitchState: Hashable { @@ -770,15 +507,12 @@ enum CommandPaletteFuzzyMatcher { return 3500 + stitchedScore - lengthPenalty } - private static func stitchedWordPrefixMatchIndices( - token: PreparedToken, - candidate: PreparedCandidateText - ) -> Set? { - let tokenChars = token.characters - let candidateChars = candidate.characters + private static func stitchedWordPrefixMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) guard tokenChars.count >= 4 else { return nil } - let segments = candidate.wordSegments + let segments = wordSegments(candidateChars) guard segments.count >= 2 else { return nil } var tokenIndex = 0 @@ -838,25 +572,13 @@ enum CommandPaletteFuzzyMatcher { private static func singleEditWordPrefixMatch( tokenChars: [Character], candidateChars: [Character] - ) -> SingleEditWordPrefixMatch? { - singleEditWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidateChars, - segments: wordSegments(candidateChars) - ) - } - - private static func singleEditWordPrefixMatch( - tokenChars: [Character], - candidateChars: [Character], - segments: [WordSegment] ) -> SingleEditWordPrefixMatch? { guard tokenChars.count >= 4 else { return nil } var bestMatch: SingleEditWordPrefixMatch? var bestScore: Int? - for segment in segments { + for segment in wordSegments(candidateChars) { guard let match = singleEditWordPrefixMatch( tokenChars: tokenChars, candidateChars: candidateChars, @@ -879,7 +601,7 @@ enum CommandPaletteFuzzyMatcher { private static func singleEditWordPrefixMatch( tokenChars: [Character], candidateChars: [Character], - segment: WordSegment + segment: (start: Int, end: Int) ) -> SingleEditWordPrefixMatch? { guard tokenChars.count >= 4 else { return nil } @@ -1011,8 +733,8 @@ enum CommandPaletteFuzzyMatcher { return nil } - private static func wordSegments(_ candidateChars: [Character]) -> [WordSegment] { - var segments: [WordSegment] = [] + private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] { + var segments: [(start: Int, end: Int)] = [] var index = 0 while index < candidateChars.count { @@ -1024,15 +746,15 @@ enum CommandPaletteFuzzyMatcher { while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { index += 1 } - segments.append(WordSegment(start: start, end: index)) + segments.append((start: start, end: index)) } return segments } - private static func subsequenceScore(token: PreparedToken, candidate: PreparedCandidateText) -> Int? { - let tokenChars = token.characters - let candidateChars = candidate.characters + private static func subsequenceScore(token: String, candidate: String) -> Int? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) guard tokenChars.count <= candidateChars.count else { return nil } var searchIndex = 0 @@ -1071,9 +793,9 @@ enum CommandPaletteFuzzyMatcher { return max(1, score) } - private static func subsequenceMatchIndices(token: PreparedToken, candidate: PreparedCandidateText) -> Set? { - let tokenChars = token.characters - let candidateChars = candidate.characters + private static func subsequenceMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) guard tokenChars.count <= candidateChars.count else { return nil } var indices: Set = [] @@ -1096,12 +818,12 @@ enum CommandPaletteFuzzyMatcher { return indices } - private static func initialismMatchIndices(token: PreparedToken, candidate: PreparedCandidateText) -> Set? { - let tokenChars = token.characters - let candidateChars = candidate.characters + private static func initialismMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) guard !tokenChars.isEmpty else { return nil } - let segments = candidate.wordSegments + let segments = wordSegments(candidateChars) guard tokenChars.count <= segments.count else { return nil } var matched: Set = [] @@ -1129,44 +851,17 @@ struct CommandPaletteSearchCorpusEntry: Sendable where Payload: Sendabl let payload: Payload let rank: Int let title: String - let preparedTitle: CommandPaletteFuzzyMatcher.PreparedCandidateText? - let preparedSearchableTexts: [CommandPaletteFuzzyMatcher.PreparedCandidateText] - let searchableTextSet: Set - let searchablePrefixScoreByToken: [String: Int] - let nucleoSearchText: String + let normalizedTitle: String + let normalizedSearchableTexts: [String] init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) { self.payload = payload self.rank = rank self.title = title - let normalizedTitle = CommandPaletteFuzzyMatcher.normalizeForSearch(title) - self.preparedTitle = CommandPaletteFuzzyMatcher.prepareNormalizedCandidateText(normalizedTitle) - - var nucleoSearchTexts: [String] = [] - var normalizedTexts: [String] = [] - var seen: Set = [] - normalizedTexts.reserveCapacity(searchableTexts.count) - nucleoSearchTexts.reserveCapacity(searchableTexts.count) - for text in searchableTexts { - let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedText.isEmpty { - nucleoSearchTexts.append(trimmedText) - } - let normalizedText = CommandPaletteFuzzyMatcher.normalizeForSearch(text) - guard !normalizedText.isEmpty else { continue } - guard seen.insert(normalizedText).inserted else { continue } - normalizedTexts.append(normalizedText) - } - - let preparedSearchableTexts = normalizedTexts.compactMap( - CommandPaletteFuzzyMatcher.prepareNormalizedCandidateText - ) - self.preparedSearchableTexts = preparedSearchableTexts - self.searchableTextSet = Set(normalizedTexts) - self.searchablePrefixScoreByToken = CommandPaletteFuzzyMatcher.wholeCandidatePrefixScoreByToken( - preparedCandidates: preparedSearchableTexts - ) - self.nucleoSearchText = nucleoSearchTexts.joined(separator: "\n") + self.normalizedTitle = CommandPaletteFuzzyMatcher.normalizeForSearch(title) + self.normalizedSearchableTexts = searchableTexts + .map(CommandPaletteFuzzyMatcher.normalizeForSearch) + .filter { !$0.isEmpty } } } @@ -1181,99 +876,14 @@ struct CommandPaletteSearchCorpusResult: Sendable where Payload: Sendab enum CommandPaletteSearchEngine { private static let titleMatchBonus = 2000 - private struct ScoredEntry: Sendable where Payload: Sendable { - let entry: CommandPaletteSearchCorpusEntry - let index: Int - let score: Int - } - - private static func scoredEntryIsBetter( - _ lhs: ScoredEntry, - than rhs: ScoredEntry - ) -> Bool { - if lhs.score != rhs.score { return lhs.score > rhs.score } - if lhs.entry.rank != rhs.entry.rank { return lhs.entry.rank < rhs.entry.rank } - let titleComparison = lhs.entry.title.localizedCaseInsensitiveCompare(rhs.entry.title) - if titleComparison != .orderedSame { return titleComparison == .orderedAscending } - return lhs.index < rhs.index - } - - private static func scoredEntryIsWorse( - _ lhs: ScoredEntry, - than rhs: ScoredEntry - ) -> Bool { - scoredEntryIsBetter(rhs, than: lhs) - } - - private static func siftUpWorstScoredEntryHeap( - _ heap: inout [ScoredEntry], - from startIndex: Int - ) { - var child = startIndex - while child > 0 { - let parent = (child - 1) / 2 - guard scoredEntryIsWorse(heap[child], than: heap[parent]) else { break } - heap.swapAt(child, parent) - child = parent - } - } - - private static func siftDownWorstScoredEntryHeap( - _ heap: inout [ScoredEntry], - from startIndex: Int - ) { - var parent = startIndex - while true { - let leftChild = (parent * 2) + 1 - guard leftChild < heap.count else { return } - - let rightChild = leftChild + 1 - var worstChild = leftChild - if rightChild < heap.count, - scoredEntryIsWorse(heap[rightChild], than: heap[leftChild]) { - worstChild = rightChild - } - - guard scoredEntryIsWorse(heap[worstChild], than: heap[parent]) else { return } - heap.swapAt(parent, worstChild) - parent = worstChild - } - } - - private static func appendScoredEntry( - _ scoredEntry: ScoredEntry, - to scoredEntries: inout [ScoredEntry], - limit: Int? - ) { - guard let limit else { - scoredEntries.append(scoredEntry) - return - } - - if scoredEntries.count < limit { - scoredEntries.append(scoredEntry) - siftUpWorstScoredEntryHeap(&scoredEntries, from: scoredEntries.count - 1) - return - } - - guard let worstEntry = scoredEntries.first, - scoredEntryIsBetter(scoredEntry, than: worstEntry) else { - return - } - scoredEntries[0] = scoredEntry - siftDownWorstScoredEntryHeap(&scoredEntries, from: 0) - } - static func search( entries: [CommandPaletteSearchCorpusEntry], query: String, - resultLimit: Int? = nil, historyBoost: (Payload, Bool) -> Int ) -> [CommandPaletteSearchCorpusResult] { search( entries: entries, query: query, - resultLimit: resultLimit, historyBoost: historyBoost, shouldCancel: nil ) @@ -1282,14 +892,12 @@ enum CommandPaletteSearchEngine { static func search( entries: [CommandPaletteSearchCorpusEntry], query: String, - resultLimit: Int? = nil, historyBoost: (Payload, Bool) -> Int, shouldCancel: @escaping () -> Bool ) -> [CommandPaletteSearchCorpusResult] { search( entries: entries, query: query, - resultLimit: resultLimit, historyBoost: historyBoost, shouldCancel: Optional(shouldCancel) ) @@ -1298,18 +906,13 @@ enum CommandPaletteSearchEngine { private static func search( entries: [CommandPaletteSearchCorpusEntry], query: String, - resultLimit: Int?, historyBoost: (Payload, Bool) -> Int, shouldCancel: (() -> Bool)? ) -> [CommandPaletteSearchCorpusResult] { - if let resultLimit, resultLimit <= 0 { - return [] - } let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) let queryIsEmpty = preparedQuery.isEmpty - let limitedResultCount = resultLimit.map { min($0, entries.count) } - var scoredEntries: [ScoredEntry] = [] - scoredEntries.reserveCapacity(limitedResultCount ?? entries.count) + var results: [CommandPaletteSearchCorpusResult] = [] + results.reserveCapacity(entries.count) func shouldCancelSearch(at index: Int) -> Bool { guard let shouldCancel else { return false } @@ -1319,14 +922,14 @@ enum CommandPaletteSearchEngine { if queryIsEmpty { for (index, entry) in entries.enumerated() { if shouldCancelSearch(at: index) { return [] } - appendScoredEntry( - ScoredEntry( - entry: entry, - index: index, - score: historyBoost(entry.payload, true) - ), - to: &scoredEntries, - limit: limitedResultCount + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: historyBoost(entry.payload, true), + titleMatchIndices: [] + ) ) } } else { @@ -1338,51 +941,28 @@ enum CommandPaletteSearchEngine { ) else { continue } - appendScoredEntry( - ScoredEntry( - entry: entry, - index: index, - score: fuzzyScore + historyBoost(entry.payload, false) - ), - to: &scoredEntries, - limit: limitedResultCount + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: fuzzyScore + historyBoost(entry.payload, false), + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + preparedQuery: preparedQuery, + candidate: entry.title + ) + ) ) } } if shouldCancel?() == true { return [] } - scoredEntries.sort { scoredEntryIsBetter($0, than: $1) } - - let outputCount = resultLimit.map { min($0, scoredEntries.count) } ?? scoredEntries.count - var results: [CommandPaletteSearchCorpusResult] = [] - results.reserveCapacity(outputCount) - for index in 0.. - if queryIsEmpty { - titleMatchIndices = [] - } else { - titleMatchIndices = entry.preparedTitle.map { - CommandPaletteFuzzyMatcher.matchCharacterIndices( - preparedQuery: preparedQuery, - preparedCandidate: $0 - ) - } ?? [] - } - results.append( - CommandPaletteSearchCorpusResult( - payload: entry.payload, - rank: entry.rank, - title: entry.title, - score: scoredEntry.score, - titleMatchIndices: titleMatchIndices - ) - ) + return results.sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending } - return results } private static func weightedScore( @@ -1390,21 +970,18 @@ enum CommandPaletteSearchEngine { entry: CommandPaletteSearchCorpusEntry ) -> Int? { guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( - preparedQuery: preparedQuery, - preparedCandidates: entry.preparedSearchableTexts, - exactCandidateTexts: entry.searchableTextSet, - wholeCandidatePrefixScoreByToken: entry.searchablePrefixScoreByToken - ) else { + preparedQuery: preparedQuery, + normalizedCandidates: entry.normalizedSearchableTexts + ) else { return nil } - if let preparedTitle = entry.preparedTitle, - preparedQuery.tokens.allSatisfy({ $0.couldMatch(preparedTitle) }), - let titleScore = CommandPaletteFuzzyMatcher.score( + guard !entry.normalizedTitle.isEmpty, + let titleScore = CommandPaletteFuzzyMatcher.score( preparedQuery: preparedQuery, - preparedCandidate: preparedTitle - ) { - return max(fuzzyScore, titleScore + titleMatchBonus) + normalizedCandidates: [entry.normalizedTitle] + ) else { + return fuzzyScore } - return fuzzyScore + return max(fuzzyScore, titleScore + titleMatchBonus) } } diff --git a/Sources/CommandPalette/CommandPaletteSearchOrchestrator.swift b/Sources/CommandPalette/CommandPaletteSearchOrchestrator.swift deleted file mode 100644 index f35e93452b52..000000000000 --- a/Sources/CommandPalette/CommandPaletteSearchOrchestrator.swift +++ /dev/null @@ -1,388 +0,0 @@ -import Foundation - -enum CommandPaletteListScope: String, Sendable { - case commands - case switcher -} - -struct CommandPaletteUsageEntry: Codable, Sendable { - var useCount: Int - var lastUsedAt: TimeInterval -} - -struct CommandPaletteResolvedSearchMatch: Sendable { - let commandID: String - let score: Int - let titleMatchIndices: Set -} - -enum CommandPaletteSearchOrchestrator { - private static let synchronousSeedCorpusLimit = 256 - private static let singleEditFallbackNucleoProbeLimit = 12 - - static func firstValueDictionary( - _ values: [Element], - keyedBy key: (Element) -> Key - ) -> [Key: Element] { - var dictionary: [Key: Element] = [:] - dictionary.reserveCapacity(values.count) - for value in values where dictionary[key(value)] == nil { - dictionary[key(value)] = value - } - return dictionary - } - - static func resolvedSearchMatches( - searchIndex: CommandPaletteNucleoSearchIndex?, - searchCorpus: [CommandPaletteSearchCorpusEntry], - searchCorpusByID providedSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry]? = nil, - query: String, - usageHistory: [String: CommandPaletteUsageEntry], - queryIsEmpty: Bool, - historyTimestamp: TimeInterval, - additionalScoreBoost: ((String, Bool) -> Int)? = nil, - resultLimit: Int? = nil, - shouldCancel: @escaping () -> Bool = { false } - ) -> [CommandPaletteResolvedSearchMatch] { - let nucleoResultLimit = resultLimit ?? searchCorpus.count - let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) - let historyBoost: ((String, Bool) -> Int)? = usageHistory.isEmpty ? nil : { commandId, queryIsEmpty in - self.historyBoost( - for: commandId, - queryIsEmpty: queryIsEmpty, - history: usageHistory, - now: historyTimestamp - ) - } - let scoreBoost: ((String, Bool) -> Int)? = { - switch (historyBoost, additionalScoreBoost) { - case (nil, nil): - return nil - case (let historyBoost?, nil): - return historyBoost - case (nil, let additionalScoreBoost?): - return additionalScoreBoost - case (let historyBoost?, let additionalScoreBoost?): - return { commandId, queryIsEmpty in - historyBoost(commandId, queryIsEmpty) + additionalScoreBoost(commandId, queryIsEmpty) - } - } - }() - - func swiftSearchMatches() -> [CommandPaletteResolvedSearchMatch] { - let results = CommandPaletteSearchEngine.search( - entries: searchCorpus, - query: query, - resultLimit: resultLimit, - historyBoost: scoreBoost ?? { _, _ in 0 }, - shouldCancel: shouldCancel - ) - - return results.map { result in - CommandPaletteResolvedSearchMatch( - commandID: result.payload, - score: result.score, - titleMatchIndices: result.titleMatchIndices - ) - } - } - - if let results = searchIndex?.search( - query: query, - resultLimit: nucleoResultLimit, - historyBoost: scoreBoost, - shouldCancel: shouldCancel - ) { - let nucleoMatches = results.map { result in - CommandPaletteResolvedSearchMatch( - commandID: result.payload, - score: result.score, - titleMatchIndices: result.titleMatchIndices - ) - } - if Self.shouldConsiderSwiftSingleEditFallback( - preparedQuery: preparedQuery, - queryIsEmpty: queryIsEmpty, - limit: nucleoResultLimit - ) { - let searchCorpusByID = providedSearchCorpusByID ?? Self.searchCorpusByID(searchCorpus) - guard Self.shouldIncludeSwiftSingleEditFallback( - preparedQuery: preparedQuery, - nucleoMatches: nucleoMatches, - searchCorpusByID: searchCorpusByID - ) else { - return nucleoMatches - } - let fallbackMatches = swiftSingleEditFallbackMatches( - swiftSearchMatches(), - preparedQuery: preparedQuery, - searchCorpusByID: searchCorpusByID - ) - guard !fallbackMatches.isEmpty else { - return nucleoMatches - } - return mergedSwiftFallbackMatches( - fallbackMatches, - nucleoMatches: nucleoMatches, - searchCorpusByID: searchCorpusByID, - limit: nucleoResultLimit - ) - } - return nucleoMatches - } - - return swiftSearchMatches() - } - - private static func searchCorpusByID( - _ searchCorpus: [CommandPaletteSearchCorpusEntry] - ) -> [String: CommandPaletteSearchCorpusEntry] { - firstValueDictionary(searchCorpus, keyedBy: \.payload) - } - - private static func shouldConsiderSwiftSingleEditFallback( - preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery, - queryIsEmpty: Bool, - limit: Int - ) -> Bool { - guard limit > 0 else { return false } - guard !queryIsEmpty else { return false } - return preparedQuery.tokens.contains(where: { $0.allowsSingleEdit }) - } - - private static func shouldIncludeSwiftSingleEditFallback( - preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery, - nucleoMatches: [CommandPaletteResolvedSearchMatch], - searchCorpusByID: [String: CommandPaletteSearchCorpusEntry] - ) -> Bool { - guard !nucleoMatches.isEmpty else { return true } - let singleEditTokens = preparedQuery.tokens.filter { $0.allowsSingleEdit } - - let probedMatches = nucleoMatches.prefix(singleEditFallbackNucleoProbeLimit) - return singleEditTokens.contains { token in - !probedMatches.contains { match in - guard let entry = searchCorpusByID[match.commandID] else { return false } - return entry.preparedSearchableTexts.contains { - CommandPaletteFuzzyMatcher.tokenCanMatchWithoutSingleEdit( - token, - preparedCandidate: $0 - ) - } - } - } - } - - private static func swiftSingleEditFallbackMatches( - _ swiftMatches: [CommandPaletteResolvedSearchMatch], - preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery, - searchCorpusByID: [String: CommandPaletteSearchCorpusEntry] - ) -> [CommandPaletteResolvedSearchMatch] { - swiftMatches.filter { match in - guard let entry = searchCorpusByID[match.commandID] else { return false } - return CommandPaletteFuzzyMatcher.usesSingleEditWordPrefix( - preparedQuery: preparedQuery, - preparedCandidates: entry.preparedSearchableTexts - ) - } - } - - private static func mergedSwiftFallbackMatches( - _ swiftMatches: [CommandPaletteResolvedSearchMatch], - nucleoMatches: [CommandPaletteResolvedSearchMatch], - searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], - limit: Int - ) -> [CommandPaletteResolvedSearchMatch] { - guard limit > 0 else { return [] } - var matchesByID: [String: CommandPaletteResolvedSearchMatch] = [:] - matchesByID.reserveCapacity(swiftMatches.count + nucleoMatches.count) - - func merge(_ match: CommandPaletteResolvedSearchMatch) { - if let existing = matchesByID[match.commandID] { - if resolvedSearchMatchIsBetter(match, than: existing, searchCorpusByID: searchCorpusByID) { - matchesByID[match.commandID] = match - } - return - } - matchesByID[match.commandID] = match - } - - for match in nucleoMatches { - merge(match) - } - for match in swiftMatches { - merge(match) - } - - return Array( - matchesByID.values - .sorted { - resolvedSearchMatchIsBetter($0, than: $1, searchCorpusByID: searchCorpusByID) - } - .prefix(limit) - ) - } - - private static func resolvedSearchMatchIsBetter( - _ lhs: CommandPaletteResolvedSearchMatch, - than rhs: CommandPaletteResolvedSearchMatch, - searchCorpusByID: [String: CommandPaletteSearchCorpusEntry] - ) -> Bool { - if lhs.score != rhs.score { return lhs.score > rhs.score } - let lhsEntry = searchCorpusByID[lhs.commandID] - let rhsEntry = searchCorpusByID[rhs.commandID] - let lhsRank = lhsEntry?.rank ?? Int.max - let rhsRank = rhsEntry?.rank ?? Int.max - if lhsRank != rhsRank { return lhsRank < rhsRank } - let lhsTitle = lhsEntry?.title ?? lhs.commandID - let rhsTitle = rhsEntry?.title ?? rhs.commandID - let titleComparison = lhsTitle.localizedCaseInsensitiveCompare(rhsTitle) - if titleComparison != .orderedSame { - return titleComparison == .orderedAscending - } - return lhs.commandID < rhs.commandID - } - - static func mergedSwiftFallbackMatchesForTests( - _ swiftMatches: [CommandPaletteResolvedSearchMatch], - nucleoMatches: [CommandPaletteResolvedSearchMatch], - searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], - limit: Int - ) -> [CommandPaletteResolvedSearchMatch] { - mergedSwiftFallbackMatches( - swiftMatches, - nucleoMatches: nucleoMatches, - searchCorpusByID: searchCorpusByID, - limit: limit - ) - } - - static func previewSearchMatches( - scope: CommandPaletteListScope, - searchIndex: CommandPaletteNucleoSearchIndex?, - searchCorpus: [CommandPaletteSearchCorpusEntry], - candidateCommandIDs: [String], - searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], - query: String, - usageHistory: [String: CommandPaletteUsageEntry], - queryIsEmpty: Bool, - historyTimestamp: TimeInterval, - additionalScoreBoost: ((String, Bool) -> Int)? = nil, - resultLimit: Int - ) -> [CommandPaletteResolvedSearchMatch] { - guard resultLimit > 0 else { - return [] - } - - if scope == .commands { - return resolvedSearchMatches( - searchIndex: searchIndex, - searchCorpus: searchCorpus, - searchCorpusByID: searchCorpusByID, - query: query, - usageHistory: usageHistory, - queryIsEmpty: queryIsEmpty, - historyTimestamp: historyTimestamp, - additionalScoreBoost: additionalScoreBoost, - resultLimit: resultLimit - ) - } - - guard !candidateCommandIDs.isEmpty else { - return [] - } - - var seenCommandIDs: Set = [] - let previewEntries: [CommandPaletteSearchCorpusEntry] = candidateCommandIDs.compactMap { commandID in - guard seenCommandIDs.insert(commandID).inserted else { return nil } - return searchCorpusByID[commandID] - } - guard !previewEntries.isEmpty else { - return [] - } - - return resolvedSearchMatches( - searchIndex: nil, - searchCorpus: previewEntries, - query: query, - usageHistory: usageHistory, - queryIsEmpty: queryIsEmpty, - historyTimestamp: historyTimestamp, - additionalScoreBoost: additionalScoreBoost, - resultLimit: resultLimit - ) - } - - static func commandPreviewMatchCommandIDsForTests( - searchCorpus: [CommandPaletteSearchCorpusEntry], - searchIndex: CommandPaletteNucleoSearchIndex?, - candidateCommandIDs: [String], - searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], - query: String, - resultLimit: Int - ) -> [String] { - let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) - return previewSearchMatches( - scope: .commands, - searchIndex: searchIndex, - searchCorpus: searchCorpus, - candidateCommandIDs: candidateCommandIDs, - searchCorpusByID: searchCorpusByID, - query: query, - usageHistory: [:], - queryIsEmpty: preparedQuery.isEmpty, - historyTimestamp: 0, - resultLimit: resultLimit - ).map(\.commandID) - } - - static func previewCandidateCommandIDs( - resultIDs: [String], - limit: Int - ) -> [String] { - guard limit > 0 else { return [] } - guard resultIDs.count > limit else { return resultIDs } - return Array(resultIDs.prefix(limit)) - } - - static func shouldSynchronouslySeedResults( - hasVisibleResultsForScope: Bool, - hasSearchIndex: Bool, - corpusCount: Int - ) -> Bool { - !hasVisibleResultsForScope && (hasSearchIndex || corpusCount <= synchronousSeedCorpusLimit) - } - - static func shouldPreserveEmptyStateWhileSearchPending( - isSearchPending: Bool, - visibleResultsScopeMatches: Bool, - resolvedSearchScopeMatches: Bool, - resolvedSearchFingerprintMatches: Bool, - resolvedResultsAreEmpty: Bool - ) -> Bool { - guard isSearchPending, - visibleResultsScopeMatches, - resolvedSearchScopeMatches, - resolvedSearchFingerprintMatches, - resolvedResultsAreEmpty else { - return false - } - - return true - } - - static func historyBoost( - for commandId: String, - queryIsEmpty: Bool, - history: [String: CommandPaletteUsageEntry], - now: TimeInterval - ) -> Int { - guard let entry = history[commandId] else { return 0 } - - let ageDays = max(0, now - entry.lastUsedAt) / 86_400 - let recencyBoost = max(0, 320 - Int(ageDays * 20)) - let countBoost = min(180, entry.useCount * 12) - let totalBoost = recencyBoost + countBoost - - return queryIsEmpty ? totalBoost : max(0, totalBoost / 3) - } -} diff --git a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift index 8905a83d020d..8e3405730a2b 100644 --- a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift +++ b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift @@ -168,38 +168,6 @@ enum CommandPaletteSettingsToggleCommands { defaultValue: PaneFirstClickFocusSettings.defaultEnabled, defaultsKey: PaneFirstClickFocusSettings.enabledKey ), - CommandPaletteSettingToggleDescriptor( - commandId: commandIdPrefix + "openSupportedFilesInCmux", - settingsKey: "app.openSupportedFilesInCmux", - title: { - String( - localized: "settings.app.openSupportedFilesInCmux", - defaultValue: "Open Supported Files in cmux" - ) - }, - sectionTitle: app, - keywords: [ - "app.openSupportedFilesInCmux", - "cmd", - "click", - "file", - "preview", - "pdf", - "image", - "audio", - "video", - "quicklook", - "quick", - "look", - "editor", - "external", - ], - defaultValue: CmdClickSupportedFileRouteSettings.defaultValue, - defaultsKey: CmdClickSupportedFileRouteSettings.key, - didSet: { _, _, notificationCenter in - CmdClickSupportedFileRouteSettings.notifyDidChange(notificationCenter: notificationCenter) - } - ), CommandPaletteSettingToggleDescriptor( commandId: commandIdPrefix + "openMarkdownInCmuxViewer", settingsKey: "app.openMarkdownInCmuxViewer", diff --git a/Sources/ContentView+ForkAgentConversation.swift b/Sources/ContentView+ForkAgentConversation.swift deleted file mode 100644 index 0064356f230d..000000000000 --- a/Sources/ContentView+ForkAgentConversation.swift +++ /dev/null @@ -1,162 +0,0 @@ -import AppKit -import Foundation - -extension ContentView { - func forkFocusedAgentConversationRight() { - forkFocusedAgentConversation(.split(.right)) - } - - func forkFocusedAgentConversationLeft() { - forkFocusedAgentConversation(.split(.left)) - } - - func forkFocusedAgentConversationTop() { - forkFocusedAgentConversation(.split(.up)) - } - - func forkFocusedAgentConversationBottom() { - forkFocusedAgentConversation(.split(.down)) - } - - func forkFocusedAgentConversationToNewWorkspace() { - forkFocusedAgentConversation(.newWorkspace) - } - - private func forkFocusedAgentConversation(_ destination: AgentConversationForkDestination) { - guard let initialContext = focusedPanelContext, - initialContext.panel.panelType == .terminal else { - NSSound.beep() - return - } - - let workspaceId = initialContext.workspace.id - let panelId = initialContext.panelId - let panelKey = Self.commandPaletteForkableAgentPanelKey( - workspaceId: workspaceId, - panelId: panelId - ) - - Task { @MainActor in - let index = await RestorableAgentSessionIndex.loadIncludingProcessDetectedSnapshots() - guard let currentContext = focusedPanelContext, - currentContext.workspace.id == workspaceId, - currentContext.panelId == panelId, - currentContext.panel.panelType == .terminal else { - NSSound.beep() - return - } - - let snapshot = Self.commandPaletteForkExecutionSnapshot( - indexSnapshot: index.snapshot(workspaceId: workspaceId, panelId: panelId), - fallbackSnapshot: currentContext.workspace.restoredAgentSnapshotsByPanelId[panelId], - cachedSnapshot: commandPaletteForkableAgentSnapshotsByPanelKey[panelKey] - ) - guard let snapshot else { - commandPaletteForkableAgentSupportedPanelKeys.remove(panelKey) - commandPaletteForkableAgentSnapshotsByPanelKey.removeValue(forKey: panelKey) - commandPaletteForkableAgentSnapshotFingerprintsByPanelKey.removeValue(forKey: panelKey) - NSSound.beep() - return - } - let isRemoteContext = currentContext.workspace.isRemoteTerminalSurface(panelId) - guard await AgentForkSupport.supportsFork( - snapshot: snapshot, - isRemoteContext: isRemoteContext - ) else { - NSSound.beep() - return - } - guard let postProbeContext = focusedPanelContext, - Self.commandPaletteForkPostProbeContextStillMatches( - expectedWorkspaceId: workspaceId, - expectedPanelId: panelId, - expectedIsRemoteContext: isRemoteContext, - currentWorkspaceId: postProbeContext.workspace.id, - currentPanelId: postProbeContext.panelId, - currentPanelIsTerminal: postProbeContext.panel.panelType == .terminal, - currentIsRemoteContext: postProbeContext.workspace.isRemoteTerminalSurface(panelId) - ) else { - NSSound.beep() - return - } - commandPaletteForkableAgentSupportedPanelKeys.insert( - panelKey - ) - commandPaletteForkableAgentSnapshotsByPanelKey[panelKey] = snapshot - commandPaletteForkableAgentSnapshotFingerprintsByPanelKey[panelKey] = Self.commandPaletteForkSnapshotFingerprint(snapshot) - commandPaletteForkableAgentRemoteContextsByPanelKey[panelKey] = isRemoteContext - - let didFork: Bool - switch destination { - case .split(let direction): - didFork = postProbeContext.workspace.forkAgentConversation( - fromPanelId: panelId, - snapshot: snapshot, - direction: direction - ) != nil - case .newWorkspace: - guard let launch = postProbeContext.workspace.forkAgentWorkspaceLaunch( - fromPanelId: panelId, - snapshot: snapshot - ) else { - NSSound.beep() - return - } - let forkWorkspace = tabManager.addWorkspace( - workingDirectory: launch.terminalWorkingDirectory, - initialTerminalCommand: launch.initialTerminalCommand, - initialTerminalInput: launch.initialTerminalInput, - inheritWorkingDirectory: launch.terminalWorkingDirectory != nil, - autoWelcomeIfNeeded: false - ) - if let remoteConfiguration = launch.remoteConfiguration { - forkWorkspace.configureRemoteConnection( - remoteConfiguration, - autoConnect: launch.autoConnectRemoteConfiguration - ) - } - if let workingDirectory = launch.workingDirectory, - launch.terminalWorkingDirectory == nil, - let forkPanelId = forkWorkspace.focusedPanelId { - forkWorkspace.updatePanelDirectory(panelId: forkPanelId, directory: workingDirectory) - } - didFork = true - } - - guard didFork else { - NSSound.beep() - return - } - } - } -} - -extension ContentView { - static func commandPaletteForkExecutionSnapshot( - indexSnapshot: SessionRestorableAgentSnapshot?, - fallbackSnapshot: SessionRestorableAgentSnapshot?, - cachedSnapshot: SessionRestorableAgentSnapshot? - ) -> SessionRestorableAgentSnapshot? { - indexSnapshot ?? fallbackSnapshot ?? cachedSnapshot - } - - static func commandPaletteForkPostProbeContextStillMatches( - expectedWorkspaceId: UUID, - expectedPanelId: UUID, - expectedIsRemoteContext: Bool, - currentWorkspaceId: UUID, - currentPanelId: UUID, - currentPanelIsTerminal: Bool, - currentIsRemoteContext: Bool - ) -> Bool { - currentWorkspaceId == expectedWorkspaceId - && currentPanelId == expectedPanelId - && currentPanelIsTerminal - && currentIsRemoteContext == expectedIsRemoteContext - } -} - -private enum AgentConversationForkDestination: Sendable { - case split(SplitDirection) - case newWorkspace -} diff --git a/Sources/ContentView+RightSidebarCommandPalette.swift b/Sources/ContentView+RightSidebarCommandPalette.swift index 8876c7e4c6b3..918bbffb0da7 100644 --- a/Sources/ContentView+RightSidebarCommandPalette.swift +++ b/Sources/ContentView+RightSidebarCommandPalette.swift @@ -27,8 +27,6 @@ extension ContentView { return .showNotifications case "palette.jumpUnread": return .jumpToUnread - case "palette.toggleUnread": - return .toggleUnread case "palette.markOldestUnreadAndJumpNext": return .markOldestUnreadAndJumpNext case "palette.renameTab": diff --git a/Sources/ContentView+ViewCommandPalette.swift b/Sources/ContentView+ViewCommandPalette.swift index 6a434867acbf..a5410984e642 100644 --- a/Sources/ContentView+ViewCommandPalette.swift +++ b/Sources/ContentView+ViewCommandPalette.swift @@ -1,5 +1,300 @@ import Foundation +enum CommandPaletteMode { + case commands + case renameInput(CommandPaletteRenameTarget) + case renameConfirm(CommandPaletteRenameTarget, proposedName: String) + case workspaceDescriptionInput(CommandPaletteWorkspaceDescriptionTarget) +} + +enum CommandPaletteListScope: String { + case commands + case switcher +} + +enum CommandPalettePendingActivation: Equatable { + case selected(requestID: UInt64, fallbackSelectedIndex: Int, preferredCommandID: String?) + case command(requestID: UInt64, commandID: String) +} + +enum CommandPaletteResolvedActivation: Equatable { + case selected(index: Int) + case command(commandID: String) +} + +struct CommandPaletteRenameTarget: Equatable { + enum Kind: Equatable { + case workspace(workspaceId: UUID) + case tab(workspaceId: UUID, panelId: UUID) + } + + let kind: Kind + let currentName: String + + var title: String { + switch kind { + case .workspace: + return String(localized: "commandPalette.rename.workspaceTitle", defaultValue: "Rename Workspace") + case .tab: + return String(localized: "commandPalette.rename.tabTitle", defaultValue: "Rename Tab") + } + } + + var description: String { + switch kind { + case .workspace: + return String(localized: "commandPalette.rename.workspaceDescription", defaultValue: "Choose a custom workspace name.") + case .tab: + return String(localized: "commandPalette.rename.tabDescription", defaultValue: "Choose a custom tab name.") + } + } + + var placeholder: String { + switch kind { + case .workspace: + return String(localized: "commandPalette.rename.workspacePlaceholder", defaultValue: "Workspace name") + case .tab: + return String(localized: "commandPalette.rename.tabPlaceholder", defaultValue: "Tab name") + } + } +} + +struct CommandPaletteWorkspaceDescriptionTarget: Equatable { + let workspaceId: UUID + let currentDescription: String + + var placeholder: String { + String( + localized: "commandPalette.description.workspacePlaceholder", + defaultValue: "Workspace description" + ) + } + + var inputHint: String { + String( + localized: "commandPalette.description.workspaceInputHint", + defaultValue: "Press Enter to save. Press Shift-Enter for a new line, or Escape to cancel." + ) + } +} + +struct CommandPaletteRestoreFocusTarget { + let workspaceId: UUID + let panelId: UUID + let intent: PanelFocusIntent +} + +enum CommandPaletteInputFocusTarget { + case search + case rename +} + +enum CommandPaletteTextSelectionBehavior { + case caretAtEnd + case selectAll +} + +enum CommandPaletteTrailingLabelStyle { + case shortcut + case kind +} + +struct CommandPaletteTrailingLabel { + let text: String + let style: CommandPaletteTrailingLabelStyle +} + +struct CommandPaletteInputFocusPolicy { + let focusTarget: CommandPaletteInputFocusTarget + let selectionBehavior: CommandPaletteTextSelectionBehavior + + static let search = CommandPaletteInputFocusPolicy( + focusTarget: .search, + selectionBehavior: .caretAtEnd + ) +} + +struct CommandPaletteCommand: Identifiable { + let id: String + let rank: Int + let title: String + let subtitle: String + let shortcutHint: String? + let kindLabel: String? + let keywords: [String] + let dismissOnRun: Bool + let action: () -> Void + + var searchableTexts: [String] { + [title, subtitle] + keywords + } +} + +struct CommandPaletteUsageEntry: Codable, Sendable { + var useCount: Int + var lastUsedAt: TimeInterval +} + +struct CommandPaletteContextSnapshot { + private var boolValues: [String: Bool] = [:] + private var stringValues: [String: String] = [:] + + init() {} + + mutating func setBool(_ key: String, _ value: Bool) { + boolValues[key] = value + } + + mutating func setString(_ key: String, _ value: String?) { + guard let value, !value.isEmpty else { + stringValues.removeValue(forKey: key) + return + } + stringValues[key] = value + } + + func bool(_ key: String) -> Bool { + boolValues[key] ?? false + } + + func string(_ key: String) -> String? { + stringValues[key] + } + + func fingerprint() -> Int { + ContentView.commandPaletteContextFingerprint( + boolValues: boolValues, + stringValues: stringValues + ) + } +} + +struct CommandPaletteCommandsContext { + let snapshot: CommandPaletteContextSnapshot +} + +enum CommandPaletteContextKeys { + static let hasWorkspace = "workspace.hasSelection" + static let workspaceName = "workspace.name" + static let workspaceHasCustomName = "workspace.hasCustomName" + static let workspaceHasCustomDescription = "workspace.hasCustomDescription" + static let workspaceMinimalModeEnabled = "workspace.minimalModeEnabled" + static let workspaceShouldPin = "workspace.shouldPin" + static let workspaceHasPullRequests = "workspace.hasPullRequests" + static let workspaceHasSplits = "workspace.hasSplits" + static let workspaceHasPeers = "workspace.hasPeers" + static let workspaceHasAbove = "workspace.hasAbove" + static let workspaceHasBelow = "workspace.hasBelow" + static let workspaceCanMarkRead = "workspace.canMarkRead" + static let workspaceCanMarkUnread = "workspace.canMarkUnread" + static let sidebarMatchTerminalBackground = "sidebar.matchTerminalBackground" + static let hasFocusedPanel = "panel.hasFocus" + static let panelName = "panel.name" + static let panelIsBrowser = "panel.isBrowser" + static let panelIsTerminal = "panel.isTerminal" + static let panelHasPane = "panel.hasPane" + static let panelHasCustomName = "panel.hasCustomName" + static let panelShouldPin = "panel.shouldPin" + static let panelHasUnread = "panel.hasUnread" + static let panelCanMoveToNewWorkspace = "panel.canMoveToNewWorkspace" + static let updateHasAvailable = "update.hasAvailable" + static let cliInstalledInPATH = "cli.installedInPATH" + static let browserDisabled = "browser.disabled" + static let supportedFileRoutingDisabled = "filePreview.supportedFileRoutingDisabled" + static func terminalOpenTargetAvailable(_ target: TerminalDirectoryOpenTarget) -> String { + "terminal.openTarget.\(target.rawValue).available" + } +} + +struct CommandPaletteCommandContribution { + let commandId: String + let title: (CommandPaletteContextSnapshot) -> String + let subtitle: (CommandPaletteContextSnapshot) -> String + let shortcutHint: String? + let keywords: [String] + let dismissOnRun: Bool + let when: (CommandPaletteContextSnapshot) -> Bool + let enablement: (CommandPaletteContextSnapshot) -> Bool + + init( + commandId: String, + title: @escaping (CommandPaletteContextSnapshot) -> String, + subtitle: @escaping (CommandPaletteContextSnapshot) -> String, + shortcutHint: String? = nil, + keywords: [String] = [], + dismissOnRun: Bool = true, + when: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true }, + enablement: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true } + ) { + self.commandId = commandId + self.title = title + self.subtitle = subtitle + self.shortcutHint = shortcutHint + self.keywords = keywords + self.dismissOnRun = dismissOnRun + self.when = when + self.enablement = enablement + } +} + +struct CommandPaletteHandlerRegistry { + private var handlers: [String: () -> Void] = [:] + + mutating func register(commandId: String, handler: @escaping () -> Void) { + guard handlers[commandId] == nil else { + assertionFailure("Duplicate command palette handler id: \(commandId)") + return + } + handlers[commandId] = handler + } + + func handler(for commandId: String) -> (() -> Void)? { + handlers[commandId] + } +} + +struct CommandPaletteSearchResult: Identifiable { + let command: CommandPaletteCommand + let score: Int + let titleMatchIndices: Set + + var id: String { command.id } +} + +struct CommandPaletteResolvedSearchMatch: Sendable { + let commandID: String + let score: Int + let titleMatchIndices: Set +} + +struct CommandPaletteSwitcherWindowContext { + let windowId: UUID + let tabManager: TabManager + let selectedWorkspaceId: UUID? + let windowLabel: String? +} + +struct CommandPaletteSwitcherFingerprintWorkspace: Sendable { + let id: UUID + let displayName: String + let metadata: CommandPaletteSwitcherSearchMetadata + let surfaces: [CommandPaletteSwitcherFingerprintSurface] +} + +struct CommandPaletteSwitcherFingerprintSurface: Sendable { + let id: UUID + let displayName: String + let kindLabel: String + let metadata: CommandPaletteSwitcherSearchMetadata +} + +struct CommandPaletteSwitcherFingerprintContext: Sendable { + let windowId: UUID + let windowLabel: String? + let selectedWorkspaceId: UUID? + let workspaces: [CommandPaletteSwitcherFingerprintWorkspace] +} + extension ContentView { static func commandPaletteViewCommandContributions() -> [CommandPaletteCommandContribution] { func constant(_ value: String) -> (CommandPaletteContextSnapshot) -> String { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e7e58e8dc44f..4979f0cd7c41 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1070,7 +1070,6 @@ struct ContentView: View { @StateObject private var fileExplorerStore = FileExplorerStore() @StateObject private var sessionIndexStore = SessionIndexStore() @StateObject private var selectedWorkspaceDirectoryObserver = SelectedWorkspaceDirectoryObserver() - @State private var commandPaletteOverlayRenderModel = CommandPaletteOverlayRenderModel() @State private var backgroundWorkspacePrimeCoordinator = BackgroundWorkspacePrimeCoordinator() @State private var fileExplorerWidth: CGFloat = 220 @State private var fileExplorerDragStartWidth: CGFloat? @@ -1095,18 +1094,15 @@ struct ContentView: View { @State private var commandPaletteWorkspaceDescriptionHeight: CGFloat = CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight @State private var commandPaletteSelectedResultIndex: Int = 0 @State private var commandPaletteSelectionAnchorCommandID: String? + @State private var commandPaletteHoveredResultIndex: Int? @State private var commandPaletteScrollTargetIndex: Int? @State private var commandPaletteScrollTargetAnchor: UnitPoint? @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? @State private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] = [] @State private var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] = [:] @State private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] = [:] - @State private var commandPaletteNucleoSearchIndex: CommandPaletteNucleoSearchIndex? - @State private var commandPaletteSearchIndexBuildTask: Task? - @State private var commandPaletteSearchIndexBuildGeneration: UInt64 = 0 @State private var cachedCommandPaletteResults: [CommandPaletteSearchResult] = [] @State private var commandPaletteVisibleResults: [CommandPaletteSearchResult] = [] - @State private var commandPaletteVisibleResultsVersion: UInt64 = 0 @State private var commandPaletteVisibleResultsScope: CommandPaletteListScope? @State private var commandPaletteVisibleResultsFingerprint: Int? @State private var cachedCommandPaletteScope: CommandPaletteListScope? @@ -1121,14 +1117,6 @@ struct ContentView: View { @State private var commandPaletteResolvedSearchFingerprint: Int? @State private var commandPaletteResolvedMatchingQuery = "" @State private var commandPaletteTerminalOpenTargetAvailability: Set = [] - @State private var commandPaletteForkableAgentActivePanelKey: String? - @State private var commandPaletteForkableAgentProbeIDsByPanelKey: [String: UUID] = [:] - @State var commandPaletteForkableAgentSupportedPanelKeys: Set = [] - @State var commandPaletteForkableAgentSnapshotsByPanelKey: [String: SessionRestorableAgentSnapshot] = [:] - @State var commandPaletteForkableAgentSnapshotFingerprintsByPanelKey: [String: String] = [:] - @State var commandPaletteForkableAgentRemoteContextsByPanelKey: [String: Bool] = [:] - @State private var commandPaletteForkableAgentAvailabilityTasksByPanelKey: [String: Task] = [:] - @State private var commandPaletteForkableAgentProbeFingerprintsByPanelKey: [String: String] = [:] @State private var isCommandPaletteSearchPending = false @State private var commandPalettePendingActivation: CommandPalettePendingActivation? @State private var commandPaletteResultsRevision: UInt64 = 0 @@ -1143,126 +1131,6 @@ struct ContentView: View { @FocusState private var isCommandPaletteSearchFocused: Bool @FocusState private var isCommandPaletteRenameFocused: Bool - private enum CommandPaletteMode { - case commands - case renameInput(CommandPaletteRenameTarget) - case renameConfirm(CommandPaletteRenameTarget, proposedName: String) - case workspaceDescriptionInput(CommandPaletteWorkspaceDescriptionTarget) - } - - enum CommandPalettePendingActivation: Equatable { - case selected(requestID: UInt64, fallbackSelectedIndex: Int, preferredCommandID: String?) - case command(requestID: UInt64, commandID: String) - } - - enum CommandPaletteResolvedActivation: Equatable { - case selected(index: Int) - case command(commandID: String) - } - - struct CommandPalettePendingActivationResolutionResult: Equatable { - let resolvedActivation: CommandPaletteResolvedActivation? - let shouldClearPendingActivation: Bool - } - - private struct CommandPaletteRenameTarget: Equatable { - enum Kind: Equatable { - case workspace(workspaceId: UUID) - case tab(workspaceId: UUID, panelId: UUID) - } - - let kind: Kind - let currentName: String - - var title: String { - switch kind { - case .workspace: - return String(localized: "commandPalette.rename.workspaceTitle", defaultValue: "Rename Workspace") - case .tab: - return String(localized: "commandPalette.rename.tabTitle", defaultValue: "Rename Tab") - } - } - - var description: String { - switch kind { - case .workspace: - return String(localized: "commandPalette.rename.workspaceDescription", defaultValue: "Choose a custom workspace name.") - case .tab: - return String(localized: "commandPalette.rename.tabDescription", defaultValue: "Choose a custom tab name.") - } - } - - var placeholder: String { - switch kind { - case .workspace: - return String(localized: "commandPalette.rename.workspacePlaceholder", defaultValue: "Workspace name") - case .tab: - return String(localized: "commandPalette.rename.tabPlaceholder", defaultValue: "Tab name") - } - } - } - - private struct CommandPaletteWorkspaceDescriptionTarget: Equatable { - let workspaceId: UUID - let currentDescription: String - - var placeholder: String { - String( - localized: "commandPalette.description.workspacePlaceholder", - defaultValue: "Workspace description" - ) - } - - var inputHint: String { - String( - localized: "commandPalette.description.workspaceInputHint", - defaultValue: "Press Enter to save. Press Shift-Enter for a new line, or Escape to cancel." - ) - } - } - - private struct CommandPaletteRestoreFocusTarget { - let workspaceId: UUID - let panelId: UUID - let intent: PanelFocusIntent - } - - private enum CommandPaletteInputFocusTarget { - case search - case rename - } - - private enum CommandPaletteTextSelectionBehavior { - case caretAtEnd - case selectAll - } - - private struct CommandPaletteInputFocusPolicy { - let focusTarget: CommandPaletteInputFocusTarget - let selectionBehavior: CommandPaletteTextSelectionBehavior - - static let search = CommandPaletteInputFocusPolicy( - focusTarget: .search, - selectionBehavior: .caretAtEnd - ) - } - - private struct CommandPaletteCommand: Identifiable { - let id: String - let rank: Int - let title: String - let subtitle: String - let shortcutHint: String? - let kindLabel: String? - let keywords: [String] - let dismissOnRun: Bool - let action: () -> Void - - var searchableTexts: [String] { - [title, subtitle] + keywords - } - } - static func tmuxWorkspacePaneExactRect( for panel: Panel, in contentView: NSView @@ -1407,156 +1275,6 @@ struct ContentView: View { ) } - struct CommandPaletteContextSnapshot { - private var boolValues: [String: Bool] = [:] - private var stringValues: [String: String] = [:] - - init() {} - - mutating func setBool(_ key: String, _ value: Bool) { - boolValues[key] = value - } - - mutating func setString(_ key: String, _ value: String?) { - guard let value, !value.isEmpty else { - stringValues.removeValue(forKey: key) - return - } - stringValues[key] = value - } - - func bool(_ key: String) -> Bool { - boolValues[key] ?? false - } - - func string(_ key: String) -> String? { - stringValues[key] - } - - func fingerprint() -> Int { - ContentView.commandPaletteContextFingerprint( - boolValues: boolValues, - stringValues: stringValues - ) - } - } - - private struct CommandPaletteCommandsContext { - let snapshot: CommandPaletteContextSnapshot - } - - enum CommandPaletteContextKeys { - static let hasWorkspace = "workspace.hasSelection" - static let workspaceName = "workspace.name" - static let workspaceHasCustomName = "workspace.hasCustomName" - static let workspaceHasCustomDescription = "workspace.hasCustomDescription" - static let workspaceMinimalModeEnabled = "workspace.minimalModeEnabled" - static let workspaceShouldPin = "workspace.shouldPin" - static let workspaceHasPullRequests = "workspace.hasPullRequests" - static let workspaceHasSplits = "workspace.hasSplits" - static let workspaceHasPeers = "workspace.hasPeers" - static let workspaceHasAbove = "workspace.hasAbove" - static let workspaceHasBelow = "workspace.hasBelow" - static let workspaceCanMarkRead = "workspace.canMarkRead" - static let workspaceCanMarkUnread = "workspace.canMarkUnread" - static let sidebarMatchTerminalBackground = "sidebar.matchTerminalBackground" - static let hasFocusedPanel = "panel.hasFocus" - static let panelName = "panel.name" - static let panelIsBrowser = "panel.isBrowser" - static let panelIsTerminal = "panel.isTerminal" - static let panelHasPane = "panel.hasPane" - static let panelHasForkableAgent = "panel.hasForkableAgent" - static let panelHasCustomName = "panel.hasCustomName" - static let panelShouldPin = "panel.shouldPin" - static let panelHasUnread = "panel.hasUnread" - static let panelCanMoveToNewWorkspace = "panel.canMoveToNewWorkspace" - static let updateHasAvailable = "update.hasAvailable" - static let cliInstalledInPATH = "cli.installedInPATH" - static let browserDisabled = "browser.disabled" - static func terminalOpenTargetAvailable(_ target: TerminalDirectoryOpenTarget) -> String { - "terminal.openTarget.\(target.rawValue).available" - } - } - - struct CommandPaletteCommandContribution { - let commandId: String - let title: (CommandPaletteContextSnapshot) -> String - let subtitle: (CommandPaletteContextSnapshot) -> String - let shortcutHint: String? - let keywords: [String] - let dismissOnRun: Bool - let when: (CommandPaletteContextSnapshot) -> Bool - let enablement: (CommandPaletteContextSnapshot) -> Bool - - init( - commandId: String, - title: @escaping (CommandPaletteContextSnapshot) -> String, - subtitle: @escaping (CommandPaletteContextSnapshot) -> String, - shortcutHint: String? = nil, - keywords: [String] = [], - dismissOnRun: Bool = true, - when: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true }, - enablement: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true } - ) { - self.commandId = commandId - self.title = title - self.subtitle = subtitle - self.shortcutHint = shortcutHint - self.keywords = keywords - self.dismissOnRun = dismissOnRun - self.when = when - self.enablement = enablement - } - } - - struct CommandPaletteHandlerRegistry { - private var handlers: [String: () -> Void] = [:] - - mutating func register(commandId: String, handler: @escaping () -> Void) { - handlers[commandId] = handler - } - - func handler(for commandId: String) -> (() -> Void)? { - handlers[commandId] - } - } - - private struct CommandPaletteSearchResult: Identifiable { - let command: CommandPaletteCommand - let score: Int - let titleMatchIndices: Set - - var id: String { command.id } - } - - private struct CommandPaletteSwitcherWindowContext { - let windowId: UUID - let tabManager: TabManager - let selectedWorkspaceId: UUID? - let windowLabel: String? - } - - struct CommandPaletteSwitcherFingerprintWorkspace: Sendable { - let id: UUID - let displayName: String - let metadata: CommandPaletteSwitcherSearchMetadata - let surfaces: [CommandPaletteSwitcherFingerprintSurface] - } - - struct CommandPaletteSwitcherFingerprintSurface: Sendable { - let id: UUID - let displayName: String - let kindLabel: String - let metadata: CommandPaletteSwitcherSearchMetadata - } - - struct CommandPaletteSwitcherFingerprintContext: Sendable { - let windowId: UUID - let windowLabel: String? - let selectedWorkspaceId: UUID? - let workspaces: [CommandPaletteSwitcherFingerprintWorkspace] - } - private static let fixedSidebarResizeCursor = NSCursor( image: NSCursor.resizeLeftRight.image, hotSpot: NSCursor.resizeLeftRight.hotSpot @@ -1564,7 +1282,7 @@ struct ContentView: View { private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1" nonisolated private static let commandPaletteCommandsPrefix = ">" private static let commandPaletteVisiblePreviewResultLimit = 48 - private static let commandPaletteVisiblePreviewCandidateLimit = 128 + private static let commandPaletteVisiblePreviewCandidateLimit = 192 private static let minimumSidebarWidth: CGFloat = CGFloat(SessionPersistencePolicy.minimumSidebarWidth) private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 private static let minimumRightSidebarWidth: CGFloat = 276 @@ -3714,7 +3432,16 @@ struct ContentView: View { } private var commandPaletteCommandListView: some View { - VStack(spacing: 0) { + let visibleResults = commandPaletteVisibleResults + let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) + let commandPaletteListIdentity = Self.commandPaletteListIdentity(for: commandPaletteQuery) + let shouldShowEmptyState = commandPaletteShouldShowEmptyState + let commandPaletteListMaxHeight: CGFloat = 450 + let commandPaletteRowHeight: CGFloat = 36 + let commandPaletteEmptyStateHeight: CGFloat = 44 + let commandPaletteListContentHeight = visibleResults.isEmpty ? commandPaletteEmptyStateHeight : CGFloat(visibleResults.count) * commandPaletteRowHeight + let commandPaletteListHeight = min(commandPaletteListMaxHeight, commandPaletteListContentHeight) + return VStack(spacing: 0) { HStack(spacing: 8) { CommandPaletteSearchFieldRepresentable( placeholder: commandPaletteSearchPlaceholder, @@ -3732,10 +3459,80 @@ struct ContentView: View { Divider() - CommandPaletteCommandListRenderView( - renderModel: commandPaletteOverlayRenderModel, - onRunResult: runCommandPaletteResult(commandID:) + ScrollView { + // Rebuild the full results container on scope transitions so + // stale switcher rows cannot linger above command-mode results. + VStack(spacing: 0) { + if visibleResults.isEmpty { + if shouldShowEmptyState { + Text(commandPaletteEmptyStateText) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 12) + } else { + Color.clear + .frame(maxWidth: .infinity) + .frame(height: commandPaletteEmptyStateHeight) + } + } else { + ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in + let isSelected = index == selectedIndex + let isHovered = commandPaletteHoveredResultIndex == index + let trailingLabel = commandPaletteTrailingLabel(for: result.command) + let rowBackground: Color = isSelected + ? cmuxAccentColor().opacity(0.12) + : (isHovered ? Color.primary.opacity(0.08) : .clear) + + Button { + runCommandPaletteResult(commandID: result.id) + } label: { + Self.commandPaletteResultLabelContent( + title: result.command.title, + matchedIndices: result.titleMatchIndices, + subtitle: result.command.subtitle, + trailingLabel: trailingLabel + ) + .padding(.horizontal, 9) + .padding(.vertical, 2) + .frame( + maxWidth: .infinity, + minHeight: commandPaletteRowHeight, + alignment: .leading + ) + .background(rowBackground) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("CommandPaletteResultRow.\(index)") + .accessibilityValue(result.id) + .id(index) + .onHover { hovering in + if hovering { + commandPaletteHoveredResultIndex = index + } else if commandPaletteHoveredResultIndex == index { + commandPaletteHoveredResultIndex = nil + } + } + } + } + } + .scrollTargetLayout() + } + .id(commandPaletteListIdentity) + .frame(height: commandPaletteListHeight) + .scrollPosition( + id: Binding( + get: { commandPaletteScrollTargetIndex }, + // Ignore passive readback so manual scrolling doesn't mutate selection-follow state. + set: { _ in } + ), + anchor: commandPaletteScrollTargetAnchor ) + .onChange(of: commandPaletteSelectedResultIndex) { _ in + updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: true) + } // Keep Esc-to-close behavior without showing footer controls. Button(action: { dismissCommandPalette() }) { @@ -3748,12 +3545,14 @@ struct ContentView: View { .accessibilityHidden(true) } .onAppear { + commandPaletteHoveredResultIndex = nil updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) resetCommandPaletteSearchFocus() } .onChange(of: commandPaletteQuery) { oldValue, newValue in commandPaletteSelectedResultIndex = 0 commandPaletteSelectionAnchorCommandID = nil + commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil if Self.commandPaletteShouldResetVisibleResultsForQueryTransition( @@ -3765,7 +3564,6 @@ struct ContentView: View { commandPaletteVisibleResults = [] commandPaletteVisibleResultsScope = nil commandPaletteVisibleResultsFingerprint = nil - commandPaletteVisibleResultsVersion &+= 1 } scheduleCommandPaletteResultsRefresh(query: newValue) updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) @@ -3794,12 +3592,12 @@ struct ContentView: View { syncCommandPaletteSelectionAnchorFromCurrentResults() let visibleResultCount = commandPaletteVisibleResults.count updateCommandPaletteScrollTarget(resultCount: visibleResultCount, animated: false) - syncCommandPaletteOverlayCommandListState() + if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResultCount { + commandPaletteHoveredResultIndex = nil + } syncCommandPaletteDebugStateForObservedWindow() } .onChange(of: commandPaletteSelectedResultIndex) { _ in - updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: true) - syncCommandPaletteOverlayCommandListState() syncCommandPaletteDebugStateForObservedWindow() } } @@ -4972,7 +4770,6 @@ struct ContentView: View { if commandPaletteTerminalOpenTargetAvailability != terminalOpenTargets { commandPaletteTerminalOpenTargetAvailability = terminalOpenTargets } - refreshCommandPaletteForkableAgentAvailabilityIfNeeded(scope: scope) let commandsContext = scope == .commands ? commandPaletteCommandsContext(terminalOpenTargets: terminalOpenTargets) : nil @@ -4985,15 +4782,28 @@ struct ContentView: View { return } - let entries = commandPaletteEntries( + let rawEntries = commandPaletteEntries( for: scope, includeSurfaces: includeSurfaces, commandsContext: commandsContext ) - commandPaletteSearchCommandsByID = CommandPaletteSearchOrchestrator.firstValueDictionary( - entries, - keyedBy: \.id - ) +#if DEBUG + let duplicateCommandIDs = Dictionary(grouping: rawEntries, by: \.id) + .compactMap { id, entries in entries.count > 1 ? id : nil } + .sorted() + if !duplicateCommandIDs.isEmpty { + cmuxDebugLog("commandPalette.duplicateCommandIDs ids=\(duplicateCommandIDs.joined(separator: ","))") + } +#endif + var seenCommandIDs: Set = [] + let entries = Array(rawEntries + .reversed() + .compactMap { (entry: CommandPaletteCommand) -> CommandPaletteCommand? in + guard seenCommandIDs.insert(entry.id).inserted else { return nil } + return entry + } + .reversed()) + commandPaletteSearchCommandsByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) let searchCorpus = entries.map { entry in CommandPaletteSearchCorpusEntry( payload: entry.id, @@ -5003,17 +4813,9 @@ struct ContentView: View { ) } commandPaletteSearchCorpus = searchCorpus - commandPaletteSearchCorpusByID = CommandPaletteSearchOrchestrator.firstValueDictionary( - searchCorpus, - keyedBy: \.payload - ) + commandPaletteSearchCorpusByID = Dictionary(uniqueKeysWithValues: searchCorpus.map { ($0.payload, $0) }) cachedCommandPaletteScope = scope cachedCommandPaletteFingerprint = fingerprint - scheduleCommandPaletteSearchIndexBuild( - entries: searchCorpus, - scope: scope, - fingerprint: fingerprint - ) } private func cancelCommandPaletteSearch() { @@ -5021,50 +4823,35 @@ struct ContentView: View { commandPaletteSearchTask = nil } - private func cancelCommandPaletteSearchIndexBuild() { - commandPaletteSearchIndexBuildTask?.cancel() - commandPaletteSearchIndexBuildTask = nil - commandPaletteSearchIndexBuildGeneration &+= 1 - } - - private func scheduleCommandPaletteSearchIndexBuild( - entries: [CommandPaletteSearchCorpusEntry], - scope: CommandPaletteListScope, - fingerprint: Int? - ) { - cancelCommandPaletteSearchIndexBuild() - commandPaletteNucleoSearchIndex = nil - let generation = commandPaletteSearchIndexBuildGeneration - commandPaletteSearchIndexBuildTask = Task.detached(priority: .userInitiated) { - let index = CommandPaletteNucleoSearchIndex(entries: entries) - guard !Task.isCancelled else { return } - - await MainActor.run { - guard commandPaletteSearchIndexBuildGeneration == generation, - cachedCommandPaletteScope == scope, - cachedCommandPaletteFingerprint == fingerprint else { - return - } - commandPaletteNucleoSearchIndex = index - commandPaletteSearchIndexBuildTask = nil - guard index != nil else { return } - if isCommandPalettePresented, - Self.commandPaletteListScope(for: commandPaletteQuery) == scope { - scheduleCommandPaletteResultsRefresh( - query: commandPaletteQuery, - preservePendingActivation: true - ) - } - } - } - } + nonisolated private static func commandPaletteResolvedSearchMatches( + searchCorpus: [CommandPaletteSearchCorpusEntry], + query: String, + usageHistory: [String: CommandPaletteUsageEntry], + queryIsEmpty: Bool, + historyTimestamp: TimeInterval, + shouldCancel: @escaping () -> Bool = { false } + ) -> [CommandPaletteResolvedSearchMatch] { + let results = CommandPaletteSearchEngine.search( + entries: searchCorpus, + query: query, + historyBoost: { commandId, _ in + Self.commandPaletteHistoryBoost( + for: commandId, + queryIsEmpty: queryIsEmpty, + history: usageHistory, + now: historyTimestamp + ) + }, + shouldCancel: shouldCancel + ) - nonisolated static func commandPaletteForkPriorityBoost(commandId: String, query: String) -> Int { - guard CommandPaletteFuzzyMatcher.normalizeForSearch(query) == "fork", - commandId == "palette.forkAgentConversationRight" else { - return 0 + return results.map { result in + CommandPaletteResolvedSearchMatch( + commandID: result.payload, + score: result.score, + titleMatchIndices: result.titleMatchIndices + ) } - return 10_000 } private static func commandPaletteMaterializedSearchResults( @@ -5089,59 +4876,150 @@ struct ContentView: View { commandPaletteVisibleResults = results commandPaletteVisibleResultsScope = scope commandPaletteVisibleResultsFingerprint = fingerprint - commandPaletteVisibleResultsVersion &+= 1 - syncCommandPaletteOverlayCommandListState() } - private func commandPaletteRenderTrailingLabel(for command: CommandPaletteCommand) -> CommandPaletteRenderTrailingLabel? { - if let shortcutHint = command.shortcutHint { - return CommandPaletteRenderTrailingLabel(text: shortcutHint, style: .shortcut) + private func refreshPendingCommandPaletteVisibleResults( + scope: CommandPaletteListScope, + fingerprint: Int?, + query: String, + usageHistory: [String: CommandPaletteUsageEntry], + queryIsEmpty: Bool, + historyTimestamp: TimeInterval + ) { + let candidateCommandIDs: [String] + if commandPaletteVisibleResultsScope == scope, + commandPaletteVisibleResultsFingerprint == fingerprint { + candidateCommandIDs = Self.commandPalettePreviewCandidateCommandIDs( + resultIDs: commandPaletteVisibleResults.map(\.id), + limit: Self.commandPaletteVisiblePreviewCandidateLimit + ) + } else { + candidateCommandIDs = [] } - if let kindLabel = command.kindLabel { - return CommandPaletteRenderTrailingLabel(text: kindLabel, style: .kind) - } - return nil + let previewMatches = Self.commandPalettePreviewSearchMatches( + scope: scope, + searchCorpus: commandPaletteSearchCorpus, + candidateCommandIDs: candidateCommandIDs, + searchCorpusByID: commandPaletteSearchCorpusByID, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp, + resultLimit: Self.commandPaletteVisiblePreviewResultLimit + ) + let previewResults = Self.commandPaletteMaterializedSearchResults( + matches: previewMatches, + commandsByID: commandPaletteSearchCommandsByID + ) + setCommandPaletteVisibleResults( + previewResults, + scope: scope, + fingerprint: fingerprint + ) } - private func commandPaletteOverlayCommandListStateSnapshot() -> CommandPaletteCommandListRenderState { - let rows = commandPaletteVisibleResults.map { result in - CommandPaletteRenderResultRow( - id: result.id, - title: result.command.title, - matchedIndices: result.titleMatchIndices, - trailingLabel: commandPaletteRenderTrailingLabel(for: result.command) - ) + nonisolated private static func commandPalettePreviewSearchMatches( + scope: CommandPaletteListScope, + searchCorpus: [CommandPaletteSearchCorpusEntry], + candidateCommandIDs: [String], + searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], + query: String, + usageHistory: [String: CommandPaletteUsageEntry], + queryIsEmpty: Bool, + historyTimestamp: TimeInterval, + resultLimit: Int + ) -> [CommandPaletteResolvedSearchMatch] { + guard resultLimit > 0 else { + return [] } - let selectedIndex = commandPaletteSelectedIndex(resultCount: rows.count) - return CommandPaletteCommandListRenderState( - resultsVersion: commandPaletteVisibleResultsVersion, - emptyStateText: commandPaletteEmptyStateText, - listIdentity: Self.commandPaletteListIdentity(for: commandPaletteQuery), - rows: rows, - selectedIndex: selectedIndex, - shouldShowEmptyState: commandPaletteShouldShowEmptyState, - scrollTargetID: commandPaletteScrollTargetID(rows: rows), - scrollTargetAnchor: commandPaletteScrollTargetAnchor + + guard !candidateCommandIDs.isEmpty else { + return [] + } + + var seenCommandIDs: Set = [] + let previewEntries: [CommandPaletteSearchCorpusEntry] = candidateCommandIDs.compactMap { commandID in + guard seenCommandIDs.insert(commandID).inserted else { return nil } + return searchCorpusByID[commandID] + } + guard !previewEntries.isEmpty else { + return [] + } + + let matches = commandPaletteResolvedSearchMatches( + searchCorpus: previewEntries, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp ) + guard matches.count > resultLimit else { + return matches + } + return Array(matches.prefix(resultLimit)) } - private func commandPaletteScrollTargetID(rows: [CommandPaletteRenderResultRow]) -> String? { - guard let index = commandPaletteScrollTargetIndex, - rows.indices.contains(index) else { - return nil - } - return rows[index].id + nonisolated static func commandPaletteCommandPreviewMatchCommandIDsForTests( + searchCorpus: [CommandPaletteSearchCorpusEntry], + candidateCommandIDs: [String], + searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], + query: String, + resultLimit: Int + ) -> [String] { + let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) + return commandPalettePreviewSearchMatches( + scope: .commands, + searchCorpus: searchCorpus, + candidateCommandIDs: candidateCommandIDs, + searchCorpusByID: searchCorpusByID, + query: query, + usageHistory: [:], + queryIsEmpty: preparedQuery.isEmpty, + historyTimestamp: 0, + resultLimit: resultLimit + ).map(\.commandID) + } + + static func commandPalettePreviewCandidateCommandIDs( + resultIDs: [String], + limit: Int + ) -> [String] { + guard limit > 0 else { return [] } + guard resultIDs.count > limit else { return resultIDs } + return Array(resultIDs.prefix(limit)) + } + + static func commandPaletteShouldSynchronouslySeedResults( + hasVisibleResultsForScope _: Bool + ) -> Bool { + true } - private func syncCommandPaletteOverlayCommandListState() { - commandPaletteOverlayRenderModel.scheduleCommandListUpdate(commandPaletteOverlayCommandListStateSnapshot()) + static func commandPaletteShouldPreserveEmptyStateWhileSearchPending( + isSearchPending: Bool, + visibleResultsScopeMatches: Bool, + resolvedSearchScopeMatches: Bool, + resolvedSearchFingerprintMatches: Bool, + resolvedResultsAreEmpty: Bool + ) -> Bool { + guard isSearchPending, + visibleResultsScopeMatches, + resolvedSearchScopeMatches, + resolvedSearchFingerprintMatches, + resolvedResultsAreEmpty else { + return false + } + + // The visible list is already empty at the call site. Keep the no-match + // message stable across any same-corpus pending query, including edits + // in the middle of the search text that are not prefix refinements. + return true } private func scheduleCommandPaletteResultsRefresh( query: String? = nil, - forceSearchCorpusRefresh: Bool = false, - preservePendingActivation: Bool = false + forceSearchCorpusRefresh: Bool = false ) { let effectiveQuery = Self.commandPaletteRefreshQuery( stateQuery: commandPaletteQuery, @@ -5162,50 +5040,26 @@ struct ContentView: View { let requestID = commandPaletteSearchRequestID let fingerprint = cachedCommandPaletteFingerprint let searchCorpus = commandPaletteSearchCorpus - let searchCorpusByID = commandPaletteSearchCorpusByID - let searchIndex = commandPaletteNucleoSearchIndex let commandsByID = commandPaletteSearchCommandsByID let usageHistory = commandPaletteUsageHistoryByCommandId let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(matchingQuery).isEmpty let historyTimestamp = Date().timeIntervalSince1970 - let additionalScoreBoost: (String, Bool) -> Int = { commandId, _ in - Self.commandPaletteForkPriorityBoost(commandId: commandId, query: matchingQuery) - } - let visiblePreviewResultLimit = Self.commandPaletteVisiblePreviewResultLimit - if preservePendingActivation { - commandPalettePendingActivation = Self.commandPalettePendingActivation( - commandPalettePendingActivation, - rebasedTo: requestID - ) - } else { - commandPalettePendingActivation = nil - } + commandPalettePendingActivation = nil cancelCommandPaletteSearch() - if CommandPaletteSearchOrchestrator.shouldSynchronouslySeedResults( - hasVisibleResultsForScope: commandPaletteVisibleResultsScope == scope, - hasSearchIndex: searchIndex != nil, - corpusCount: searchCorpus.count + if Self.commandPaletteShouldSynchronouslySeedResults( + hasVisibleResultsForScope: commandPaletteVisibleResultsScope == scope ) { - let matches = CommandPaletteSearchOrchestrator.resolvedSearchMatches( - searchIndex: searchIndex, + let matches = Self.commandPaletteResolvedSearchMatches( searchCorpus: searchCorpus, - searchCorpusByID: searchCorpusByID, query: matchingQuery, usageHistory: usageHistory, queryIsEmpty: queryIsEmpty, - historyTimestamp: historyTimestamp, - additionalScoreBoost: additionalScoreBoost + historyTimestamp: historyTimestamp ) cachedCommandPaletteResults = Self.commandPaletteMaterializedSearchResults( matches: matches, commandsByID: commandsByID ) - let resultIDs = cachedCommandPaletteResults.map(\.id) - let pendingActivationResolution = Self.commandPalettePendingActivationResolution( - commandPalettePendingActivation, - requestID: requestID, - resultIDs: resultIDs - ) commandPaletteResolvedSearchRequestID = requestID commandPaletteResolvedSearchScope = scope commandPaletteResolvedSearchFingerprint = fingerprint @@ -5216,93 +5070,26 @@ struct ContentView: View { scope: scope, fingerprint: fingerprint ) - if pendingActivationResolution.shouldClearPendingActivation { - commandPalettePendingActivation = nil - } commandPaletteResultsRevision &+= 1 - if let resolvedActivation = pendingActivationResolution.resolvedActivation { - runCommandPaletteResolvedActivation(resolvedActivation) - } return } - let previewCandidateCommandIDs: [String] - if commandPaletteVisibleResultsScope == scope, - commandPaletteVisibleResultsFingerprint == fingerprint, - !commandPaletteVisibleResults.isEmpty { - previewCandidateCommandIDs = CommandPaletteSearchOrchestrator.previewCandidateCommandIDs( - resultIDs: commandPaletteVisibleResults.map(\.id), - limit: Self.commandPaletteVisiblePreviewCandidateLimit - ) - } else { - previewCandidateCommandIDs = [] - } - let shouldApplyPreviewResults = scope == .commands || !previewCandidateCommandIDs.isEmpty + refreshPendingCommandPaletteVisibleResults( + scope: scope, + fingerprint: fingerprint, + query: matchingQuery, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp + ) isCommandPaletteSearchPending = true - syncCommandPaletteOverlayCommandListState() commandPaletteSearchTask = Task.detached(priority: .userInitiated) { - let previewMatches = shouldApplyPreviewResults - ? CommandPaletteSearchOrchestrator.previewSearchMatches( - scope: scope, - searchIndex: searchIndex, - searchCorpus: searchCorpus, - candidateCommandIDs: previewCandidateCommandIDs, - searchCorpusByID: searchCorpusByID, - query: matchingQuery, - usageHistory: usageHistory, - queryIsEmpty: queryIsEmpty, - historyTimestamp: historyTimestamp, - additionalScoreBoost: additionalScoreBoost, - resultLimit: visiblePreviewResultLimit - ) - : [] - - guard !Task.isCancelled else { return } - - await MainActor.run { - let currentScope = Self.commandPaletteListScope(for: commandPaletteQuery) - let currentMatchingQuery = Self.commandPaletteQueryForMatching( - query: commandPaletteQuery, - scope: currentScope - ) - let shouldApplyPreview = commandPaletteSearchRequestID == requestID - && isCommandPalettePresented - && currentScope == scope - && currentMatchingQuery == matchingQuery - && cachedCommandPaletteFingerprint == fingerprint - && isCommandPaletteSearchPending - guard shouldApplyPreview else { - return - } - guard shouldApplyPreviewResults else { - return - } - - let previewResults = Self.commandPaletteMaterializedSearchResults( - matches: previewMatches, - commandsByID: commandPaletteSearchCommandsByID - ) - setCommandPaletteVisibleResults( - previewResults, - scope: scope, - fingerprint: fingerprint - ) - updateCommandPaletteScrollTarget(resultCount: previewResults.count, animated: false) - syncCommandPaletteOverlayCommandListState() - syncCommandPaletteDebugStateForObservedWindow() - } - - guard !Task.isCancelled else { return } - - let matches = CommandPaletteSearchOrchestrator.resolvedSearchMatches( - searchIndex: searchIndex, + let matches = Self.commandPaletteResolvedSearchMatches( searchCorpus: searchCorpus, - searchCorpusByID: searchCorpusByID, query: matchingQuery, usageHistory: usageHistory, queryIsEmpty: queryIsEmpty, historyTimestamp: historyTimestamp, - additionalScoreBoost: additionalScoreBoost, shouldCancel: { Task.isCancelled } ) @@ -5328,8 +5115,9 @@ struct ContentView: View { commandsByID: commandPaletteSearchCommandsByID ) let resultIDs = cachedCommandPaletteResults.map(\.id) - let pendingActivationResolution = Self.commandPalettePendingActivationResolution( - commandPalettePendingActivation, + let pendingActivation = commandPalettePendingActivation + let resolvedActivation = Self.commandPaletteResolvedPendingActivation( + pendingActivation, requestID: requestID, resultIDs: resultIDs ) @@ -5343,14 +5131,14 @@ struct ContentView: View { scope: scope, fingerprint: fingerprint ) - if pendingActivationResolution.shouldClearPendingActivation { + if Self.commandPalettePendingActivationRequestID(pendingActivation) == requestID { commandPalettePendingActivation = nil } commandPaletteResultsRevision &+= 1 if commandPaletteSearchRequestID == requestID { commandPaletteSearchTask = nil } - if let resolvedActivation = pendingActivationResolution.resolvedActivation { + if let resolvedActivation { runCommandPaletteResolvedActivation(resolvedActivation) } } @@ -5452,7 +5240,7 @@ struct ContentView: View { } @ViewBuilder - private static func commandPaletteRenderTrailingLabelView(_ trailingLabel: CommandPaletteRenderTrailingLabel?) -> some View { + private static func commandPaletteTrailingLabelView(_ trailingLabel: CommandPaletteTrailingLabel?) -> some View { if let trailingLabel { switch trailingLabel.style { case .shortcut: @@ -5474,23 +5262,43 @@ struct ContentView: View { } } - static func commandPaletteRenderResultLabelContent( + private static func commandPaletteResultLabelContent( title: String, matchedIndices: Set, - trailingLabel: CommandPaletteRenderTrailingLabel? + subtitle: String?, + trailingLabel: CommandPaletteTrailingLabel? ) -> some View { - HStack(spacing: 8) { - commandPaletteHighlightedTitleText( - title, - matchedIndices: matchedIndices - ) - .font(.system(size: 13, weight: .regular)) - .lineLimit(1) - Spacer() - commandPaletteRenderTrailingLabelView(trailingLabel) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + commandPaletteHighlightedTitleText( + title, + matchedIndices: matchedIndices + ) + .font(.system(size: 13, weight: .regular)) + .lineLimit(1) + Spacer() + commandPaletteTrailingLabelView(trailingLabel) + } + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(.secondary) + .lineLimit(1) + } } } + private func commandPaletteTrailingLabel(for command: CommandPaletteCommand) -> CommandPaletteTrailingLabel? { + if let shortcutHint = command.shortcutHint { + return CommandPaletteTrailingLabel(text: shortcutHint, style: .shortcut) + } + + if let kindLabel = command.kindLabel { + return CommandPaletteTrailingLabel(text: kindLabel, style: .kind) + } + return nil + } + private func commandPaletteSwitcherEntries(includeSurfaces: Bool) -> [CommandPaletteCommand] { let windowContexts = commandPaletteSwitcherWindowContexts() guard !windowContexts.isEmpty else { return [] } @@ -5798,274 +5606,6 @@ struct ContentView: View { return TerminalDirectoryOpenTarget.availableTargets() } - static func commandPaletteForkableAgentPanelKey(workspaceId: UUID, panelId: UUID) -> String { - "\(workspaceId.uuidString):\(panelId.uuidString)" - } - - private enum CommandPaletteForkSnapshotAvailability { - case unsupported - case supportedWithoutProbe - case requiresProbe - } - - private static func commandPaletteSnapshotForkAvailability( - _ snapshot: SessionRestorableAgentSnapshot, - isRemoteTerminal: Bool = false - ) -> CommandPaletteForkSnapshotAvailability { - guard snapshot.forkCommand != nil else { return .unsupported } - if isRemoteTerminal, - snapshot.forkStartupInput(allowLauncherScript: false) == nil { - return .unsupported - } - switch snapshot.kind { - case .claude, .codex: - return .supportedWithoutProbe - case .opencode: - return snapshot.launchCommand?.launcher == "omo" || isRemoteTerminal ? .supportedWithoutProbe : .requiresProbe - default: - return .unsupported - } - } - - static func commandPaletteForkSnapshotFingerprint( - _ snapshot: SessionRestorableAgentSnapshot - ) -> String { - let launchCommand = snapshot.launchCommand - let launchArguments = launchCommand?.arguments.joined(separator: "\u{1f}") ?? "" - let parts: [String] = [ - snapshot.kind.rawValue, - snapshot.sessionId, - snapshot.workingDirectory ?? "", - launchCommand?.launcher ?? "", - launchCommand?.executablePath ?? "", - launchArguments, - launchCommand?.workingDirectory ?? "", - launchCommand?.source ?? "", - snapshot.forkCommand ?? "" - ] - return parts.joined(separator: "\u{1e}") - } - - static func commandPaletteForkCacheFingerprint( - snapshot: SessionRestorableAgentSnapshot, - fallbackFingerprint: String? - ) -> String { - fallbackFingerprint ?? commandPaletteForkSnapshotFingerprint(snapshot) - } - - static func commandPalettePanelHasForkableAgent( - workspaceId: UUID, - panelId: UUID, - supportedPanelKeys: Set, - supportedRemoteContextsByPanelKey: [String: Bool] = [:], - fallbackSnapshot: SessionRestorableAgentSnapshot?, - isRemoteTerminal: Bool = false - ) -> Bool { - let panelKey = commandPaletteForkableAgentPanelKey( - workspaceId: workspaceId, - panelId: panelId - ) - if supportedPanelKeys.contains(panelKey) { - if let fallbackSnapshot { - return commandPaletteSnapshotForkAvailability( - fallbackSnapshot, - isRemoteTerminal: isRemoteTerminal - ) != .unsupported - } - guard let supportedRemoteContext = supportedRemoteContextsByPanelKey[panelKey] else { - return true - } - return supportedRemoteContext == isRemoteTerminal - } - if let fallbackSnapshot { - return commandPaletteSnapshotForkAvailability( - fallbackSnapshot, - isRemoteTerminal: isRemoteTerminal - ) == .supportedWithoutProbe - } - return false - } - - private func refreshCommandPaletteForkableAgentAvailabilityIfNeeded(scope: CommandPaletteListScope) { - guard scope == .commands, - let panelContext = focusedPanelContext, - panelContext.panel.panelType == .terminal else { - commandPaletteForkableAgentActivePanelKey = nil - cancelCommandPaletteForkableAgentAvailabilityProbe() - return - } - - let workspaceId = panelContext.workspace.id - let panelId = panelContext.panelId - let isRemoteTerminal = panelContext.workspace.isRemoteTerminalSurface(panelId) - let panelKey = Self.commandPaletteForkableAgentPanelKey(workspaceId: workspaceId, panelId: panelId) - let panelChanged = commandPaletteForkableAgentActivePanelKey != panelKey - commandPaletteForkableAgentActivePanelKey = panelKey - let fallbackSnapshot = panelContext.workspace.restoredAgentSnapshotsByPanelId[panelId] - - if let fallbackSnapshot { - let fallbackFingerprint = Self.commandPaletteForkSnapshotFingerprint(fallbackSnapshot) - if let cachedFingerprint = commandPaletteForkableAgentSnapshotFingerprintsByPanelKey[panelKey], - cachedFingerprint != fallbackFingerprint { - cancelCommandPaletteForkableAgentAvailabilityProbe(for: panelKey) - commandPaletteForkableAgentSupportedPanelKeys.remove(panelKey) - commandPaletteForkableAgentSnapshotsByPanelKey.removeValue(forKey: panelKey) - commandPaletteForkableAgentSnapshotFingerprintsByPanelKey.removeValue(forKey: panelKey) - commandPaletteForkableAgentRemoteContextsByPanelKey.removeValue(forKey: panelKey) - } - switch Self.commandPaletteSnapshotForkAvailability( - fallbackSnapshot, - isRemoteTerminal: isRemoteTerminal - ) { - case .supportedWithoutProbe: - cancelCommandPaletteForkableAgentAvailabilityProbe(for: panelKey) - commandPaletteForkableAgentSupportedPanelKeys.insert(panelKey) - commandPaletteForkableAgentSnapshotsByPanelKey[panelKey] = fallbackSnapshot - commandPaletteForkableAgentSnapshotFingerprintsByPanelKey[panelKey] = fallbackFingerprint - commandPaletteForkableAgentRemoteContextsByPanelKey[panelKey] = isRemoteTerminal - return - case .unsupported: - cancelCommandPaletteForkableAgentAvailabilityProbe(for: panelKey) - commandPaletteForkableAgentSupportedPanelKeys.remove(panelKey) - commandPaletteForkableAgentSnapshotsByPanelKey.removeValue(forKey: panelKey) - commandPaletteForkableAgentSnapshotFingerprintsByPanelKey.removeValue(forKey: panelKey) - commandPaletteForkableAgentRemoteContextsByPanelKey.removeValue(forKey: panelKey) - return - case .requiresProbe: - if commandPaletteForkableAgentSupportedPanelKeys.contains(panelKey), - commandPaletteForkableAgentSnapshotFingerprintsByPanelKey[panelKey] == fallbackFingerprint, - commandPaletteForkableAgentRemoteContextsByPanelKey[panelKey] == isRemoteTerminal, - !panelChanged { - return - } - startCommandPaletteForkableAgentAvailabilityProbe( - panelKey: panelKey, - workspaceId: workspaceId, - panelId: panelId, - fallbackSnapshot: fallbackSnapshot, - fallbackFingerprint: fallbackFingerprint, - isRemoteTerminal: isRemoteTerminal - ) - return - } - } - - if commandPaletteForkableAgentSupportedPanelKeys.contains(panelKey), - commandPaletteForkableAgentRemoteContextsByPanelKey[panelKey] == isRemoteTerminal, - !panelChanged { - return - } - - startCommandPaletteForkableAgentAvailabilityProbe( - panelKey: panelKey, - workspaceId: workspaceId, - panelId: panelId, - fallbackSnapshot: nil, - fallbackFingerprint: nil, - isRemoteTerminal: isRemoteTerminal - ) - } - - private func startCommandPaletteForkableAgentAvailabilityProbe( - panelKey: String, - workspaceId: UUID, - panelId: UUID, - fallbackSnapshot: SessionRestorableAgentSnapshot?, - fallbackFingerprint: String?, - isRemoteTerminal: Bool - ) { - let probeFingerprint = "\(fallbackFingerprint ?? "")\u{1f}\(isRemoteTerminal ? "remote" : "local")" - if let task = commandPaletteForkableAgentAvailabilityTasksByPanelKey[panelKey] { - guard commandPaletteForkableAgentProbeFingerprintsByPanelKey[panelKey] != probeFingerprint else { return } - task.cancel() - commandPaletteForkableAgentAvailabilityTasksByPanelKey.removeValue(forKey: panelKey) - commandPaletteForkableAgentProbeIDsByPanelKey.removeValue(forKey: panelKey) - commandPaletteForkableAgentProbeFingerprintsByPanelKey.removeValue(forKey: panelKey) - } - let probeID = UUID() - commandPaletteForkableAgentProbeIDsByPanelKey[panelKey] = probeID - commandPaletteForkableAgentProbeFingerprintsByPanelKey[panelKey] = probeFingerprint - - commandPaletteForkableAgentAvailabilityTasksByPanelKey[panelKey] = Task { - let index = await RestorableAgentSessionIndex.loadIncludingProcessDetectedSnapshots() - guard !Task.isCancelled else { return } - let snapshot = index.snapshot(workspaceId: workspaceId, panelId: panelId) ?? fallbackSnapshot - let supportsFork: Bool - if let snapshot { - supportsFork = await AgentForkSupport.supportsFork( - snapshot: snapshot, - isRemoteContext: isRemoteTerminal - ) - } else { - supportsFork = false - } - guard !Task.isCancelled else { return } - - await MainActor.run { - guard commandPaletteForkableAgentProbeIDsByPanelKey[panelKey] == probeID else { return } - guard commandPaletteForkableAgentProbeFingerprintsByPanelKey[panelKey] == probeFingerprint else { return } - if let fallbackFingerprint, - let currentContext = focusedPanelContext, - currentContext.workspace.id == workspaceId, - currentContext.panelId == panelId, - let currentFallbackSnapshot = currentContext.workspace.restoredAgentSnapshotsByPanelId[panelId], - Self.commandPaletteForkSnapshotFingerprint(currentFallbackSnapshot) != fallbackFingerprint { - commandPaletteForkableAgentProbeIDsByPanelKey.removeValue(forKey: panelKey) - commandPaletteForkableAgentProbeFingerprintsByPanelKey.removeValue(forKey: panelKey) - commandPaletteForkableAgentAvailabilityTasksByPanelKey.removeValue(forKey: panelKey) - return - } - let wasSupported = commandPaletteForkableAgentSupportedPanelKeys.contains(panelKey) - let hadCachedSnapshot = commandPaletteForkableAgentSnapshotsByPanelKey[panelKey] != nil - let shouldRefreshResults: Bool - if supportsFork { - shouldRefreshResults = !wasSupported - commandPaletteForkableAgentSupportedPanelKeys.insert(panelKey) - commandPaletteForkableAgentRemoteContextsByPanelKey[panelKey] = isRemoteTerminal - if let snapshot { - commandPaletteForkableAgentSnapshotsByPanelKey[panelKey] = snapshot - commandPaletteForkableAgentSnapshotFingerprintsByPanelKey[panelKey] = Self.commandPaletteForkCacheFingerprint( - snapshot: snapshot, - fallbackFingerprint: fallbackFingerprint - ) - } - } else { - shouldRefreshResults = wasSupported || hadCachedSnapshot - commandPaletteForkableAgentSupportedPanelKeys.remove(panelKey) - commandPaletteForkableAgentSnapshotsByPanelKey.removeValue(forKey: panelKey) - commandPaletteForkableAgentSnapshotFingerprintsByPanelKey.removeValue(forKey: panelKey) - commandPaletteForkableAgentRemoteContextsByPanelKey.removeValue(forKey: panelKey) - } - commandPaletteForkableAgentProbeIDsByPanelKey.removeValue(forKey: panelKey) - commandPaletteForkableAgentProbeFingerprintsByPanelKey.removeValue(forKey: panelKey) - commandPaletteForkableAgentAvailabilityTasksByPanelKey.removeValue(forKey: panelKey) - if shouldRefreshResults, - isCommandPalettePresented, - commandPaletteForkableAgentActivePanelKey == panelKey { - scheduleCommandPaletteResultsRefresh( - query: commandPaletteQuery, - forceSearchCorpusRefresh: true - ) - } - } - } - } - - private func cancelCommandPaletteForkableAgentAvailabilityProbe() { - for task in commandPaletteForkableAgentAvailabilityTasksByPanelKey.values { - task.cancel() - } - commandPaletteForkableAgentAvailabilityTasksByPanelKey.removeAll() - commandPaletteForkableAgentProbeIDsByPanelKey.removeAll() - commandPaletteForkableAgentProbeFingerprintsByPanelKey.removeAll() - } - - private func cancelCommandPaletteForkableAgentAvailabilityProbe(for panelKey: String) { - commandPaletteForkableAgentAvailabilityTasksByPanelKey.removeValue(forKey: panelKey)?.cancel() - commandPaletteForkableAgentProbeIDsByPanelKey.removeValue(forKey: panelKey) - commandPaletteForkableAgentProbeFingerprintsByPanelKey.removeValue(forKey: panelKey) - } - private func commandPaletteCommandsContext( terminalOpenTargets: Set ) -> CommandPaletteCommandsContext { @@ -6209,6 +5749,10 @@ struct ContentView: View { snapshot.setBool(CommandPaletteContextKeys.workspaceMinimalModeEnabled, isMinimalMode) snapshot.setBool(CommandPaletteContextKeys.sidebarMatchTerminalBackground, sidebarMatchTerminalBackground) snapshot.setBool(CommandPaletteContextKeys.browserDisabled, BrowserAvailabilitySettings.isDisabled()) + snapshot.setBool( + CommandPaletteContextKeys.supportedFileRoutingDisabled, + !CmdClickSupportedFileRouteSettings.isEnabled() + ) if let workspace = tabManager.selectedWorkspace { let pinTarget = WorkspaceActionDispatcher.Target.single(workspace.id) @@ -6247,24 +5791,11 @@ struct ContentView: View { let workspace = panelContext.workspace let panelId = panelContext.panelId let panelIsTerminal = panelContext.panel.panelType == .terminal - let panelIsRemoteTerminal = workspace.isRemoteTerminalSurface(panelId) snapshot.setBool(CommandPaletteContextKeys.hasFocusedPanel, true) snapshot.setString(CommandPaletteContextKeys.panelName, panelDisplayName(workspace: workspace, panelId: panelId, fallback: panelContext.panel.displayTitle)) snapshot.setBool(CommandPaletteContextKeys.panelIsBrowser, panelContext.panel.panelType == .browser) snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelIsTerminal) snapshot.setBool(CommandPaletteContextKeys.panelHasPane, workspace.paneId(forPanelId: panelId) != nil) - let fallbackForkableSnapshot = workspace.restoredAgentSnapshotsByPanelId[panelId] - snapshot.setBool( - CommandPaletteContextKeys.panelHasForkableAgent, - Self.commandPalettePanelHasForkableAgent( - workspaceId: workspace.id, - panelId: panelId, - supportedPanelKeys: commandPaletteForkableAgentSupportedPanelKeys, - supportedRemoteContextsByPanelKey: commandPaletteForkableAgentRemoteContextsByPanelKey, - fallbackSnapshot: fallbackForkableSnapshot, - isRemoteTerminal: panelIsRemoteTerminal - ) - ) snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil) snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId)) snapshot.setBool(CommandPaletteContextKeys.panelCanMoveToNewWorkspace, workspace.panels.count > 1) @@ -6535,15 +6066,6 @@ struct ContentView: View { keywords: ["jump", "unread", "notification"] ) ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.toggleUnread", - title: constant(String(localized: "command.toggleUnread.title", defaultValue: "Toggle Unread")), - subtitle: constant(String(localized: "command.jumpUnread.subtitle", defaultValue: "Notifications")), - keywords: ["toggle", "mark", "read", "unread", "notification"], - when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } - ) - ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.markOldestUnreadAndJumpNext", @@ -6626,6 +6148,24 @@ struct ContentView: View { when: { $0.bool(CommandPaletteContextKeys.browserDisabled) } ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.disableSupportedFileRouting", + title: constant(String(localized: "command.disableSupportedFileRouting.title", defaultValue: "Disable Cmd-click File Previews")), + subtitle: constant(String(localized: "command.supportedFileRouting.subtitle", defaultValue: "File Preview")), + keywords: ["file", "preview", "disable", "external", "editor", "pdf", "image", "audio", "video"], + when: { !$0.bool(CommandPaletteContextKeys.supportedFileRoutingDisabled) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.enableSupportedFileRouting", + title: constant(String(localized: "command.enableSupportedFileRouting.title", defaultValue: "Enable Cmd-click File Previews")), + subtitle: constant(String(localized: "command.supportedFileRouting.subtitle", defaultValue: "File Preview")), + keywords: ["file", "preview", "enable", "cmux", "pdf", "image", "audio", "video"], + when: { $0.bool(CommandPaletteContextKeys.supportedFileRoutingDisabled) } + ) + ) contributions.append(contentsOf: Self.commandPaletteSettingsToggleCommandContributions()) contributions.append( @@ -7135,66 +6675,6 @@ struct ContentView: View { when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } ) ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.forkAgentConversationRight", - title: constant(String(localized: "command.forkAgentConversationRight.title", defaultValue: "Fork Conversation to the Right")), - subtitle: terminalPanelSubtitle, - keywords: ["terminal", "agent", "fork", "conversation", "session", "claude", "codex", "opencode", "right", "split"], - when: { - $0.bool(CommandPaletteContextKeys.panelIsTerminal) && - $0.bool(CommandPaletteContextKeys.panelHasForkableAgent) - } - ) - ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.forkAgentConversationLeft", - title: constant(String(localized: "command.forkAgentConversationLeft.title", defaultValue: "Fork Conversation to the Left")), - subtitle: terminalPanelSubtitle, - keywords: ["terminal", "agent", "fork", "conversation", "session", "claude", "codex", "opencode", "left", "split"], - when: { - $0.bool(CommandPaletteContextKeys.panelIsTerminal) && - $0.bool(CommandPaletteContextKeys.panelHasForkableAgent) - } - ) - ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.forkAgentConversationTop", - title: constant(String(localized: "command.forkAgentConversationTop.title", defaultValue: "Fork Conversation to the Top")), - subtitle: terminalPanelSubtitle, - keywords: ["terminal", "agent", "fork", "conversation", "session", "claude", "codex", "opencode", "top", "up", "above", "split"], - when: { - $0.bool(CommandPaletteContextKeys.panelIsTerminal) && - $0.bool(CommandPaletteContextKeys.panelHasForkableAgent) - } - ) - ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.forkAgentConversationBottom", - title: constant(String(localized: "command.forkAgentConversationBottom.title", defaultValue: "Fork Conversation to the Bottom")), - subtitle: terminalPanelSubtitle, - keywords: ["terminal", "agent", "fork", "conversation", "session", "claude", "codex", "opencode", "bottom", "down", "below", "split"], - when: { - $0.bool(CommandPaletteContextKeys.panelIsTerminal) && - $0.bool(CommandPaletteContextKeys.panelHasForkableAgent) - } - ) - ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.forkAgentConversationNewWorkspace", - title: constant(String(localized: "command.forkAgentConversationNewWorkspace.title", defaultValue: "Fork Conversation to New Workspace")), - subtitle: workspaceSubtitle, - keywords: ["terminal", "agent", "fork", "conversation", "session", "claude", "codex", "opencode", "new", "workspace"], - when: { - $0.bool(CommandPaletteContextKeys.panelIsTerminal) && - $0.bool(CommandPaletteContextKeys.panelHasForkableAgent) - } - ) - ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.terminalSplitDown", @@ -7480,11 +6960,6 @@ struct ContentView: View { registry.register(commandId: "palette.jumpUnread") { AppDelegate.shared?.jumpToLatestUnread() } - registry.register(commandId: "palette.toggleUnread") { - AppDelegate.shared?.toggleFocusedNotificationUnread( - preferredWindow: observedWindow - ) - } registry.register(commandId: "palette.markOldestUnreadAndJumpNext") { AppDelegate.shared?.markFocusedNotificationAsOldestUnreadAndJumpToNextLatestUnread( preferredWindow: observedWindow @@ -7527,6 +7002,12 @@ struct ContentView: View { registry.register(commandId: "palette.enableBrowser") { BrowserAvailabilitySettings.setDisabled(false) } + registry.register(commandId: "palette.disableSupportedFileRouting") { + CmdClickSupportedFileRouteSettings.setEnabled(false) + } + registry.register(commandId: "palette.enableSupportedFileRouting") { + CmdClickSupportedFileRouteSettings.setEnabled(true) + } registerSettingsToggleCommandHandlers(®istry) registry.register(commandId: "palette.renameWorkspace") { @@ -7775,21 +7256,6 @@ struct ContentView: View { tabManager.createSplit(direction: .right) } } - registry.register(commandId: "palette.forkAgentConversationRight") { - forkFocusedAgentConversationRight() - } - registry.register(commandId: "palette.forkAgentConversationLeft") { - forkFocusedAgentConversationLeft() - } - registry.register(commandId: "palette.forkAgentConversationTop") { - forkFocusedAgentConversationTop() - } - registry.register(commandId: "palette.forkAgentConversationBottom") { - forkFocusedAgentConversationBottom() - } - registry.register(commandId: "palette.forkAgentConversationNewWorkspace") { - forkFocusedAgentConversationToNewWorkspace() - } registry.register(commandId: "palette.terminalSplitDown") { if !executeConfiguredAction(id: CmuxSurfaceTabBarBuiltInAction.splitDown.configID) { tabManager.createSplit(direction: .down) @@ -7948,24 +7414,6 @@ struct ContentView: View { } } - static func commandPalettePendingActivation( - _ pendingActivation: CommandPalettePendingActivation?, - rebasedTo requestID: UInt64 - ) -> CommandPalettePendingActivation? { - switch pendingActivation { - case .selected(_, let fallbackSelectedIndex, let preferredCommandID): - return .selected( - requestID: requestID, - fallbackSelectedIndex: fallbackSelectedIndex, - preferredCommandID: preferredCommandID - ) - case .command(_, let commandID): - return .command(requestID: requestID, commandID: commandID) - case nil: - return nil - } - } - static func commandPaletteResolvedPendingActivation( _ pendingActivation: CommandPalettePendingActivation?, requestID: UInt64, @@ -7988,21 +7436,6 @@ struct ContentView: View { } } - static func commandPalettePendingActivationResolution( - _ pendingActivation: CommandPalettePendingActivation?, - requestID: UInt64, - resultIDs: [String] - ) -> CommandPalettePendingActivationResolutionResult { - CommandPalettePendingActivationResolutionResult( - resolvedActivation: commandPaletteResolvedPendingActivation( - pendingActivation, - requestID: requestID, - resultIDs: resultIDs - ), - shouldClearPendingActivation: commandPalettePendingActivationRequestID(pendingActivation) == requestID - ) - } - static func commandPaletteContextFingerprint( boolValues: [String: Bool], stringValues: [String: String] @@ -8127,8 +7560,6 @@ struct ContentView: View { } else { syncCommandPaletteSelectionAnchorFromVisibleResults() } - updateCommandPaletteScrollTarget(resultCount: count, animated: true) - syncCommandPaletteOverlayCommandListState() syncCommandPaletteDebugStateForObservedWindow() } @@ -8189,7 +7620,7 @@ struct ContentView: View { return true } - return CommandPaletteSearchOrchestrator.shouldPreserveEmptyStateWhileSearchPending( + return Self.commandPaletteShouldPreserveEmptyStateWhileSearchPending( isSearchPending: isCommandPaletteSearchPending, visibleResultsScopeMatches: commandPaletteVisibleResultsScope == commandPaletteListScope, resolvedSearchScopeMatches: commandPaletteResolvedSearchScope == commandPaletteListScope, @@ -8415,7 +7846,7 @@ struct ContentView: View { commandId: result.command.id, title: result.command.title, shortcutHint: result.command.shortcutHint, - trailingLabel: commandPaletteRenderTrailingLabel(for: result.command)?.text, + trailingLabel: commandPaletteTrailingLabel(for: result.command)?.text, score: result.score ) } @@ -8438,7 +7869,6 @@ struct ContentView: View { commandPaletteRestoreFocusTarget = nil } isCommandPalettePresented = true - commandPaletteForkableAgentActivePanelKey = nil refreshCommandPaletteUsageHistory() resetCommandPaletteListState(initialQuery: initialQuery) } @@ -8451,11 +7881,11 @@ struct ContentView: View { commandPaletteWorkspaceDescriptionHeight = CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight commandPaletteSelectedResultIndex = 0 commandPaletteSelectionAnchorCommandID = nil + commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil commandPaletteShouldFocusWorkspaceDescriptionEditor = false scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: true) - syncCommandPaletteOverlayCommandListState() resetCommandPaletteSearchFocus() syncCommandPaletteDebugStateForObservedWindow() } @@ -8484,9 +7914,6 @@ struct ContentView: View { } #endif cancelCommandPaletteSearch() - cancelCommandPaletteSearchIndexBuild() - cancelCommandPaletteForkableAgentAvailabilityProbe() - commandPaletteForkableAgentActivePanelKey = nil commandPaletteSearchRequestID &+= 1 isCommandPalettePresented = false commandPaletteMode = .commands @@ -8496,6 +7923,7 @@ struct ContentView: View { commandPaletteWorkspaceDescriptionHeight = CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight commandPaletteSelectedResultIndex = 0 commandPaletteSelectionAnchorCommandID = nil + commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil commandPaletteShouldFocusWorkspaceDescriptionEditor = false @@ -8505,12 +7933,10 @@ struct ContentView: View { commandPaletteSearchCorpus = [] commandPaletteSearchCorpusByID = [:] commandPaletteSearchCommandsByID = [:] - commandPaletteNucleoSearchIndex = nil cachedCommandPaletteResults = [] commandPaletteVisibleResults = [] commandPaletteVisibleResultsScope = nil commandPaletteVisibleResultsFingerprint = nil - commandPaletteVisibleResultsVersion &+= 1 cachedCommandPaletteScope = nil cachedCommandPaletteFingerprint = nil commandPalettePendingTextSelectionBehavior = nil @@ -8521,7 +7947,6 @@ struct ContentView: View { isCommandPaletteSearchPending = false commandPalettePendingActivation = nil commandPaletteResultsRevision &+= 1 - syncCommandPaletteOverlayCommandListState() if let window = observedWindow { _ = window.makeFirstResponder(nil) } @@ -8891,8 +8316,24 @@ struct ContentView: View { persistCommandPaletteUsageHistory(history) } + nonisolated private static func commandPaletteHistoryBoost( + for commandId: String, + queryIsEmpty: Bool, + history: [String: CommandPaletteUsageEntry], + now: TimeInterval + ) -> Int { + guard let entry = history[commandId] else { return 0 } + + let ageDays = max(0, now - entry.lastUsedAt) / 86_400 + let recencyBoost = max(0, 320 - Int(ageDays * 20)) + let countBoost = min(180, entry.useCount * 12) + let totalBoost = recencyBoost + countBoost + + return queryIsEmpty ? totalBoost : max(0, totalBoost / 3) + } + private func commandPaletteHistoryBoost(for commandId: String, queryIsEmpty: Bool) -> Int { - CommandPaletteSearchOrchestrator.historyBoost( + Self.commandPaletteHistoryBoost( for: commandId, queryIsEmpty: queryIsEmpty, history: commandPaletteUsageHistoryByCommandId, diff --git a/Sources/DockPanelView.swift b/Sources/DockPanelView.swift index a2738485291f..8921930db4be 100644 --- a/Sources/DockPanelView.swift +++ b/Sources/DockPanelView.swift @@ -796,7 +796,7 @@ private struct DockTerminalView: View { searchState: attachment.searchState, reattachToken: attachment.reattachToken, onFocus: { _ in - onKeyboardFocusIntent(attachment.terminalSurface.uiWindow) + onKeyboardFocusIntent(attachment.terminalSurface.hostedView.window) }, onTriggerFlash: { onTriggerFlash() diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 7f4658859973..f810c00d19b0 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -1,7 +1,7 @@ import AppKit import CMUXWorkstream import Foundation -@preconcurrency import UserNotifications +import UserNotifications /// App-level coordinator that owns the shared `WorkstreamStore` and /// mediates between the socket thread (which processes `feed.*` V2 @@ -69,7 +69,7 @@ final class FeedCoordinator: @unchecked Sendable { src.setEventHandler { [weak self] in Task { @MainActor in guard let self else { return } - self.store?.expireItems(forPpid: ppid) + self.expirePendingItemsAndWaiters(forPpid: ppid) self.pidWatchers[ppid]?.cancel() self.pidWatchers.removeValue(forKey: ppid) } @@ -179,32 +179,85 @@ final class FeedCoordinator: @unchecked Sendable { cancelNotification(requestId: requestId) } + @MainActor + private func expirePendingItemsAndWaiters(forPpid ppid: Int) { + guard let store else { return } + let requestIds = Self.pendingRequestIds(forPpid: ppid, in: store.items) + store.expireItems(forPpid: ppid) + signalExpiredWaiters(requestIds: requestIds) + } + +#if DEBUG + @MainActor + func debugExpirePendingItemsAndWaiters(forPpid ppid: Int) { + expirePendingItemsAndWaiters(forPpid: ppid) + } +#endif + + private func signalExpiredWaiters(requestIds: Set) { + guard !requestIds.isEmpty else { return } + waiterLock.lock() + let expiredWaiters = requestIds.compactMap { waiters.removeValue(forKey: $0) } + waiterLock.unlock() + + for waiter in expiredWaiters { + waiter.semaphore.signal() + } + } + + @MainActor fileprivate func isAwaitingDecision(requestId: String) -> Bool { waiterLock.lock() - defer { waiterLock.unlock() } - guard let waiter = waiters[requestId] else { return false } - return waiter.decision == nil + let waiter = waiters[requestId] + waiterLock.unlock() + guard let waiter, waiter.decision == nil else { return false } + guard let store, + let item = Self.findItem(for: requestId, in: store.items) else { + return false + } + return item.status.isPending } - private static func findItemId( + private static func requestId(for item: WorkstreamItem) -> String? { + switch item.payload { + case .permissionRequest(let requestId, _, _, _): + return requestId + case .exitPlan(let requestId, _, _): + return requestId + case .question(let requestId, _): + return requestId + default: + return nil + } + } + + private static func pendingRequestIds( + forPpid ppid: Int, + in items: [WorkstreamItem] + ) -> Set { + Set(items.compactMap { item in + guard item.status.isPending, item.ppid == ppid else { return nil } + return requestId(for: item) + }) + } + + private static func findItem( for requestId: String, in items: [WorkstreamItem] - ) -> UUID? { - for item in items.reversed() { - switch item.payload { - case .permissionRequest(let rid, _, _, _) where rid == requestId: - return item.id - case .exitPlan(let rid, _, _) where rid == requestId: - return item.id - case .question(let rid, _) where rid == requestId: - return item.id - default: - continue - } + ) -> WorkstreamItem? { + for item in items.reversed() where Self.requestId(for: item) == requestId { + return item } return nil } + private static func findItemId( + for requestId: String, + in items: [WorkstreamItem] + ) -> UUID? { + findItem(for: requestId, in: items)?.id + } + private func expireTimedOutItem(_ itemId: UUID?) { guard let itemId else { return } let expire: @Sendable () -> Void = { [itemId] in diff --git a/Sources/GhosttyCrashBreadcrumb.swift b/Sources/GhosttyCrashBreadcrumb.swift index cdf9e0056385..cf4ffb14ca85 100644 --- a/Sources/GhosttyCrashBreadcrumb.swift +++ b/Sources/GhosttyCrashBreadcrumb.swift @@ -10,7 +10,7 @@ nonisolated enum GhosttyCrashBreadcrumb { static let lastShownCrashDefaultsKey = "ghosttyCrashBreadcrumb.lastShownCrashAt" static let notificationTabId = UUID(uuidString: "00000000-0000-0000-0000-000000003873")! - nonisolated static var defaultCrashDirectoryURL: URL { + static var defaultCrashDirectoryURL: URL { FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".local/state/cmux/crash", isDirectory: true) } @@ -22,7 +22,7 @@ nonisolated enum GhosttyCrashBreadcrumb { }.value } - nonisolated static func pendingCrash( + static func pendingCrash( in crashDirectoryURL: URL = defaultCrashDirectoryURL, defaults: UserDefaults = .standard, fileManager: FileManager = .default, @@ -44,11 +44,11 @@ nonisolated enum GhosttyCrashBreadcrumb { return latest } - nonisolated static func markShown(_ pendingCrash: PendingCrash, defaults: UserDefaults = .standard) { + static func markShown(_ pendingCrash: PendingCrash, defaults: UserDefaults = .standard) { defaults.set(pendingCrash.modifiedAt, forKey: lastShownCrashDefaultsKey) } - nonisolated static func markCleanExit(defaults: UserDefaults = .standard, date: Date = Date()) { + static func markCleanExit(defaults: UserDefaults = .standard, date: Date = Date()) { defaults.set(date, forKey: lastCleanExitDefaultsKey) } @@ -85,3 +85,89 @@ nonisolated enum GhosttyCrashBreadcrumb { return reportedExecutablePaths.contains(currentExecutablePath) } } + +private enum GhosttyCrashReportMetadata { + static func reportedExecutablePaths(in url: URL) -> Set? { + guard let data = try? Data(contentsOf: url, options: .mappedIfSafe), + let event = sentryEvent(from: data), + let debugMeta = event["debug_meta"] as? [String: Any], + let images = debugMeta["images"] as? [[String: Any]] + else { + return nil + } + + let paths = images.compactMap { image -> String? in + guard let codeFile = image["code_file"] as? String else { return nil } + let trimmedPath = codeFile.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPath.isEmpty else { return nil } + return normalizedPath(trimmedPath) + } + return paths.isEmpty ? nil : Set(paths) + } + + static func normalizedPath(_ path: String) -> String { + URL(fileURLWithPath: path) + .standardizedFileURL + .resolvingSymlinksInPath() + .path + } + + private static func sentryEvent(from data: Data) -> [String: Any]? { + guard let envelopeHeaderRange = lineRange(after: data.startIndex, in: data) else { + return nil + } + + var itemHeaderStart = data.index(after: envelopeHeaderRange.upperBound) + while itemHeaderStart < data.endIndex { + guard let itemHeaderRange = lineRange(after: itemHeaderStart, in: data), + let itemHeader = jsonObject(in: itemHeaderRange, from: data) + else { + return nil + } + + let payloadStart = data.index(after: itemHeaderRange.upperBound) + let payloadRange: Range + if let length = itemHeader["length"] as? Int { + guard length >= 0, + let payloadEnd = data.index(payloadStart, offsetBy: length, limitedBy: data.endIndex) + else { + return nil + } + payloadRange = payloadStart.. Range? { + guard startIndex < data.endIndex, + let newlineIndex = data[startIndex...].firstIndex(of: 0x0A) + else { + return nil + } + return startIndex.., from data: Data) -> [String: Any]? { + guard range.lowerBound <= range.upperBound else { return nil } + guard let object = try? JSONSerialization.jsonObject(with: data.subdata(in: range)) else { + return nil + } + return object as? [String: Any] + } +} diff --git a/Sources/GhosttyCrashReportMetadata.swift b/Sources/GhosttyCrashReportMetadata.swift deleted file mode 100644 index 959f5f9030ed..000000000000 --- a/Sources/GhosttyCrashReportMetadata.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Foundation - -nonisolated enum GhosttyCrashReportMetadata { - static func reportedExecutablePaths(in url: URL) -> Set? { - guard let data = try? Data(contentsOf: url, options: .mappedIfSafe), - let event = sentryEvent(from: data), - let debugMeta = event["debug_meta"] as? [String: Any], - let images = debugMeta["images"] as? [[String: Any]] - else { - return nil - } - - let paths = images.compactMap { image -> String? in - guard let codeFile = image["code_file"] as? String else { return nil } - let trimmedPath = codeFile.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedPath.isEmpty else { return nil } - return normalizedPath(trimmedPath) - } - return paths.isEmpty ? nil : Set(paths) - } - - static func normalizedPath(_ path: String) -> String { - URL(fileURLWithPath: path) - .standardizedFileURL - .resolvingSymlinksInPath() - .path - } - - private static func sentryEvent(from data: Data) -> [String: Any]? { - guard let envelopeHeaderRange = lineRange(after: data.startIndex, in: data) else { - return nil - } - - var itemHeaderStart = data.index(after: envelopeHeaderRange.upperBound) - while itemHeaderStart < data.endIndex { - guard let itemHeaderRange = lineRange(after: itemHeaderStart, in: data), - let itemHeader = jsonObject(in: itemHeaderRange, from: data) - else { - return nil - } - - let payloadStart = data.index(after: itemHeaderRange.upperBound) - let payloadRange: Range - if let length = itemHeader["length"] as? Int { - guard length >= 0, - let payloadEnd = data.index(payloadStart, offsetBy: length, limitedBy: data.endIndex) - else { - return nil - } - payloadRange = payloadStart.. Range? { - guard startIndex < data.endIndex, - let newlineIndex = data[startIndex...].firstIndex(of: 0x0A) - else { - return nil - } - return startIndex.., from data: Data) -> [String: Any]? { - guard range.lowerBound <= range.upperBound else { return nil } - guard let object = try? JSONSerialization.jsonObject(with: data.subdata(in: range)) else { - return nil - } - return object as? [String: Any] - } -} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 793377d31385..ca95c31f44ad 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1843,6 +1843,12 @@ class GhosttyApp { if ProcessInfo.processInfo.environment["CMUX_DEBUG_LOG"] != nil { return true } + if ProcessInfo.processInfo.environment["GHOSTTYTABS_DEBUG_BG"] == "1" { + return true + } + if UserDefaults.standard.bool(forKey: "cmuxDebugBG") { + return true + } return UserDefaults.standard.bool(forKey: "cmuxDebugBG") }() private let backgroundLogURL = GhosttyApp.resolveBackgroundLogURL() @@ -4720,56 +4726,21 @@ final class TerminalSurface: Identifiable, ObservableObject { let keycode: UInt32 let mods: ghostty_input_mods_e let label: String - - var queuedByteCost: Int { - max(label.utf8.count, 1) - } } private enum PendingSocketInput { case pasteText(Data) - case inputText(String) + case keyText(String) case key(PendingKeyEvent) var estimatedBytes: Int { switch self { case .pasteText(let data): return data.count - case .inputText(let text): + case .keyText(let text): return text.utf8.count case .key(let event): - return event.queuedByteCost - } - } - } - - private enum ParsedSocketInput { - case text(String) - case key(PendingKeyEvent) - } - - enum NamedKeySendResult: Equatable { - case sent - case queued - case unknownKey - case inputQueueFull - case surfaceUnavailable - case processExited - } - - enum InputSendResult: Equatable { - case sent - case queued - case inputQueueFull - case surfaceUnavailable - case processExited - - var accepted: Bool { - switch self { - case .sent, .queued: - return true - case .inputQueueFull, .surfaceUnavailable, .processExited: - return false + return max(event.label.utf8.count, 1) } } } @@ -4791,25 +4762,11 @@ final class TerminalSurface: Identifiable, ObservableObject { /// Use the hosted view rather than the inner surface view, since the surface can be /// temporarily unattached (surface not yet created / reparenting) even while the panel /// is already in the window. - var uiWindow: NSWindow? { - guard let window = hostedView.window else { return nil } - if let headlessStartupWindow, window === headlessStartupWindow { - return nil - } - return window - } - - var isViewInWindow: Bool { uiWindow != nil } - - func isHeadlessStartupWindow(_ window: NSWindow?) -> Bool { - guard let window, let headlessStartupWindow else { return false } - return window === headlessStartupWindow - } + var isViewInWindow: Bool { hostedView.window != nil } let id: UUID private(set) var tabId: UUID - /// Port ordinal for CMUX_PORT range assignment. Captured at construction so - /// every runtime startup path uses the same immutable workspace port range. - private let portOrdinal: Int + /// Port ordinal for CMUX_PORT range assignment + 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: AutomationSettings.portBaseKey) @@ -4840,13 +4797,10 @@ final class TerminalSurface: Identifiable, ObservableObject { private var runtimeSurfaceCreatedAt: Date? private var teardownRequestedAt: Date? private var teardownRequestReason: String? - // Main-thread only. Public socket send entrypoints are MainActor-isolated - // before reading `surface` or mutating this pending queue. private var pendingSocketInputQueue: [PendingSocketInput] = [] private var pendingSocketInputBytes: Int = 0 private let maxPendingSocketInputBytes = 1_048_576 private var backgroundSurfaceStartQueued = false - private var headlessStartupWindow: NSWindow? private var surfaceCallbackContext: Unmanaged? /// The desired focus state for the Ghostty C surface. May be set before the /// C surface exists (e.g. during layout restoration); `createSurface` @@ -4861,7 +4815,6 @@ final class TerminalSurface: Identifiable, ObservableObject { #if DEBUG private var needsConfirmCloseOverrideForTesting: Bool? private var runtimeSurfaceFreedOutOfBandForTesting = false - private var runtimeSurfaceCreateAttemptCountForTesting = 0 private let debugForceRefreshCountLock = NSLock() private var debugForceRefreshCountValue = 0 #endif @@ -4929,7 +4882,6 @@ final class TerminalSurface: Identifiable, ObservableObject { context: ghostty_surface_context_e, configTemplate: CmuxSurfaceConfigTemplate?, workingDirectory: String? = nil, - portOrdinal: Int = 0, initialCommand: String? = nil, tmuxStartCommand: String? = nil, initialInput: String? = nil, @@ -4946,7 +4898,6 @@ final class TerminalSurface: Identifiable, ObservableObject { self.surfaceContext = context self.configTemplate = configTemplate self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) - self.portOrdinal = portOrdinal let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil let trimmedTmuxStartCommand = tmuxStartCommand?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -4962,25 +4913,9 @@ final class TerminalSurface: Identifiable, ObservableObject { let view = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) self.surfaceView = view self.hostedView = GhosttySurfaceScrollView(surfaceView: view) + // Surface is created when attached to a view + hostedView.attachSurface(self) TerminalSurfaceRegistry.shared.register(self) - self.hostedView.attachSurface(self) - - let inheritedCommand = configTemplate?.command?.trimmingCharacters(in: .whitespacesAndNewlines) - let inheritedInput = configTemplate?.initialInput - let hasStartupWork = self.initialCommand != nil - || self.tmuxStartCommand != nil - || trimmedInput != nil - || inheritedCommand?.isEmpty == false - || inheritedInput?.isEmpty == false - - // Surfaces with startup work must spawn before the user focuses their workspace. - // Ghostty's embedded surface creation still expects a view with a window, so use - // a hidden bootstrap window until the real portal host is ready. - if hasStartupWork { - MainActor.assumeIsolated { - scheduleHeadlessRuntimeStartIfNeeded(reason: "startup") - } - } } func updateWorkspaceId(_ newTabId: UUID) { @@ -4989,99 +4924,6 @@ final class TerminalSurface: Identifiable, ObservableObject { surfaceView.tabId = newTabId } - @MainActor - private func scheduleHeadlessRuntimeStartIfNeeded(reason: String) { - startRuntimeUsingHeadlessWindowIfNeeded(reason: reason) - } - - @MainActor - private func startRuntimeUsingHeadlessWindowIfNeeded(reason: String) { - guard allowsRuntimeSurfaceCreation() else { return } - guard surface == nil else { return } - ensureHeadlessStartupWindowIfNeeded(reason: reason) - hostedView.attachSurface(self) - } - - @MainActor - private func ensureHeadlessStartupWindowIfNeeded(reason: String) { - guard headlessStartupWindow == nil else { return } - guard hostedView.window == nil else { return } - - let width = max(surfaceView.bounds.width, CGFloat(800)) - let height = max(surfaceView.bounds.height, CGFloat(600)) - let frame = NSRect(x: 0, y: 0, width: width, height: height) - let window = NSWindow( - contentRect: frame, - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - window.isReleasedWhenClosed = false - window.hasShadow = false - window.alphaValue = 0 - window.ignoresMouseEvents = true - window.collectionBehavior = [.transient, .ignoresCycle, .stationary] - window.isExcludedFromWindowsMenu = true - - let contentView = NSView(frame: frame) - hostedView.frame = contentView.bounds - hostedView.autoresizingMask = [.width, .height] - contentView.addSubview(hostedView) - window.contentView = contentView - headlessStartupWindow = window - hostedView.setVisibleInUI(false) - hostedView.setActive(false) - -#if DEBUG - cmuxDebugLog( - "surface.headless_window.create surface=\(id.uuidString.prefix(8)) " + - "reason=\(reason) window=\(ObjectIdentifier(window))" - ) -#endif - } - - @MainActor - private func releaseHeadlessStartupWindowIfNeeded(for view: GhosttyNSView) { - guard let window = headlessStartupWindow else { return } - guard let currentWindow = view.window, currentWindow !== window else { return } - headlessStartupWindow = nil - window.contentView = nil - window.close() -#if DEBUG - cmuxDebugLog( - "surface.headless_window.release surface=\(id.uuidString.prefix(8)) " + - "realWindow=\(ObjectIdentifier(currentWindow))" - ) -#endif - } - - private func closeHeadlessStartupWindowIfNeeded() { - let startupWindow = headlessStartupWindow - headlessStartupWindow = nil - guard let startupWindow else { return } - - let closeStartupWindow = { - startupWindow.contentView = nil - startupWindow.close() - } - if Thread.isMainThread { - closeStartupWindow() - } else { - DispatchQueue.main.async(execute: closeStartupWindow) - } - } - - @MainActor - func reconcileAttachedWindowIfNeeded(for view: GhosttyNSView) { - guard attachedView === view else { return } - releaseHeadlessStartupWindowIfNeeded(for: view) - guard let screen = view.window?.screen ?? NSScreen.main, - let displayID = screen.displayID, - displayID != 0 else { return } - guard let s = liveSurfaceForGhosttyAccess(reason: "reconcileAttachedWindow") else { return } - ghostty_surface_set_display_id(s, displayID) - } - private static func mergedNormalizedEnvironment( base: [String: String], overrides: [String: String] @@ -5389,7 +5231,6 @@ final class TerminalSurface: Identifiable, ObservableObject { func teardownSurface() { recordTeardownRequest(reason: "surface.teardown") markPortalLifecycleClosed(reason: "teardown") - closeHeadlessStartupWindowIfNeeded() let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil @@ -5510,7 +5351,6 @@ final class TerminalSurface: Identifiable, ObservableObject { abs(lhs - rhs) <= epsilon } - @MainActor func attachToView(_ view: GhosttyNSView) { #if DEBUG cmuxDebugLog( @@ -5528,7 +5368,6 @@ final class TerminalSurface: Identifiable, ObservableObject { // markers, visibility flags), so avoid forcing a geometry refresh when the attachment // itself is unchanged. if attachedView === view && surface != nil { - releaseHeadlessStartupWindowIfNeeded(for: view) #if DEBUG cmuxDebugLog("surface.attach.reuse surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view).toOpaque())") #endif @@ -5552,11 +5391,9 @@ final class TerminalSurface: Identifiable, ObservableObject { } attachedView = view - releaseHeadlessStartupWindowIfNeeded(for: view) - // Ordinary portal attachment can arrive before AppKit has put the view in - // a window. Defer those. Startup and cold-input paths install the owned - // view in a hidden bootstrap window first, then come through here. + // If surface doesn't exist yet, create it once the view is in a real window so + // content scale and pixel geometry are derived from the actual backing context. if surface == nil { guard allowsRuntimeSurfaceCreation() else { #if DEBUG @@ -5571,16 +5408,13 @@ final class TerminalSurface: Identifiable, ObservableObject { #if DEBUG cmuxDebugLog( "surface.attach.defer surface=\(id.uuidString.prefix(5)) reason=noWindow " + - "bounds=\(String(format: "%.1fx%.1f", Double(view.bounds.width), Double(view.bounds.height)))" + "bounds=\(String(format: "%.1fx%.1f", view.bounds.width, view.bounds.height))" ) #endif return } #if DEBUG - cmuxDebugLog( - "surface.attach.create surface=\(id.uuidString.prefix(5)) " + - "inWindow=\(view.window != nil ? 1 : 0)" - ) + cmuxDebugLog("surface.attach.create surface=\(id.uuidString.prefix(5))") #endif createSurface(for: view) #if DEBUG @@ -5598,7 +5432,6 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - @MainActor private func createSurface(for view: GhosttyNSView) { guard allowsRuntimeSurfaceCreation() else { #if DEBUG @@ -5612,9 +5445,6 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif return } -#if DEBUG - runtimeSurfaceCreateAttemptCountForTesting += 1 -#endif #if DEBUG let resourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap { String(cString: $0) } ?? "(unset)" let terminfo = getenv("TERMINFO").flatMap { String(cString: $0) } ?? "(unset)" @@ -6021,13 +5851,12 @@ final class TerminalSurface: Identifiable, ObservableObject { } /// Force a full size recalculation and surface redraw. - @MainActor func forceRefresh(reason: String = "unspecified") { -#if DEBUG + #if DEBUG let hasSurface = surface != nil let viewState: String if let view = attachedView { - let inWindow = uiWindow != nil + let inWindow = view.window != nil let bounds = view.bounds let metalOK = (view.layer as? CAMetalLayer) != nil viewState = "inWindow=\(inWindow) bounds=\(bounds) metalOK=\(metalOK) hasSurface=\(hasSurface)" @@ -6035,9 +5864,10 @@ final class TerminalSurface: Identifiable, ObservableObject { viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)" } cmuxDebugLog("forceRefresh: \(id) reason=\(reason) \(viewState)") -#endif + #endif guard let view = attachedView, - let window = uiWindow, + let surface, + view.window != nil, view.bounds.width > 0, view.bounds.height > 0 else { return @@ -6045,6 +5875,8 @@ final class TerminalSurface: Identifiable, ObservableObject { #if DEBUG recordDebugForceRefresh() #endif + guard let currentSurface = self.surface else { return } + // Re-read self.surface before each ghostty call to guard against the surface // being freed during wake-from-sleep geometry reconciliation (issue #432). // The surface can be invalidated between calls when AppKit layout triggers @@ -6053,29 +5885,13 @@ final class TerminalSurface: Identifiable, ObservableObject { // Reassert display id on topology churn (split close/reparent) before forcing a refresh. // This avoids a first-run stuck-vsync state where Ghostty believes vsync is active // but callbacks have not resumed for the current display. - let displayID = (window.screen ?? NSScreen.main)?.displayID -#if DEBUG - let accessReason = "forceRefresh.\(reason)" -#else - let accessReason = "forceRefresh" -#endif - guard let currentSurface = liveSurfaceForGhosttyAccess(reason: accessReason) else { - return - } - if let displayID, + if let displayID = (view.window?.screen ?? NSScreen.main)?.displayID, displayID != 0 { ghostty_surface_set_display_id(currentSurface, displayID) } view.forceRefreshSurface() -#if DEBUG - let refreshReason = "forceRefresh.refresh.\(reason)" -#else - let refreshReason = "forceRefresh.refresh" -#endif - guard let surface = liveSurfaceForGhosttyAccess(reason: refreshReason) else { - return - } + guard let surface = self.surface else { return } ghostty_surface_refresh(surface) } @@ -6128,146 +5944,75 @@ final class TerminalSurface: Identifiable, ObservableObject { return ghostty_surface_needs_confirm_quit(surface) } - @MainActor - @discardableResult - func sendText(_ text: String) -> Bool { - guard let data = text.data(using: .utf8), !data.isEmpty else { return true } - guard surface != nil else { - guard allowsRuntimeSurfaceCreation() else { return false } - let queued = enqueuePendingSocketInput(.pasteText(data)) - if queued { - requestBackgroundSurfaceStartIfNeeded() - } - return queued - } - guard let liveSurface = liveSurfaceForSocketWrite(reason: "socket.sendText") else { - return false + func sendText(_ text: String) { + guard let data = text.data(using: .utf8), !data.isEmpty else { return } + guard let surface = surface else { + enqueuePendingSocketInput(.pasteText(data)) + requestBackgroundSurfaceStartIfNeeded() + return } - guard !ghostty_surface_process_exited(liveSurface) else { return false } - writeTextData(data, to: liveSurface) - return true + writeTextData(data, to: surface) } - @MainActor @discardableResult - func sendNamedKey(_ keyName: String) -> NamedKeySendResult { - guard let event = pendingKeyEvent(for: keyName) else { return .unknownKey } - guard surface != nil else { - guard allowsRuntimeSurfaceCreation() else { return .surfaceUnavailable } - guard enqueuePendingSocketInput(.key(event)) else { return .inputQueueFull } + func sendNamedKey(_ keyName: String) -> Bool { + guard let event = pendingKeyEvent(for: keyName) else { return false } + if let surface = surface { + sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) + } else { + enqueuePendingSocketInput(.key(event)) requestBackgroundSurfaceStartIfNeeded() - return .queued - } - guard let liveSurface = liveSurfaceForSocketWrite(reason: "socket.sendNamedKey") else { - return .surfaceUnavailable } - guard !ghostty_surface_process_exited(liveSurface) else { return .processExited } - sendKeyEvent(surface: liveSurface, keycode: event.keycode, mods: event.mods) - return .sent + return true } /// Send text with control characters (Return, Tab, etc.) delivered as key /// events so the shell processes them, while regular text is sent via the - /// normal key-text path. Cold surfaces queue the same ordered events and - /// flush them after runtime creation. - @MainActor - @discardableResult - func sendInput(_ text: String) -> Bool { - return sendInputResult(text).accepted - } - - @MainActor - @discardableResult - func sendInputResult(_ text: String) -> InputSendResult { - guard !text.isEmpty else { return .sent } - guard surface != nil else { - guard allowsRuntimeSurfaceCreation() else { return .surfaceUnavailable } - let queued = enqueuePendingSocketInput(text) - if queued { - requestBackgroundSurfaceStartIfNeeded() - } - return queued ? .queued : .inputQueueFull - } - guard let liveSurface = liveSurfaceForSocketWrite(reason: "socket.sendInput") else { - return .surfaceUnavailable - } - guard !ghostty_surface_process_exited(liveSurface) else { return .processExited } - sendInput(text, to: liveSurface) - return .sent - } - - @MainActor - private func sendInput(_ text: String, to surface: ghostty_surface_t) { - for event in Self.parsedSocketInputEvents(for: text) { - switch event { - case .text(let value): - var bufferedText = value - flushText(&bufferedText, surface: surface) - case .key(let event): - sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) - } - } - } - - @MainActor - private func enqueuePendingSocketInput(_ text: String) -> Bool { - let inputs = Self.parsedSocketInputEvents(for: text).compactMap { event -> PendingSocketInput? in - switch event { - case .text(let value): - return value.isEmpty ? nil : .inputText(value) - case .key(let event): - return .key(event) - } - } - return enqueuePendingSocketInputs(inputs) - } - - private static func parsedSocketInputEvents(for text: String) -> [ParsedSocketInput] { - guard !text.isEmpty else { return [] } - - var events: [ParsedSocketInput] = [] - events.reserveCapacity(8) + /// normal key-text path. Mirrors `TerminalController.sendSocketText`. + func sendInput(_ text: String) { + let activeSurface = surface var bufferedText = "" - bufferedText.reserveCapacity(text.count) var previousWasCR = false + var queuedPendingInput = false func flushBufferedText() { - guard !bufferedText.isEmpty else { return } - events.append(.text(bufferedText)) - bufferedText.removeAll(keepingCapacity: true) + if let activeSurface { + flushText(&bufferedText, surface: activeSurface) + } else { + queuedPendingInput = enqueuePendingText(&bufferedText) || queuedPendingInput + } } - func appendKey(_ keycode: UInt32, label: String) { - events.append(.key(PendingKeyEvent( - keycode: keycode, - mods: GHOSTTY_MODS_NONE, - label: label - ))) + func sendOrQueueKey(keycode: UInt32, label: String) { + if let activeSurface { + sendKeyEvent(surface: activeSurface, keycode: keycode) + } else { + enqueuePendingSocketInput( + .key(PendingKeyEvent(keycode: keycode, mods: GHOSTTY_MODS_NONE, label: label)) + ) + queuedPendingInput = true + } } for scalar in text.unicodeScalars { switch scalar.value { - case 0x0A: + case 0x0A: // \n — skip if preceded by \r (already sent Return) if !previousWasCR { flushBufferedText() - appendKey(UInt32(kVK_Return), label: "return") + sendOrQueueKey(keycode: 0x24, label: "enter") // kVK_Return } previousWasCR = false case 0x0D: flushBufferedText() - appendKey(UInt32(kVK_Return), label: "return") + sendOrQueueKey(keycode: 0x24, label: "enter") // kVK_Return previousWasCR = true case 0x09: flushBufferedText() - appendKey(UInt32(kVK_Tab), label: "tab") + sendOrQueueKey(keycode: 0x30, label: "tab") // kVK_Tab previousWasCR = false case 0x1B: flushBufferedText() - appendKey(UInt32(kVK_Escape), label: "escape") - previousWasCR = false - case 0x08, 0x7F: - flushBufferedText() - appendKey(UInt32(kVK_Delete), label: "backspace") + sendOrQueueKey(keycode: 0x35, label: "escape") // kVK_Escape previousWasCR = false default: bufferedText.unicodeScalars.append(scalar) @@ -6275,7 +6020,9 @@ final class TerminalSurface: Identifiable, ObservableObject { } } flushBufferedText() - return events + if activeSurface == nil, queuedPendingInput { + requestBackgroundSurfaceStartIfNeeded() + } } private func flushText(_ buffer: inout String, surface: ghostty_surface_t) { @@ -6294,6 +6041,14 @@ final class TerminalSurface: Identifiable, ObservableObject { buffer.removeAll(keepingCapacity: true) } + @discardableResult + private func enqueuePendingText(_ buffer: inout String) -> Bool { + guard !buffer.isEmpty else { return false } + enqueuePendingSocketInput(.keyText(buffer)) + buffer.removeAll(keepingCapacity: true) + return true + } + private func sendKeyEvent( surface: ghostty_surface_t, keycode: UInt32, @@ -6310,15 +6065,6 @@ final class TerminalSurface: Identifiable, ObservableObject { _ = ghostty_surface_key(surface, keyEvent) } - @MainActor - private func liveSurfaceForSocketWrite(reason: String) -> ghostty_surface_t? { - return liveSurfaceForGhosttyAccess(reason: reason) - } - - // Socket/API operations are an explicit runtime demand: they must be able to - // start a terminal in a background workspace without selecting that workspace. - // When there is no real window yet, bootstrap Ghostty in a hidden window and - // reconcile display/window state when the terminal is later presented. func requestBackgroundSurfaceStartIfNeeded() { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in @@ -6328,32 +6074,33 @@ final class TerminalSurface: Identifiable, ObservableObject { } guard allowsRuntimeSurfaceCreation() else { return } - guard surface == nil else { return } + guard surface == nil, + let attachedView, + attachedView.window != nil else { + return + } guard !backgroundSurfaceStartQueued else { return } backgroundSurfaceStartQueued = true DispatchQueue.main.async { [weak self] in guard let self else { return } - MainActor.assumeIsolated { - self.backgroundSurfaceStartQueued = false - guard self.allowsRuntimeSurfaceCreation() else { return } - guard self.surface == nil else { return } + self.backgroundSurfaceStartQueued = false + guard self.allowsRuntimeSurfaceCreation() else { return } + guard self.surface == nil, + let view = self.attachedView, + view.window != nil else { + return + } #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime + let startedAt = ProcessInfo.processInfo.systemUptime #endif - if let view = self.attachedView, view.window != nil { - self.createSurface(for: view) - } else { - self.scheduleHeadlessRuntimeStartIfNeeded(reason: "background-input") - } + self.createSurface(for: view) #if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - let view = self.attachedView ?? self.surfaceView - cmuxDebugLog( - "surface.background_start surface=\(self.id.uuidString.prefix(8)) inWindow=\(view.window != nil ? 1 : 0) ready=\(self.surface != nil ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs))" - ) + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 + cmuxDebugLog( + "surface.background_start surface=\(self.id.uuidString.prefix(8)) inWindow=\(view.window != nil ? 1 : 0) ready=\(self.surface != nil ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs))" + ) #endif - } } } @@ -6500,28 +6247,26 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - @MainActor - private func enqueuePendingSocketInput(_ input: PendingSocketInput) -> Bool { - enqueuePendingSocketInputs([input]) - } - - @MainActor - private func enqueuePendingSocketInputs(_ inputs: [PendingSocketInput]) -> Bool { - let incomingBytes = inputs.reduce(0) { $0 + $1.estimatedBytes } - guard incomingBytes > 0 else { return true } - - guard incomingBytes <= maxPendingSocketInputBytes, - pendingSocketInputBytes + incomingBytes <= maxPendingSocketInputBytes else { + private func enqueuePendingSocketInput(_ input: PendingSocketInput) { + let incomingBytes = input.estimatedBytes + guard incomingBytes <= maxPendingSocketInputBytes else { #if DEBUG cmuxDebugLog( - "surface.socket_input.reject surface=\(id.uuidString.prefix(8)) " + - "items=\(inputs.count) incomingBytes=\(incomingBytes) pendingBytes=\(pendingSocketInputBytes)" + "surface.socket_input.drop surface=\(id.uuidString.prefix(8)) reason=oversized bytes=\(incomingBytes)" ) #endif - return false + return + } + guard pendingSocketInputBytes + incomingBytes <= maxPendingSocketInputBytes else { +#if DEBUG + cmuxDebugLog( + "surface.socket_input.drop surface=\(id.uuidString.prefix(8)) reason=full bytes=\(incomingBytes) queuedBytes=\(pendingSocketInputBytes)" + ) +#endif + return } - pendingSocketInputQueue.append(contentsOf: inputs) + pendingSocketInputQueue.append(input) pendingSocketInputBytes += incomingBytes #if DEBUG let pendingKeys = pendingSocketInputQueue.reduce(into: 0) { count, item in @@ -6534,25 +6279,22 @@ final class TerminalSurface: Identifiable, ObservableObject { "keys=\(pendingKeys) bytes=\(pendingSocketInputBytes)" ) #endif - return true } - @MainActor private func flushPendingSocketInputIfNeeded() { - guard let surface = liveSurfaceForSocketWrite(reason: "socket.flushPendingInput") else { return } + guard let surface = surface, !pendingSocketInputQueue.isEmpty else { return } let queued = pendingSocketInputQueue let queuedBytes = pendingSocketInputBytes pendingSocketInputQueue.removeAll(keepingCapacity: false) pendingSocketInputBytes = 0 - guard !queued.isEmpty else { return } var queuedKeys = 0 for item in queued { switch item { case .pasteText(let chunk): writeTextData(chunk, to: surface) - case .inputText(let text): - var bufferedText = text + case .keyText(let chunk): + var bufferedText = chunk flushText(&bufferedText, surface: surface) case .key(let event): queuedKeys += 1 @@ -6608,26 +6350,6 @@ final class TerminalSurface: Identifiable, ObservableObject { needsConfirmCloseOverrideForTesting = value } - @MainActor - func debugRuntimeSurfaceCreateAttemptCountForTesting() -> Int { - runtimeSurfaceCreateAttemptCountForTesting - } - - @MainActor - func debugHasHeadlessStartupWindowForTesting() -> Bool { - headlessStartupWindow != nil - } - - @MainActor - func debugPendingSocketInputForTesting() -> (items: Int, bytes: Int, keyEvents: Int) { - let keyEvents = pendingSocketInputQueue.reduce(into: 0) { count, item in - if case .key = item { - count += 1 - } - } - return (pendingSocketInputQueue.count, pendingSocketInputBytes, keyEvents) - } - /// Test-only helper to deterministically simulate a released runtime surface. @MainActor func releaseSurfaceForTesting() { @@ -6669,7 +6391,6 @@ final class TerminalSurface: Identifiable, ObservableObject { deinit { TerminalSurfaceRegistry.shared.unregister(self) markPortalLifecycleClosed(reason: "deinit") - closeHeadlessStartupWindowIfNeeded() let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil @@ -7108,8 +6829,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { tabId = surface.tabId if !isAlreadyAttached { surface.attachToView(self) - } else { - surface.reconcileAttachedWindowIfNeeded(for: self) } surface.setKeyboardCopyModeActive(keyboardCopyModeActive) if !isAlreadyAttached { @@ -7133,13 +6852,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #if DEBUG cmuxDebugLog( "surface.view.windowMove surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + - "inWindow=\(window != nil ? 1 : 0) bounds=\(String(format: "%.1fx%.1f", Double(bounds.width), Double(bounds.height))) " + - "pending=\(String(format: "%.1fx%.1f", Double(pendingSurfaceSize?.width ?? 0), Double(pendingSurfaceSize?.height ?? 0)))" + "inWindow=\(window != nil ? 1 : 0) bounds=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "pending=\(String(format: "%.1fx%.1f", pendingSurfaceSize?.width ?? 0, pendingSurfaceSize?.height ?? 0))" ) #endif guard let window else { return } - // Reconcile the already-started runtime with the real window backing context. + // If the surface creation was deferred while detached, create/attach it now. terminalSurface?.attachToView(self) if let terminalSurface { NotificationCenter.default.post( @@ -9058,7 +8777,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { in: window ) } - terminalSurface.hostedView.clearReparentFocusSuppressionForPointerFocus() } requestPointerFocusRecovery() window?.makeFirstResponder(self) @@ -10439,17 +10157,14 @@ final class GhosttySurfaceScrollView: NSView { private let notificationRingLayer: CAShapeLayer private let flashOverlayView: GhosttyFlashOverlayView private let flashLayer: CAShapeLayer + private var reparentFocusSuppressionCount = 0 + var isSuppressingReparentFocus: Bool { + reparentFocusSuppressionCount > 0 + } var isRightSidebarDockSurface: Bool { surfaceView.terminalSurface?.focusPlacement == .rightSidebarDock } - var uiWindow: NSWindow? { - if let terminalSurface = surfaceView.terminalSurface { - return terminalSurface.uiWindow - } - return window - } - func forwardKeyDownToSurface(_ event: NSEvent) { surfaceView.keyDown(with: event) } @@ -12031,7 +11746,7 @@ final class GhosttySurfaceScrollView: NSView { } if !visible { // If we were focused, yield first responder. - if let window = uiWindow, let fr = window.firstResponder as? NSView, + if let window, let fr = window.firstResponder as? NSView, fr === surfaceView || fr.isDescendant(of: surfaceView) { window.makeFirstResponder(nil) } @@ -12053,7 +11768,7 @@ final class GhosttySurfaceScrollView: NSView { } var debugPortalFrameInWindow: CGRect { - guard uiWindow != nil else { return .zero } + guard window != nil else { return .zero } return convert(bounds, to: nil) } @@ -12088,7 +11803,7 @@ final class GhosttySurfaceScrollView: NSView { } private func debugFirstResponderLabel() -> String { - guard let window = uiWindow, let firstResponder = window.firstResponder else { return "nil" } + guard let window, let firstResponder = window.firstResponder else { return "nil" } if let view = firstResponder as? NSView { if view === surfaceView { return "surfaceView" @@ -12104,7 +11819,7 @@ final class GhosttySurfaceScrollView: NSView { private func debugVisibilityStateSuffix(transition: String) -> String { let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" let hiddenInHierarchy = (isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor) ? 1 : 0 - let inWindow = uiWindow != nil ? 1 : 0 + let inWindow = window != nil ? 1 : 0 let hasSuperview = superview != nil ? 1 : 0 let hostHidden = isHidden ? 1 : 0 let surfaceHidden = surfaceView.isHidden ? 1 : 0 @@ -12132,7 +11847,7 @@ final class GhosttySurfaceScrollView: NSView { #endif let work = { [weak self] in guard let self else { return } - guard let window = self.uiWindow else { return } + guard let window = self.window else { return } #if DEBUG let before = String(describing: window.firstResponder) #endif @@ -12243,7 +11958,7 @@ final class GhosttySurfaceScrollView: NSView { keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags = [] ) -> Bool { - guard let window = uiWindow else { return false } + guard let window else { return false } window.makeFirstResponder(surfaceView) let timestamp = ProcessInfo.processInfo.systemUptime @@ -12304,7 +12019,7 @@ final class GhosttySurfaceScrollView: NSView { let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor guard isActive else { return } - guard let window = uiWindow else { return } + guard let window else { return } guard surfaceView.isVisibleInUI else { #if DEBUG cmuxDebugLog( @@ -12494,30 +12209,23 @@ final class GhosttySurfaceScrollView: NSView { } /// Suppress the surface view's onFocus callback and ghostty_surface_set_focus during - /// SwiftUI reparenting (programmatic splits). Call clearSuppressReparentFocus() after layout settles. - func suppressReparentFocus() { + /// SwiftUI reparenting (programmatic splits). + func beginSuppressingReparentFocus() { + reparentFocusSuppressionCount += 1 surfaceView.suppressingReparentFocus = true } - func isSuppressingReparentFocusForLayoutFollowUp() -> Bool { - surfaceView.suppressingReparentFocus - } - - func canClearPendingReparentFocusSuppressionAfterLayoutAttempt() -> Bool { - // After Workspace has flushed a layout follow-up, the protected reparent - // turn has passed even if AppKit never tried to focus this old view. - true - } - - func clearReparentFocusSuppressionForPointerFocus() { - guard surfaceView.suppressingReparentFocus else { return } - surfaceView.suppressingReparentFocus = false -#if DEBUG - cmuxDebugLog("focus.reparent.pointerClear surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")") -#endif + func endSuppressingReparentFocus() { + guard reparentFocusSuppressionCount > 0 else { + clearSuppressReparentFocus() + return + } + reparentFocusSuppressionCount -= 1 + guard reparentFocusSuppressionCount == 0 else { return } + clearSuppressReparentFocus() } - func clearSuppressReparentFocus() { + private func clearSuppressReparentFocus() { surfaceView.suppressingReparentFocus = false let hasUsablePortalGeometry: Bool = { let size = bounds.size @@ -12525,12 +12233,12 @@ final class GhosttySurfaceScrollView: NSView { }() let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor let surfaceShort = String(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") - let surfaceOwnsFirstResponder = currentTerminalSurfaceOwnsFirstResponder() + let surfaceOwnsFirstResponder = isSurfaceViewFirstResponder() guard surfaceView.desiredFocus || surfaceOwnsFirstResponder else { return } guard surfaceView.isVisibleInUI else { return } surfaceView.terminalSurface?.recordExternalFocusState(true) - guard let window = uiWindow, window.isKeyWindow else { return } + guard let window, window.isKeyWindow else { return } guard !isHiddenForFocus, hasUsablePortalGeometry else { #if DEBUG cmuxDebugLog( @@ -12555,40 +12263,17 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG cmuxDebugLog("focus.reparent.resume surface=\(surfaceShort) firstResponder=\(String(describing: window.firstResponder))") #endif - reassertTerminalSurfaceFocus(reason: "clearSuppressReparentFocus", force: true) + reassertTerminalSurfaceFocus(reason: "clearSuppressReparentFocus") } /// Returns true if the terminal's actual Ghostty surface view is (or contains) the window first responder. /// This is stricter than checking `hostedView` descendants, since the scroll view can sometimes become /// first responder transiently while focus is being applied. func isSurfaceViewFirstResponder() -> Bool { - guard let window = uiWindow, let fr = window.firstResponder as? NSView else { return false } + guard let window, let fr = window.firstResponder as? NSView else { return false } return fr === surfaceView || fr.isDescendant(of: surfaceView) } -#if DEBUG - func debugIsSuppressingReparentFocusForTesting() -> Bool { - surfaceView.suppressingReparentFocus - } -#endif - - private func currentTerminalSurfaceOwnsFirstResponder() -> Bool { - guard let window = uiWindow, let firstResponder = window.firstResponder as? NSView else { return false } - if firstResponder === surfaceView || firstResponder.isDescendant(of: surfaceView) { - return true - } - guard let terminalSurface = surfaceView.terminalSurface else { return false } - var current: NSView? = firstResponder - while let view = current { - if let ghosttyView = view as? GhosttyNSView, - ghosttyView.terminalSurface === terminalSurface { - return true - } - current = view.superview - } - return false - } - private func canRequestSurfaceFirstResponder(in window: NSWindow, reason: String) -> Bool { guard let terminalSurface = surfaceView.terminalSurface else { return true @@ -12631,7 +12316,7 @@ final class GhosttySurfaceScrollView: NSView { } } - private func reassertTerminalSurfaceFocus(reason: String, force: Bool = false) { + private func reassertTerminalSurfaceFocus(reason: String) { guard let terminalSurface = surfaceView.terminalSurface else { return } if terminalSurface.surface == nil { terminalSurface.requestBackgroundSurfaceStartIfNeeded() @@ -12639,14 +12324,14 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG cmuxDebugLog("focus.surface.reassert surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)") #endif - terminalSurface.setFocus(true, force: force) + terminalSurface.setFocus(true, force: true) refreshSurfaceAfterFocusIfNeeded(reason: reason) } private func refreshSurfaceAfterFocusIfNeeded(reason: String) { guard let terminalSurface = surfaceView.terminalSurface, isActive, - let window = uiWindow, + let window, window.isKeyWindow, surfaceView.isVisibleInUI else { return } @@ -12680,7 +12365,7 @@ final class GhosttySurfaceScrollView: NSView { #endif return } - guard let window = uiWindow, window.isKeyWindow else { return } + guard let window, window.isKeyWindow else { return } guard let tabId = surfaceView.tabId, let panelId = surfaceView.terminalSurface?.id, matchesCurrentTerminalFocusTarget(tabId: tabId, surfaceId: panelId) else { diff --git a/Sources/KeyboardShortcutContext.swift b/Sources/KeyboardShortcutContext.swift index 43e9d3f48482..ad39d9425e5a 100644 --- a/Sources/KeyboardShortcutContext.swift +++ b/Sources/KeyboardShortcutContext.swift @@ -51,7 +51,7 @@ extension KeyboardShortcutSettings.Action { switch self { case .switchRightSidebarToFiles, .switchRightSidebarToFind, .switchRightSidebarToSessions, .switchRightSidebarToFeed, .switchRightSidebarToDock: return .rightSidebarFocus - case .renameTab, .renameWorkspace: + case .renameTab, .renameWorkspace, .toggleTerminalSidekick: return .nonBrowserPanel case .browserBack, .browserForward, .browserReload, .toggleBrowserDeveloperTools, .showBrowserJavaScriptConsole, .browserZoomIn, .browserZoomOut, .browserZoomReset: @@ -169,6 +169,9 @@ extension AppDelegate { if let panel = workspace.browserPanel(for: panelId) { return panel } + if let sidekickPanel = shortcutSidekickBrowserPanel(in: workspace, panelId: panelId) { + return sidekickPanel + } } } return nil @@ -184,7 +187,34 @@ extension AppDelegate { } return browserPanel } + if let sidekickPanel = shortcutSidekickBrowserPanel(in: workspace, webView: webView) { + return sidekickPanel + } + } + } + return nil + } + + private func shortcutSidekickBrowserPanel(in workspace: Workspace, panelId: UUID) -> BrowserPanel? { + for panel in workspace.panels.values { + guard let terminalPanel = panel as? TerminalPanel, + let sidekickPanel = terminalPanel.sidekickBrowserPanel, + sidekickPanel.id == panelId else { + continue + } + return sidekickPanel + } + return nil + } + + private func shortcutSidekickBrowserPanel(in workspace: Workspace, webView: WKWebView) -> BrowserPanel? { + for panel in workspace.panels.values { + guard let terminalPanel = panel as? TerminalPanel, + let sidekickPanel = terminalPanel.sidekickBrowserPanel, + sidekickPanel.webView === webView else { + continue } + return sidekickPanel } return nil } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 6686e0a82521..e5efc1be673b 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -80,7 +80,6 @@ enum KeyboardShortcutSettings { case sendFeedback case showNotifications case jumpToUnread - case toggleUnread case markOldestUnreadAndJumpNext case focusRightSidebar case switchRightSidebarToFiles @@ -124,6 +123,7 @@ enum KeyboardShortcutSettings { // Panels case saveFilePreview case openBrowser + case toggleTerminalSidekick case focusBrowserAddressBar case browserBack case browserForward @@ -164,7 +164,6 @@ enum KeyboardShortcutSettings { case .sendFeedback: return String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback") case .showNotifications: return String(localized: "shortcut.showNotifications.label", defaultValue: "Show Notifications") case .jumpToUnread: return String(localized: "shortcut.jumpToUnread.label", defaultValue: "Jump to Latest Unread") - case .toggleUnread: return String(localized: "shortcut.toggleUnread.label", defaultValue: "Toggle Unread") case .markOldestUnreadAndJumpNext: return String(localized: "shortcut.markOldestUnreadAndJumpNext.label", defaultValue: "Mark as Oldest Unread and Jump to Next Latest Unread") case .focusRightSidebar: return String(localized: "shortcut.focusRightSidebar.label", defaultValue: "Toggle Right Sidebar Focus") @@ -202,6 +201,7 @@ enum KeyboardShortcutSettings { case .toggleRightSidebar: return String(localized: "shortcut.toggleRightSidebar.label", defaultValue: "Toggle Right Sidebar") case .saveFilePreview: return String(localized: "shortcut.saveFilePreview.label", defaultValue: "Save File Preview") case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser") + case .toggleTerminalSidekick: return String(localized: "shortcut.toggleTerminalSidekick.label", defaultValue: "Toggle Terminal Sidekick") case .focusBrowserAddressBar: return String(localized: "command.browserFocusAddressBar.title", defaultValue: "Focus Address Bar") case .browserBack: return String(localized: "menu.view.back", defaultValue: "Back") case .browserForward: return String(localized: "menu.view.forward", defaultValue: "Forward") @@ -280,8 +280,6 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false) case .jumpToUnread: return StoredShortcut(key: "u", command: true, shift: true, option: false, control: false) - case .toggleUnread: - return StoredShortcut(key: "u", command: true, shift: false, option: true, control: false) case .markOldestUnreadAndJumpNext: return StoredShortcut(key: "u", command: true, shift: false, option: false, control: true) case .focusRightSidebar: @@ -351,6 +349,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "s", command: true, shift: false, option: false, control: false) case .openBrowser: return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false) + case .toggleTerminalSidekick: + return StoredShortcut(key: "b", command: true, shift: false, option: true, control: false) case .focusBrowserAddressBar: return StoredShortcut(key: "l", command: true, shift: false, option: false, control: false) case .browserBack: @@ -416,6 +416,9 @@ enum KeyboardShortcutSettings { proposedAction: Action, configuredShortcut: StoredShortcut ) -> Bool { + if Self.allowsSharedShortcut(self, proposedAction) { + return false + } guard shortcutContext.overlaps(proposedAction.shortcutContext) else { return false } @@ -427,6 +430,11 @@ enum KeyboardShortcutSettings { ) } + private static func allowsSharedShortcut(_ lhs: Action, _ rhs: Action) -> Bool { + (lhs == .toggleTerminalSidekick && rhs == .toggleRightSidebar) || + (lhs == .toggleRightSidebar && rhs == .toggleTerminalSidekick) + } + func normalizedRecordedShortcutResult(_ shortcut: StoredShortcut) -> RecordedShortcutResolution { if shortcut.isUnbound { return .accepted(.unbound) diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 183928bf542f..9a52015bb567 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -13,10 +13,8 @@ struct NotificationsPage: View { header Divider() - if !notificationStore.notificationMenuSnapshot.hasNotifications { + if notificationStore.notifications.isEmpty { emptyState - } else if notificationStore.notifications.isEmpty { - workspaceUnreadIndicatorState } else { ScrollView { LazyVStack(spacing: 8) { @@ -74,7 +72,7 @@ struct NotificationsPage: View { Spacer() - if notificationStore.notificationMenuSnapshot.hasNotifications { + if !notificationStore.notifications.isEmpty { jumpToUnreadButton Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) { @@ -101,17 +99,6 @@ struct NotificationsPage: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - private var workspaceUnreadIndicatorState: some View { - VStack(spacing: 8) { - Image(systemName: "bell.badge") - .font(.system(size: 32)) - .foregroundColor(.secondary) - Text(notificationStore.notificationMenuSnapshot.stateHintTitle) - .font(.headline) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - @ViewBuilder private var jumpToUnreadButton: some View { if let key = jumpToUnreadShortcut.keyEquivalent { @@ -152,7 +139,7 @@ struct NotificationsPage: View { } private var hasUnreadNotifications: Bool { - notificationStore.notificationMenuSnapshot.hasUnreadNotifications + notificationStore.notifications.contains(where: { !$0.isRead }) } } diff --git a/Sources/Panels/BrowserOmnibarAppKitBridge.swift b/Sources/Panels/BrowserOmnibarAppKitBridge.swift deleted file mode 100644 index c8d6f26bf1f2..000000000000 --- a/Sources/Panels/BrowserOmnibarAppKitBridge.swift +++ /dev/null @@ -1,260 +0,0 @@ -import AppKit -import SwiftUI - -private final class WeakOmnibarNativeTextField { - weak var field: OmnibarNativeTextField? - - init(_ field: OmnibarNativeTextField) { - self.field = field - } -} - -@MainActor -final class BrowserOmnibarNativeFieldRegistry { - static let shared = BrowserOmnibarNativeFieldRegistry() - - // Ownership invariant: OmnibarNativeTextField.panelId is the single source - // of truth for field-to-panel ownership, and a live omnibar field owns its - // current field editor. AppKit's field-editor responder chain is the normal - // lookup path, but it can keep a stale nextResponder during browser - // focus/layout transitions. This weak registry is only a live-field lookup - // cache for those stale-responder windows; it does not create ownership. - private var fields: [UUID: [WeakOmnibarNativeTextField]] = [:] - - func register(_ field: OmnibarNativeTextField, panelId: UUID) { - var entries = fields[panelId] ?? [] - entries.removeAll { entry in - guard let existing = entry.field else { return true } - return existing === field - } - entries.append(WeakOmnibarNativeTextField(field)) - fields[panelId] = entries - } - - func unregister(_ field: OmnibarNativeTextField, panelId: UUID) { - guard var entries = fields[panelId] else { return } - entries.removeAll { entry in - guard let existing = entry.field else { return true } - return existing === field - } - if entries.isEmpty { - fields.removeValue(forKey: panelId) - } else { - fields[panelId] = entries - } - } - - func field(for panelId: UUID?, in window: NSWindow? = nil) -> OmnibarNativeTextField? { - guard let panelId else { return nil } - pruneDeadEntries(for: panelId) - guard let entries = fields[panelId] else { return nil } - let liveFields = entries.reversed().compactMap(\.field) - if let window { - return liveFields.first(where: { $0.window === window }) - } - return liveFields.first(where: { $0.window != nil }) ?? liveFields.first - } - - func fieldOwningEditor(_ editor: NSTextView, in window: NSWindow? = nil) -> OmnibarNativeTextField? { - for panelId in Array(fields.keys) { - pruneDeadEntries(for: panelId) - } - - let liveFields = fields.values.flatMap { entries in - entries.reversed().compactMap(\.field) - } - if let window, - let windowField = liveFields.first(where: { $0.window === window && $0.currentEditor() === editor }) { - return windowField - } - if let registeredField = liveFields.first(where: { $0.currentEditor() === editor }) { - return registeredField - } - - guard let root = window?.contentView?.superview ?? window?.contentView else { - return nil - } - var stack: [NSView] = [root] - while let view = stack.popLast() { - if let field = view as? OmnibarNativeTextField, - field.currentEditor() === editor { - return field - } - stack.append(contentsOf: view.subviews) - } - return nil - } - - private func pruneDeadEntries(for panelId: UUID) { - guard var entries = fields[panelId] else { return } - entries.removeAll { $0.field == nil } - if entries.isEmpty { - fields.removeValue(forKey: panelId) - } else { - fields[panelId] = entries - } - } -} - -@MainActor -final class BrowserOmnibarInteractionView: NSView { - var panelId: UUID? - private var trackingArea: NSTrackingArea? - - override var isFlipped: Bool { true } - override var mouseDownCanMoveWindow: Bool { false } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setAccessibilityElement(false) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func resetCursorRects() { - super.resetCursorRects() - addCursorRect(bounds, cursor: .iBeam) - } - - override func updateTrackingAreas() { - if let trackingArea { - removeTrackingArea(trackingArea) - } - let options: NSTrackingArea.Options = [ - .inVisibleRect, - .activeAlways, - .cursorUpdate, - .mouseMoved, - .mouseEnteredAndExited, - .enabledDuringMouseDrag, - ] - let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil) - addTrackingArea(next) - trackingArea = next - super.updateTrackingAreas() - } - - override func hitTest(_ point: NSPoint) -> NSView? { - guard !isHidden, alphaValue > 0, bounds.contains(point) else { return nil } - guard BrowserOmnibarNativeFieldRegistry.shared.field(for: panelId, in: window) != nil else { - return nil - } - return self - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - true - } - - override func cursorUpdate(with event: NSEvent) { - setIBeamCursor() - } - - override func mouseEntered(with event: NSEvent) { - setIBeamCursor() - } - - override func mouseMoved(with event: NSEvent) { - setIBeamCursor() - } - - override func mouseExited(with event: NSEvent) { - NSCursor.arrow.set() - } - - override func mouseDown(with event: NSEvent) { - forwardMouseEvent(event) { field, event in - field.mouseDown(with: event) - } - } - - override func mouseDragged(with event: NSEvent) { - forwardMouseEvent(event) { field, event in - field.mouseDragged(with: event) - } - } - - override func mouseUp(with event: NSEvent) { - forwardMouseEvent(event) { field, event in - field.mouseUp(with: event) - } - } - - override func rightMouseDown(with event: NSEvent) { - forwardMouseEvent(event) { field, event in - field.rightMouseDown(with: event) - } - } - - override func rightMouseDragged(with event: NSEvent) { - forwardMouseEvent(event) { field, event in - field.rightMouseDragged(with: event) - } - } - - override func rightMouseUp(with event: NSEvent) { - forwardMouseEvent(event) { field, event in - field.rightMouseUp(with: event) - } - } - - override func otherMouseDown(with event: NSEvent) { - forwardMouseEvent(event) { field, event in - field.otherMouseDown(with: event) - } - } - - override func otherMouseDragged(with event: NSEvent) { - forwardMouseEvent(event) { field, event in - field.otherMouseDragged(with: event) - } - } - - override func otherMouseUp(with event: NSEvent) { - forwardMouseEvent(event) { field, event in - field.otherMouseUp(with: event) - } - } - - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(newSize) - window?.invalidateCursorRects(for: self) - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - window?.invalidateCursorRects(for: self) - } - - private func setIBeamCursor() { - NSCursor.iBeam.set() - } - - private func forwardMouseEvent( - _ event: NSEvent, - _ apply: (OmnibarNativeTextField, NSEvent) -> Void - ) { - guard let field = BrowserOmnibarNativeFieldRegistry.shared.field(for: panelId, in: window) else { - return - } - apply(field, event) - } -} - -@MainActor -struct BrowserOmnibarInteractionRepresentable: NSViewRepresentable { - let panelId: UUID - - func makeNSView(context: Context) -> BrowserOmnibarInteractionView { - let view = BrowserOmnibarInteractionView(frame: .zero) - view.panelId = panelId - return view - } - - func updateNSView(_ nsView: BrowserOmnibarInteractionView, context: Context) { - nsView.panelId = panelId - nsView.window?.invalidateCursorRects(for: nsView) - } -} diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 07ce2d538d79..400cf27d6549 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -981,230 +981,6 @@ struct BrowserPopupBrowserContext { let processPool: WKProcessPool } -enum BrowserFileSystemAccessBridge { - static let scriptSource = """ - (() => { - if (typeof window.showOpenFilePicker === "function") { - return true; - } - if (window.__cmuxFileSystemAccessBridgeInstalled) { - return true; - } - window.__cmuxFileSystemAccessBridgeInstalled = true; - - const makeDOMException = (name, message) => { - try { - return new DOMException(message, name); - } catch (_) { - const error = new Error(message); - error.name = name; - return error; - } - }; - - const normalizeAcceptToken = (value) => { - if (typeof value !== "string") { - return null; - } - const token = value.trim(); - return token.length > 0 ? token : null; - }; - - const acceptStringFromTypes = (types) => { - if (!Array.isArray(types)) { - return ""; - } - - const seen = new Set(); - const tokens = []; - const pushToken = (value) => { - const token = normalizeAcceptToken(value); - if (token && !seen.has(token)) { - seen.add(token); - tokens.push(token); - } - }; - - for (const type of types) { - const accept = type && type.accept; - if (!accept || typeof accept !== "object") { - continue; - } - - for (const [mimeType, extensions] of Object.entries(accept)) { - pushToken(mimeType); - if (Array.isArray(extensions)) { - for (const extension of extensions) { - pushToken(extension); - } - } else { - pushToken(extensions); - } - } - } - - return tokens.join(","); - }; - - const FileSystemHandleShim = window.FileSystemHandle || function FileSystemHandle() {}; - const FileSystemFileHandleShim = window.FileSystemFileHandle || function FileSystemFileHandle() {}; - if (typeof window.FileSystemHandle !== "function") { - Object.defineProperty(window, "FileSystemHandle", { - value: FileSystemHandleShim, - configurable: true, - writable: true, - }); - } - if (typeof window.FileSystemFileHandle !== "function") { - FileSystemFileHandleShim.prototype = Object.create(FileSystemHandleShim.prototype); - Object.defineProperty(FileSystemFileHandleShim.prototype, "constructor", { - value: FileSystemFileHandleShim, - configurable: true, - writable: true, - }); - Object.defineProperty(window, "FileSystemFileHandle", { - value: FileSystemFileHandleShim, - configurable: true, - writable: true, - }); - } - - const makeFileHandle = (file) => { - const handle = Object.create(window.FileSystemFileHandle.prototype); - Object.defineProperties(handle, { - kind: { - value: "file", - enumerable: true, - }, - name: { - value: file.name, - enumerable: true, - }, - getFile: { - value: () => Promise.resolve(file), - }, - isSameEntry: { - value: (other) => Promise.resolve(other === handle), - }, - queryPermission: { - value: () => Promise.resolve("granted"), - }, - requestPermission: { - value: () => Promise.resolve("granted"), - }, - }); - return handle; - }; - - const filePickerDismissedError = () => makeDOMException( - "AbortError", - "The file picker was dismissed." - ); - - const cleanupInput = (input) => { - if (input && input.parentNode) { - input.parentNode.removeChild(input); - } - }; - - const showOpenFilePicker = (options = {}) => new Promise((resolve, reject) => { - const input = document.createElement("input"); - input.type = "file"; - input.multiple = options && options.multiple === true; - const accept = acceptStringFromTypes(options && options.types); - if (accept) { - input.accept = accept; - } - input.style.position = "fixed"; - input.style.left = "-10000px"; - input.style.top = "0"; - input.style.width = "1px"; - input.style.height = "1px"; - input.style.opacity = "0"; - input.tabIndex = -1; - - let settled = false; - let focusFallbackScheduled = false; - let focusFallbackTimer = null; - const currentFiles = () => Array.from(input.files || []); - const cleanup = () => { - if (focusFallbackTimer !== null) { - clearTimeout(focusFallbackTimer); - focusFallbackTimer = null; - } - input.removeEventListener("change", handleChange); - input.removeEventListener("cancel", handleCancel); - window.removeEventListener("focus", handleWindowFocus); - cleanupInput(input); - }; - const settle = (callback) => { - if (settled) { - return; - } - settled = true; - cleanup(); - callback(); - }; - - const resolveFiles = () => { - const files = currentFiles(); - settle(() => resolve(files.map(makeFileHandle))); - }; - - const dismissPicker = () => { - settle(() => reject(filePickerDismissedError())); - }; - - function handleChange() { - resolveFiles(); - } - - function handleCancel() { - dismissPicker(); - } - - function handleWindowFocus() { - if (settled || focusFallbackScheduled) { - return; - } - focusFallbackScheduled = true; - // Defer one turn so a selection-triggered change event can settle first. - focusFallbackTimer = setTimeout(() => { - focusFallbackTimer = null; - if (settled) { - return; - } - if (currentFiles().length > 0) { - resolveFiles(); - } else { - dismissPicker(); - } - }, 0); - } - - input.addEventListener("change", handleChange); - input.addEventListener("cancel", handleCancel); - window.addEventListener("focus", handleWindowFocus); - - try { - (document.body || document.documentElement).appendChild(input); - input.click(); - } catch (error) { - settle(() => reject(error)); - } - }); - - Object.defineProperty(window, "showOpenFilePicker", { - value: showOpenFilePicker, - configurable: true, - writable: true, - }); - - return true; - })(); - """ -} - func browserReadAccessURL(forLocalFileURL fileURL: URL, fileManager: FileManager = .default) -> URL? { guard fileURL.isFileURL, fileURL.path.hasPrefix("/") else { return nil } let path = fileURL.path @@ -3390,13 +3166,6 @@ final class BrowserPanel: Panel, ObservableObject { // Enable JavaScript configuration.defaultWebpagePreferences.allowsContentJavaScript = true - configuration.userContentController.addUserScript( - WKUserScript( - source: BrowserFileSystemAccessBridge.scriptSource, - injectionTime: .atDocumentStart, - forMainFrameOnly: true - ) - ) // Keep browser console/error/dialog telemetry active from document start on every navigation. // Main frame only — injecting into cross-origin iframes causes CAPTCHA providers // (reCAPTCHA, hCaptcha, Cloudflare Turnstile) to detect the overridden console.* @@ -7772,6 +7541,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { // WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.). // Hand these off to macOS so the owning app can handle them. if let url = navigationAction.request.url, + navigationAction.targetFrame?.isMainFrame != false, browserShouldRouteExternalNavigation(url) { browserHandleExternalNavigation( url, diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index d80f5a7b652a..366064f7c6c1 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -411,6 +411,8 @@ struct BrowserPanelView: View { let isVisibleInUI: Bool let portalPriority: Int let onRequestPanelFocus: () -> Void + let usesProvidedPaneContext: Bool + let embeddedCloseAction: (() -> Void)? @Environment(\.colorScheme) private var colorScheme @Environment(\.openWindow) private var openWindow @Environment(\.paneDropZone) private var paneDropZone @@ -451,7 +453,6 @@ struct BrowserPanelView: View { @State private var isBrowserImportHintPopoverPresented = false @State private var lastHandledAddressBarFocusRequestId: UUID? @State private var omnibarSelectAllRequestId: UInt64 = 0 - @State private var suppressNextFocusGainedSelectAll: Bool = false @State private var isBrowserProfileMenuPresented = false @State private var isBrowserThemeMenuPresented = false @State private var browserChromeStyle = BrowserChromeStyle.resolve( @@ -466,6 +467,26 @@ struct BrowserPanelView: View { private let addressBarVerticalPadding: CGFloat = 4 private let devToolsButtonIconSize: CGFloat = 11 + init( + panel: BrowserPanel, + paneId: PaneID, + isFocused: Bool, + isVisibleInUI: Bool, + portalPriority: Int, + onRequestPanelFocus: @escaping () -> Void, + usesProvidedPaneContext: Bool = false, + embeddedCloseAction: (() -> Void)? = nil + ) { + self.panel = panel + self.paneId = paneId + self.isFocused = isFocused + self.isVisibleInUI = isVisibleInUI + self.portalPriority = portalPriority + self.onRequestPanelFocus = onRequestPanelFocus + self.usesProvidedPaneContext = usesProvidedPaneContext + self.embeddedCloseAction = embeddedCloseAction + } + private var searchEngine: BrowserSearchEngine { BrowserSearchEngine(rawValue: searchEngineRaw) ?? BrowserSearchSettings.defaultSearchEngine } @@ -621,6 +642,9 @@ struct BrowserPanelView: View { } private var isCurrentPaneOwner: Bool { + if usesProvidedPaneContext { + return true + } guard let currentPaneId = owningWorkspace?.paneId(forPanelId: panel.id) else { return false } @@ -959,16 +983,10 @@ struct BrowserPanelView: View { #endif onRequestPanelFocus() } - let shouldSelectAll = !suppressNextFocusGainedSelectAll - suppressNextFocusGainedSelectAll = false - let effects = omnibarReduce( - state: &omnibarState, - event: .focusGained(currentURLString: urlString, shouldSelectAll: shouldSelectAll) - ) + let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString)) applyOmnibarEffects(effects) refreshInlineCompletion() } else { - suppressNextFocusGainedSelectAll = false panel.endSuppressWebViewFocusForAddressBar() NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: panel.id) if suppressNextFocusLostRevert { @@ -1034,6 +1052,7 @@ struct BrowserPanelView: View { browserProfileButton browserThemeModeButton developerToolsButton + embeddedCloseButton } } .padding(.horizontal, 8) @@ -1192,6 +1211,25 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserToggleDevToolsButton") } + @ViewBuilder + private var embeddedCloseButton: some View { + if let embeddedCloseAction { + Button(action: embeddedCloseAction) { + Image(systemName: "sidebar.trailing") + .symbolRenderingMode(.monochrome) + .cmuxFlatSymbolColorRendering() + .font(.system(size: devToolsButtonIconSize, weight: .medium)) + .foregroundStyle(devToolsColorOption.color) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + } + .buttonStyle(OmnibarAddressButtonStyle()) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + .safeHelp(String(localized: "terminalSidekick.collapse", defaultValue: "Collapse Sidekick")) + .accessibilityLabel(String(localized: "terminalSidekick.collapse", defaultValue: "Collapse Sidekick")) + .accessibilityIdentifier("TerminalSidekickCollapseButton") + } + } + private var browserProfileButton: some View { Button(action: { isBrowserProfileMenuPresented.toggle() @@ -1454,9 +1492,6 @@ struct BrowserPanelView: View { RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous) .fill(Color(nsColor: omnibarPillBackgroundColor)) ) - .overlay { - BrowserOmnibarInteractionRepresentable(panelId: panel.id) - } .overlay( RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous) .stroke(addressBarFocused ? cmuxAccentColor() : Color.clear, lineWidth: 1) @@ -1483,6 +1518,7 @@ struct BrowserPanelView: View { WebViewRepresentable( panel: panel, paneId: paneId, + usesProvidedPaneContext: usesProvidedPaneContext, shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner && !useLocalInlineDeveloperToolsHosting, useLocalInlineHosting: useLocalInlineDeveloperToolsHosting, shouldFocusWebView: isFocused && !addressBarFocused, @@ -1794,16 +1830,10 @@ struct BrowserPanelView: View { #endif if addressBarFocused { - // Re-run selection behavior when focus is explicitly requested again - // while already focused, without replacing an in-progress edit. - let effects = omnibarReduce( - state: &omnibarState, - event: .focusReasserted( - shouldSelectAll: browserOmnibarShouldSelectAllOnFocusReassertion( - isUserEditing: omnibarState.isUserEditing - ) - ) - ) + // Re-run focus behavior (select-all/refresh suggestions) when focus is + // explicitly requested again while already focused. + let urlString = panel.preferredURLStringForOmnibar() ?? "" + let effects = omnibarReduce(state: &omnibarState, event: .reassertFocus(currentURLString: urlString)) applyOmnibarEffects(effects) refreshInlineCompletion() #if DEBUG @@ -2079,17 +2109,12 @@ struct BrowserPanelView: View { #if DEBUG logBrowserFocusState(event: "addressBar.tap") #endif - let wasAddressBarFocused = addressBarFocused - let shouldRequestPanelFocus = !isFocused - if !wasAddressBarFocused { + if !addressBarFocused { // Mark focused before pane selection converges so WebKit focus is not // briefly re-acquired during `focusPane`. - suppressNextFocusGainedSelectAll = true setAddressBarFocused(true, reason: "omnibar.tap") } - if shouldRequestPanelFocus && wasAddressBarFocused { - onRequestPanelFocus() - } + onRequestPanelFocus() } private func hideSuggestions() { @@ -2329,15 +2354,6 @@ struct BrowserPanelView: View { let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(items)) applyOmnibarEffects(effects) refreshInlineCompletion() -#if DEBUG - cmuxDebugLog( - "browser.omnibar.suggestions refresh=local " + - "panel=\(panel.id.uuidString.prefix(5)) queryLen=\(query.utf8.count) " + - "items=\(items.count) history=\(historyEntries.count) openTabs=\(openTabMatches.count) " + - "staleRemote=\(staleRemote.count) frameWidth=\(String(format: "%.1f", omnibarPillFrame.width)) " + - "portal=\(shouldRenderOmnibarSuggestionsInPortal ? 1 : 0)" - ) -#endif guard !query.isEmpty else { return } @@ -2356,12 +2372,6 @@ struct BrowserPanelView: View { let forcedEffects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(merged)) applyOmnibarEffects(forcedEffects) refreshInlineCompletion() -#if DEBUG - cmuxDebugLog( - "browser.omnibar.suggestions refresh=forcedRemote " + - "panel=\(panel.id.uuidString.prefix(5)) queryLen=\(query.utf8.count) items=\(merged.count)" - ) -#endif return } @@ -2396,13 +2406,6 @@ struct BrowserPanelView: View { applyOmnibarEffects(effects) refreshInlineCompletion() isLoadingRemoteSuggestions = false -#if DEBUG - cmuxDebugLog( - "browser.omnibar.suggestions refresh=remote " + - "panel=\(panel.id.uuidString.prefix(5)) queryLen=\(query.utf8.count) " + - "remote=\(remote.count) items=\(merged.count)" - ) -#endif } } } @@ -3236,8 +3239,8 @@ struct OmnibarState: Equatable { } enum OmnibarEvent: Equatable { - case focusGained(currentURLString: String, shouldSelectAll: Bool = true) - case focusReasserted(shouldSelectAll: Bool = true) + case focusGained(currentURLString: String) + case reassertFocus(currentURLString: String) case focusLostRevertBuffer(currentURLString: String) case focusLostPreserveBuffer(currentURLString: String) case panelURLChanged(currentURLString: String) @@ -3259,7 +3262,7 @@ func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEff var effects = OmnibarEffects() switch event { - case .focusGained(let url, let shouldSelectAll): + case .focusGained(let url): state.isFocused = true state.currentURLString = url state.buffer = url @@ -3267,11 +3270,24 @@ func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEff state.suggestions = [] state.selectedSuggestionIndex = 0 state.selectedSuggestionID = nil - effects.shouldSelectAll = shouldSelectAll + effects.shouldSelectAll = true - case .focusReasserted(let shouldSelectAll): - state.isFocused = true - effects.shouldSelectAll = shouldSelectAll + case .reassertFocus(let url): + if state.isFocused { + state.currentURLString = url + state.selectedSuggestionIndex = 0 + state.selectedSuggestionID = nil + effects.shouldSelectAll = true + } else { + state.isFocused = true + state.currentURLString = url + state.buffer = url + state.isUserEditing = false + state.suggestions = [] + state.selectedSuggestionIndex = 0 + state.selectedSuggestionID = nil + effects.shouldSelectAll = true + } case .focusLostRevertBuffer(let url): state.isFocused = false @@ -3539,10 +3555,6 @@ func browserOmnibarShouldReacquireFocusAfterEndEditing( desiredOmnibarFocus && !nextResponderIsOtherTextField } -func browserOmnibarShouldSelectAllOnFocusReassertion(isUserEditing: Bool) -> Bool { - !isUserEditing -} - final class OmnibarNativeTextField: NSTextField { var panelId: UUID? var onPointerDown: (() -> Void)? @@ -3550,15 +3562,6 @@ final class OmnibarNativeTextField: NSTextField { var suppressNextFocusReacquireOnEndEditing = false /// Anchor index for Shift+click selection extension, reset on non-shift clicks. private var shiftClickAnchor: Int? - private var mouseSelectionState: MouseSelectionState? - private static let dragSelectionThreshold: CGFloat = 3 - - private struct MouseSelectionState { - let anchor: Int - let initialWindowLocation: NSPoint - let selectAllOnMouseUp: Bool - var didDrag: Bool - } override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -3574,97 +3577,80 @@ final class OmnibarNativeTextField: NSTextField { fatalError("init(coder:) has not been implemented") } - override func resetCursorRects() { - super.resetCursorRects() - addCursorRect(bounds, cursor: .iBeam) - } - override func mouseDown(with event: NSEvent) { - let hadEditor = currentEditor() != nil + #if DEBUG + let frType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + cmuxDebugLog( + "browser.omnibarClick win=\(window?.windowNumber ?? -1) " + + "fr=\(frType) hasEditor=\(currentEditor() == nil ? 0 : 1)" + ) + #endif onPointerDown?() - if !hadEditor { - _ = window?.makeFirstResponder(self) - } - - guard let editor = currentEditor() as? NSTextView else { - super.mouseDown(with: event) - return - } - - // Keep multi-click word and line selection in the field editor, while avoiding - // NSTextField's mouse tracking loop for ordinary clicks. - if event.clickCount > 1 { - mouseSelectionState = nil - editor.mouseDown(with: event) + if currentEditor() == nil { + // First click — activate editing and select all (standard URL bar behavior). + // Avoids NSTextView's tracking loop which can spin forever if text layout + // enters an infinite invalidation cycle (e.g. under memory pressure). + let result = window?.makeFirstResponder(self) ?? false +#if DEBUG + let frAfter = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + cmuxDebugLog( + "browser.omnibarClick.makeFirstResponder result=\(result ? 1 : 0) " + + "win=\(window?.windowNumber ?? -1) fr=\(frAfter)" + ) +#endif + currentEditor()?.selectAll(nil) shiftClickAnchor = nil - return - } - - let clickIndex = insertionIndex(for: event, in: editor) - let anchor: Int - if event.modifierFlags.contains(.shift) { - let selected = editor.selectedRange() - anchor = shiftClickAnchor ?? selected.location - shiftClickAnchor = anchor - setSelection(anchor: anchor, extent: clickIndex, in: editor) } else { - anchor = clickIndex - shiftClickAnchor = nil - editor.setSelectedRange(NSRange(location: clickIndex, length: 0)) - } - - mouseSelectionState = MouseSelectionState( - anchor: anchor, - initialWindowLocation: event.locationInWindow, - selectAllOnMouseUp: !hadEditor && !event.modifierFlags.contains(.shift), - didDrag: false - ) - } - - override func mouseDragged(with event: NSEvent) { - guard var state = mouseSelectionState, - let editor = currentEditor() as? NSTextView else { - super.mouseDragged(with: event) - return - } - - let dx = event.locationInWindow.x - state.initialWindowLocation.x - let dy = event.locationInWindow.y - state.initialWindowLocation.y - let distance = (dx * dx + dy * dy).squareRoot() - if state.didDrag || distance >= Self.dragSelectionThreshold { - state.didDrag = true - setSelection(anchor: state.anchor, extent: insertionIndex(for: event, in: editor), in: editor) - mouseSelectionState = state - } - } - - override func mouseUp(with event: NSEvent) { - guard let state = mouseSelectionState, - let editor = currentEditor() as? NSTextView else { - super.mouseUp(with: event) - return - } - - mouseSelectionState = nil - if state.selectAllOnMouseUp && !state.didDrag { - editor.selectAll(nil) - } - } + // Already editing — place the cursor at the click position without calling + // super.mouseDown, which enters NSTextView's mouse-tracking loop. That loop + // can spin forever when NSTextLayoutManager.enumerateTextLayoutFragments hits + // an infinite invalidation cycle (see #917). The previous mitigation posted a + // synthetic mouseUp via NSApp.postEvent after a timeout, but the tracking loop + // does not always dequeue events from the application event queue, so the hang + // persisted. By positioning the cursor ourselves we avoid the tracking loop + // entirely. Drag-to-select is not supported in this path, but for a single-line + // omnibar this is an acceptable trade-off (double-click to select word and + // Shift+click to extend selection still work via the field editor). + guard let editor = currentEditor() as? NSTextView else { + super.mouseDown(with: event) + return + } - private func insertionIndex(for event: NSEvent, in editor: NSTextView) -> Int { - let localPoint = editor.convert(event.locationInWindow, from: nil) - let index = editor.characterIndexForInsertion(at: localPoint) - let textLength = (editor.string as NSString).length - guard index != NSNotFound else { return textLength } - return min(max(index, 0), textLength) - } + // Double/triple-click: forward directly to the field editor (NSTextView) + // which handles word and line selection internally. This bypasses + // NSTextField's super.mouseDown (and its problematic tracking loop) + // while preserving multi-click semantics. + if event.clickCount > 1 { + editor.mouseDown(with: event) + shiftClickAnchor = nil + return + } - private func setSelection(anchor: Int, extent: Int, in editor: NSTextView) { - if extent >= anchor { - editor.setSelectedRange(NSRange(location: anchor, length: extent - anchor)) - } else { - editor.setSelectedRange(NSRange(location: extent, length: anchor - extent)) + let localPoint = editor.convert(event.locationInWindow, from: nil) + let index = editor.characterIndexForInsertion(at: localPoint) + let textLength = (editor.string as NSString).length + let safeIndex = min(index, textLength) + + if event.modifierFlags.contains(.shift) { + // Shift+click: extend the existing selection to the clicked position. + // Use stored anchor to handle bidirectional extension correctly; + // NSRange.location is always the lower index so it cannot serve as + // a directional anchor on its own. + let sel = editor.selectedRange() + let anchor = shiftClickAnchor ?? sel.location + shiftClickAnchor = anchor + let newRange: NSRange + if safeIndex >= anchor { + newRange = NSRange(location: anchor, length: safeIndex - anchor) + } else { + newRange = NSRange(location: safeIndex, length: anchor - safeIndex) + } + editor.setSelectedRange(newRange) + } else { + shiftClickAnchor = nil + editor.setSelectedRange(NSRange(location: safeIndex, length: 0)) + } } } @@ -3685,7 +3671,6 @@ final class OmnibarNativeTextField: NSTextField { // Shift+click uses the post-keyboard selection as its anchor, not a // stale value from a prior mouse interaction. shiftClickAnchor = nil - mouseSelectionState = nil if (currentEditor() as? NSTextView)?.hasMarkedText() == true { super.keyDown(with: event) return @@ -3713,7 +3698,6 @@ final class OmnibarNativeTextField: NSTextField { } #endif shiftClickAnchor = nil - mouseSelectionState = nil if (currentEditor() as? NSTextView)?.hasMarkedText() == true { let result = super.performKeyEquivalent(with: event) #if DEBUG @@ -3755,7 +3739,6 @@ struct OmnibarTextFieldRepresentable: NSViewRepresentable { let onSelectionChanged: (NSRange, Bool) -> Void let shouldSuppressWebViewFocus: () -> Bool - @MainActor final class Coordinator: NSObject, NSTextFieldDelegate { var parent: OmnibarTextFieldRepresentable var isProgrammaticMutation: Bool = false @@ -3868,10 +3851,6 @@ struct OmnibarTextFieldRepresentable: NSViewRepresentable { if hitView === field || hitView.isDescendant(of: field) { return false } - if let interactionView = hitView as? BrowserOmnibarInteractionView, - interactionView.panelId == field.panelId { - return false - } if let textView = hitView as? NSTextView, let delegateField = textView.delegate as? NSTextField, delegateField === field { @@ -4099,9 +4078,7 @@ struct OmnibarTextFieldRepresentable: NSViewRepresentable { object: editor, queue: .main ) { [weak self] _ in - MainActor.assumeIsolated { - self?.publishSelectionState() - } + self?.publishSelectionState() } } @@ -4200,16 +4177,20 @@ struct OmnibarTextFieldRepresentable: NSViewRepresentable { let modifiers = event.modifierFlags.intersection([.command, .control, .shift, .option, .function]) // When a non-Latin input source is active (Korean, Chinese, Japanese), // charactersIgnoringModifiers returns non-ASCII characters. Normalize - // via KeyboardLayout so Ctrl+N/P navigation works across input sources. + // via KeyboardLayout so Cmd/Ctrl+N/P navigation works across input sources. let lowered = KeyboardLayout.normalizedCharacters(for: event) + let repeatsSelectionShortcut = modifiers == [.command] || modifiers == [.control] - // Ctrl+N and Ctrl+P should repeat while held. - if let delta = browserOmnibarSelectionDeltaForControlNavigation( - hasFocusedAddressBar: true, - flags: event.modifierFlags, - chars: lowered - ) { - parent.onMoveSelection(delta) + // Cmd/Ctrl+N and Cmd/Ctrl+P should repeat while held. + if repeatsSelectionShortcut, lowered == "n" { + parent.onMoveSelection(+1) +#if DEBUG + handled = true +#endif + return true + } + if repeatsSelectionShortcut, lowered == "p" { + parent.onMoveSelection(-1) #if DEBUG handled = true #endif @@ -4294,7 +4275,6 @@ struct OmnibarTextFieldRepresentable: NSViewRepresentable { func makeNSView(context: Context) -> OmnibarNativeTextField { let field = OmnibarNativeTextField(frame: .zero) field.panelId = panelId - BrowserOmnibarNativeFieldRegistry.shared.register(field, panelId: panelId) field.identifier = browserOmnibarTextFieldIdentifier field.font = .systemFont(ofSize: 12) field.placeholderString = placeholder @@ -4312,18 +4292,13 @@ struct OmnibarTextFieldRepresentable: NSViewRepresentable { coordinator?.handleKeyEvent(event, editor: editor) ?? false } context.coordinator.parentField = field - BrowserOmnibarNativeFieldRegistry.shared.register(field, panelId: panelId) return field } func updateNSView(_ nsView: OmnibarNativeTextField, context: Context) { context.coordinator.parent = self context.coordinator.parentField = nsView - if let previousPanelId = nsView.panelId, previousPanelId != panelId { - BrowserOmnibarNativeFieldRegistry.shared.unregister(nsView, panelId: previousPanelId) - } nsView.panelId = panelId - BrowserOmnibarNativeFieldRegistry.shared.register(nsView, panelId: panelId) nsView.placeholderString = placeholder context.coordinator.queueSelectAllRequest(selectAllRequestId) @@ -4449,9 +4424,6 @@ struct OmnibarTextFieldRepresentable: NSViewRepresentable { } static func dismantleNSView(_ nsView: OmnibarNativeTextField, coordinator: Coordinator) { - if let panelId = nsView.panelId { - BrowserOmnibarNativeFieldRegistry.shared.unregister(nsView, panelId: panelId) - } nsView.onPointerDown = nil nsView.onHandleKeyEvent = nil nsView.delegate = nil @@ -4460,34 +4432,58 @@ struct OmnibarTextFieldRepresentable: NSViewRepresentable { } } -@MainActor func browserOmnibarPanelId(for responder: NSResponder?) -> UUID? { browserOmnibarField(for: responder)?.panelId } @MainActor func browserOmnibarField(panelId: UUID?, in window: NSWindow?) -> OmnibarNativeTextField? { - if let registeredField = BrowserOmnibarNativeFieldRegistry.shared.field(for: panelId, in: window) { - return registeredField + guard let panelId else { return nil } + + if let focusedField = browserOmnibarField(for: window?.firstResponder), + focusedField.panelId == panelId { + return focusedField } - guard let panelId, let root = window?.contentView?.superview ?? window?.contentView else { + + guard let window, + let root = window.contentView?.superview ?? window.contentView else { return nil } - // Fallback for SwiftUI/AppKit reconnect windows where the live native field - // has been attached but registration has not yet observed it. var stack: [NSView] = [root] + var fallback: OmnibarNativeTextField? while let view = stack.popLast() { if let field = view as? OmnibarNativeTextField, field.panelId == panelId { - return field + if browserOmnibarFieldIsLive(field, in: window, root: root) { + return field + } + fallback = fallback ?? field } stack.append(contentsOf: view.subviews) } - return nil + return fallback +} + +private func browserOmnibarFieldIsLive( + _ field: OmnibarNativeTextField, + in window: NSWindow, + root: NSView +) -> Bool { + guard field.window === window, field.isDescendant(of: root) else { return false } + var current: NSView? = field + while let view = current { + if view.isHidden || view.alphaValue <= 0 { + return false + } + if view === root { + return true + } + current = view.superview + } + return false } @discardableResult -@MainActor func browserPrepareOmnibarForProgrammaticBlur(panelId: UUID, responder: NSResponder?) -> Bool { guard let field = browserOmnibarField(for: responder), field.panelId == panelId else { @@ -4497,7 +4493,6 @@ func browserPrepareOmnibarForProgrammaticBlur(panelId: UUID, responder: NSRespon return true } -@MainActor private func browserOmnibarField(for responder: NSResponder?) -> OmnibarNativeTextField? { guard let responder else { return nil } @@ -4505,16 +4500,11 @@ private func browserOmnibarField(for responder: NSResponder?) -> OmnibarNativeTe return field } - if let editor = responder as? NSTextView, editor.isFieldEditor { - if let field = BrowserOmnibarNativeFieldRegistry.shared.fieldOwningEditor(editor, in: editor.window) { - return field - } - - if let field = cmuxFieldEditorOwnerView(editor) as? OmnibarNativeTextField, - field.currentEditor() === editor { - return field - } - + if let editor = responder as? NSTextView, + editor.isFieldEditor, + let field = cmuxFieldEditorOwnerView(editor) as? OmnibarNativeTextField, + field.currentEditor() === editor { + return field } return nil @@ -4532,22 +4522,14 @@ struct OmnibarSuggestionsView: View { // Keep radii below half of the smallest rendered heights so this keeps a // squircle silhouette instead of auto-clamping into a capsule. - private static let popupCornerRadius: CGFloat = 12 - private static let rowHighlightCornerRadius: CGFloat = 9 - private static let singleLineRowHeight: CGFloat = 24 - private static let rowSpacing: CGFloat = 1 - private static let topInset: CGFloat = 3 - private static let bottomInset: CGFloat = 3 - private static let maxPopupHeight: CGFloat = 560 - - private var popupCornerRadius: CGFloat { Self.popupCornerRadius } - private var rowHighlightCornerRadius: CGFloat { Self.rowHighlightCornerRadius } - private var singleLineRowHeight: CGFloat { Self.singleLineRowHeight } - private var rowSpacing: CGFloat { Self.rowSpacing } - private var topInset: CGFloat { Self.topInset } - private var bottomInset: CGFloat { Self.bottomInset } + private let popupCornerRadius: CGFloat = 12 + private let rowHighlightCornerRadius: CGFloat = 9 + private let singleLineRowHeight: CGFloat = 24 + private let rowSpacing: CGFloat = 1 + private let topInset: CGFloat = 3 + private let bottomInset: CGFloat = 3 private var horizontalInset: CGFloat { topInset } - private var maxPopupHeight: CGFloat { Self.maxPopupHeight } + private let maxPopupHeight: CGFloat = 560 private var totalRowCount: Int { max(1, items.count) @@ -4574,18 +4556,23 @@ struct OmnibarSuggestionsView: View { return (value * scale).rounded(.toNearestOrAwayFromZero) / scale } - private var popupHeight: CGFloat { + private var resolvedPopupHeight: CGFloat { Self.popupHeight(for: items) } static func popupHeight(for items: [OmnibarSuggestion]) -> CGFloat { + let singleLineRowHeight: CGFloat = 24 + let rowSpacing: CGFloat = 1 + let topInset: CGFloat = 3 + let bottomInset: CGFloat = 3 + let maxPopupHeight: CGFloat = 560 let totalRowCount = max(1, items.count) let rowsHeight = items.isEmpty ? singleLineRowHeight : CGFloat(items.count) * singleLineRowHeight let gaps = CGFloat(max(0, totalRowCount - 1)) let contentHeight = rowsHeight + (gaps * rowSpacing) + topInset + bottomInset let minimumPopupHeight = singleLineRowHeight + topInset + bottomInset - let scale = NSScreen.main?.backingScaleFactor ?? 2 let height = min(max(contentHeight, minimumPopupHeight), maxPopupHeight) + let scale = NSScreen.main?.backingScaleFactor ?? 2 return (height * scale).rounded(.toNearestOrAwayFromZero) / scale } @@ -4791,7 +4778,7 @@ struct OmnibarSuggestionsView: View { rowsView } } - .frame(height: popupHeight, alignment: .top) + .frame(height: resolvedPopupHeight, alignment: .top) .overlay(alignment: .topTrailing) { if searchSuggestionsEnabled, isLoadingRemoteSuggestions { ProgressView() @@ -4841,6 +4828,7 @@ struct OmnibarSuggestionsView: View { struct WebViewRepresentable: NSViewRepresentable { let panel: BrowserPanel let paneId: PaneID + let usesProvidedPaneContext: Bool let shouldAttachWebView: Bool let useLocalInlineHosting: Bool let shouldFocusWebView: Bool @@ -7182,16 +7170,7 @@ struct WebViewRepresentable: NSViewRepresentable { return } if isPanelFocused && responderChainContains(window.firstResponder, target: webView) { - if panel.shouldSuppressWebViewFocus() { -#if DEBUG - cmuxDebugLog( - "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + - "action=skip_webview_intent reason=suppressed_first_responder_chain" - ) -#endif - } else { - panel.noteWebViewFocused() - } + panel.noteWebViewFocused() } if shouldFocusWebView { if panel.shouldSuppressWebViewFocus() { @@ -7301,6 +7280,15 @@ struct WebViewRepresentable: NSViewRepresentable { } private func currentPaneDropContext() -> BrowserPaneDropContext? { + if usesProvidedPaneContext { + return BrowserPaneDropContext( + workspaceId: panel.workspaceId, + panelId: panel.id, + paneId: paneId, + allowsPanelFocusAfterFileDrop: false + ) + } + guard let app = AppDelegate.shared, let manager = app.tabManagerFor(tabId: panel.workspaceId), let workspace = manager.tabs.first(where: { $0.id == panel.workspaceId }), diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 998550c5e4ba..542a8c1d7f3d 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -353,20 +353,6 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { } } - fileprivate func requestNavigation(_ request: URLRequest, in webView: WKWebView) { - guard let url = request.url else { return } - - if browserShouldBlockInsecureHTTPURL(url) { - presentInsecureHTTPAlert(for: url, in: webView) { [weak webView] policy in - guard policy == .allow, let webView else { return } - browserLoadRequest(request, in: webView) - } - return - } - - browserLoadRequest(request, in: webView) - } - // MARK: - Insecure HTTP prompt (parity with main browser) /// Shows the same 3-button insecure HTTP alert as the main browser. @@ -438,15 +424,8 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { windowFeatures: WKWindowFeatures ) -> WKWebView? { if let url = navigationAction.request.url, - browserShouldRouteExternalNavigation(url) { - browserHandleExternalNavigation( - url, - source: "popupUIDelegate", - webView: webView, - loadFallbackRequest: { [weak controller] request in - controller?.requestNavigation(request, in: webView) - } - ) + browserShouldOpenURLExternally(url) { + NSWorkspace.shared.open(url) return nil } @@ -587,28 +566,24 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { - guard let url = navigationAction.request.url else { + // Only guard main-frame navigations + guard navigationAction.targetFrame?.isMainFrame != false else { decisionHandler(.allow) return } - // External URL schemes → hand off to macOS - if browserShouldRouteExternalNavigation(url) { - browserHandleExternalNavigation( - url, - source: "popupNavDelegate", - webView: webView, - loadFallbackRequest: { [weak controller] request in - controller?.requestNavigation(request, in: webView) - } - ) - decisionHandler(.cancel) + guard let url = navigationAction.request.url else { + decisionHandler(.allow) return } - // Only guard main-frame navigations - guard navigationAction.targetFrame?.isMainFrame != false else { - decisionHandler(.allow) + // External URL schemes → hand off to macOS + if browserShouldOpenURLExternally(url) { + NSWorkspace.shared.open(url) + #if DEBUG + cmuxDebugLog("popup.nav.external url=\(url.absoluteString)") + #endif + decisionHandler(.cancel) return } diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 065522c83396..4ea2f049f37c 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -427,12 +427,7 @@ final class CmuxWebView: WKWebView { } let result = super.becomeFirstResponder() if result { - let pointerInitiatedKey = BrowserFirstResponderNotificationUserInfoKey.pointerInitiated - NotificationCenter.default.post( - name: .browserDidBecomeFirstResponderWebView, - object: self, - userInfo: [pointerInitiatedKey: pointerFocusAllowanceDepth > 0] - ) + NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self) } #if DEBUG let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" diff --git a/Sources/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index 15cfdfe9e88a..b59fa7154c30 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -880,6 +880,12 @@ enum FilePreviewTextSaver { case failed(fileExists: Bool) } + typealias Operation = @Sendable (String, URL, String.Encoding) async -> Result + + static let live: Operation = { content, url, encoding in + await save(content: content, to: url, encoding: encoding) + } + static func save(content: String, to url: URL, encoding: String.Encoding) async -> Result { await Task.detached(priority: .userInitiated) { guard let data = content.data(using: encoding) else { @@ -921,15 +927,21 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan private var activeSaveGeneration: Int? private weak var textView: NSTextView? private let focusCoordinator: FilePreviewFocusCoordinator + private let textSaver: FilePreviewTextSaver.Operation var fileURL: URL { URL(fileURLWithPath: filePath) } - init(workspaceId: UUID, filePath: String) { + init( + workspaceId: UUID, + filePath: String, + textSaver: @escaping FilePreviewTextSaver.Operation = FilePreviewTextSaver.live + ) { self.id = UUID() self.workspaceId = workspaceId self.filePath = filePath + self.textSaver = textSaver self.displayTitle = URL(fileURLWithPath: filePath).lastPathComponent let fileURL = URL(fileURLWithPath: filePath) let initialPreviewMode = FilePreviewKindResolver.initialMode(for: fileURL) @@ -1153,8 +1165,9 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan activeSaveGeneration = generation let fileURL = fileURL let encoding = textEncoding - return Task { [weak self, currentContent, fileURL, encoding, generation] in - let result = await FilePreviewTextSaver.save(content: currentContent, to: fileURL, encoding: encoding) + let textSaver = textSaver + return Task { [weak self, currentContent, fileURL, encoding, generation, textSaver] in + let result = await textSaver(currentContent, fileURL, encoding) guard let self, self.activeSaveGeneration == generation else { return } self.activeSaveGeneration = nil self.isSaving = false diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 507e7e32f26a..3d64263d05e4 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -41,6 +41,11 @@ final class TerminalPanel: Panel, ObservableObject { var onRequestWorkspacePaneFlash: ((WorkspaceAttentionFlashReason) -> Void)? private var cancellables = Set() + private lazy var sidekickCoordinator = TerminalSidekickCoordinator( + workspaceId: workspaceId + ) { [weak self] in + self?.objectWillChange.send() + } var displayTitle: String { title.isEmpty ? "Terminal" : title @@ -71,6 +76,14 @@ final class TerminalPanel: Panel, ObservableObject { surface.requestedWorkingDirectory } + var sidekickState: TerminalSidekickState { + sidekickCoordinator.state + } + + var sidekickBrowserPanel: BrowserPanel? { + sidekickCoordinator.browserPanel + } + init(workspaceId: UUID, surface: TerminalSurface) { self.id = surface.id self.workspaceId = workspaceId @@ -105,7 +118,6 @@ final class TerminalPanel: Panel, ObservableObject { context: context, configTemplate: configTemplate, workingDirectory: workingDirectory, - portOrdinal: portOrdinal, initialCommand: initialCommand, tmuxStartCommand: tmuxStartCommand, initialInput: initialInput, @@ -113,6 +125,7 @@ final class TerminalPanel: Panel, ObservableObject { additionalEnvironment: additionalEnvironment, focusPlacement: focusPlacement ) + surface.portOrdinal = portOrdinal self.init(workspaceId: workspaceId, surface: surface) } @@ -133,6 +146,41 @@ final class TerminalPanel: Panel, ObservableObject { func updateWorkspaceId(_ newWorkspaceId: UUID) { workspaceId = newWorkspaceId surface.updateWorkspaceId(newWorkspaceId) + sidekickCoordinator.updateWorkspaceId(newWorkspaceId) + } + + @discardableResult + func toggleSidekick() -> Bool { + sidekickCoordinator.toggleSidekick() + } + + @discardableResult + func openSidekick(url: URL? = nil) -> Bool { + sidekickCoordinator.openSidekick(url: url) + } + + func closeSidekick() { + sidekickCoordinator.closeSidekick() + } + + func navigateSidekick(input: String) { + sidekickCoordinator.navigateSidekick(input: input) + } + + func recordSidekickCurrentURL(_ url: URL?) { + sidekickCoordinator.recordSidekickCurrentURL(url) + } + + func setSidekickSplitRatio(_ splitRatio: Double) { + sidekickCoordinator.setSidekickSplitRatio(splitRatio) + } + + func restoreSidekick(_ snapshot: SessionTerminalSidekickSnapshot?) { + sidekickCoordinator.restoreSidekick(snapshot) + } + + func sidekickSessionSnapshot() -> SessionTerminalSidekickSnapshot? { + sidekickCoordinator.sessionSnapshot() } func updateTmuxLayoutReport(_ report: TmuxPaneLayoutReport?) { @@ -144,17 +192,15 @@ final class TerminalPanel: Panel, ObservableObject { // `unfocus()` force-disables active state to stop stale retries from stealing focus. // Re-enable it immediately for explicit focus requests (socket/UI) so ensureFocus can run. hostedView.setActive(true) - let focusWindow = surface.uiWindow guard AppDelegate.shared?.allowsTerminalKeyboardFocus( workspaceId: workspaceId, panelId: id, - in: focusWindow + in: hostedView.window ) != false else { surface.setFocus(false) return } surface.setFocus(true) - guard focusWindow != nil else { return } hostedView.ensureFocus(for: workspaceId, surfaceId: id) } @@ -170,6 +216,7 @@ final class TerminalPanel: Panel, ObservableObject { } func close() { + sidekickCoordinator.closeBrowserPanel() // The surface will be cleaned up by its deinit // Detach from the window portal on real close so stale hosted views // cannot remain above browser panes after split close. @@ -180,7 +227,7 @@ final class TerminalPanel: Panel, ObservableObject { cmuxDebugLog( "surface.panel.close.begin panel=\(id.uuidString.prefix(5)) " + "workspace=\(workspaceId.uuidString.prefix(5)) runtimeSurface=\(surface.surface != nil ? 1 : 0) " + - "inWindow=\(surface.isViewInWindow ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " + + "inWindow=\(hostedView.window != nil ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " + "hidden=\(hostedView.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)" ) #endif @@ -190,7 +237,7 @@ final class TerminalPanel: Panel, ObservableObject { #if DEBUG cmuxDebugLog( "surface.panel.close.end panel=\(id.uuidString.prefix(5)) " + - "inWindow=\(surface.isViewInWindow ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " + + "inWindow=\(hostedView.window != nil ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " + "hidden=\(hostedView.isHidden ? 1 : 0)" ) #endif diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 0cab106980b1..96dc30b5df05 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -17,8 +17,100 @@ struct TerminalPanelView: View { let hasUnreadNotification: Bool let onFocus: () -> Void let onTriggerFlash: () -> Void + @State private var containerSize: CGSize = .zero + @State private var sidekickResizeStartRatio: Double? + @State private var sidekickResizePreviewRatio: Double? var body: some View { + ZStack(alignment: .leading) { + content + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { newSize in + containerSize = newSize + } + .animation(.easeInOut(duration: 0.16), value: panel.sidekickState.isOpen) + } + + @ViewBuilder + private var content: some View { + if panel.sidekickState.isOpen, let sidekickPanel = panel.sidekickBrowserPanel { + let splitRatio = sidekickResizePreviewRatio ?? panel.sidekickState.splitRatio + let terminalWidth = TerminalSidekickLayout.terminalWidth( + totalWidth: containerSize.width, + splitRatio: splitRatio + ) + let sidekickWidth = TerminalSidekickLayout.sidekickWidth( + totalWidth: containerSize.width, + splitRatio: splitRatio + ) + + HStack(spacing: 0) { + terminalSurface + .frame(width: terminalWidth) + .frame(maxHeight: .infinity) + TerminalSidekickDivider( + color: appearance.dividerColor, + onDrag: resizeSidekick, + onEndDrag: finishSidekickResize + ) + BrowserPanelView( + panel: sidekickPanel, + paneId: paneId, + isFocused: false, + isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority + 1, + onRequestPanelFocus: onFocus, + usesProvidedPaneContext: true, + embeddedCloseAction: { panel.closeSidekick() } + ) + .environment(\.paneDropZone, nil) + .accessibilityIdentifier("TerminalSidekickDrawer") + .frame(width: sidekickWidth) + .frame(maxHeight: .infinity) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + terminalSurface + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func resizeSidekick(translationWidth: CGFloat) { + guard let resizedSplitRatio = resizedSidekickSplitRatio(translationWidth: translationWidth) else { + return + } + sidekickResizePreviewRatio = resizedSplitRatio + } + + private func finishSidekickResize(translationWidth: CGFloat) { + if sidekickResizePreviewRatio == nil { + resizeSidekick(translationWidth: translationWidth) + } + if let sidekickResizePreviewRatio { + panel.setSidekickSplitRatio(sidekickResizePreviewRatio) + } + sidekickResizeStartRatio = nil + sidekickResizePreviewRatio = nil + } + + private func resizedSidekickSplitRatio(translationWidth: CGFloat) -> Double? { + let startRatio = sidekickResizeStartRatio ?? panel.sidekickState.splitRatio + if sidekickResizeStartRatio == nil { + sidekickResizeStartRatio = startRatio + } + + return TerminalSidekickLayout.splitRatio( + totalWidth: containerSize.width, + startRatio: startRatio, + translationWidth: translationWidth + ) + } + + private var terminalSurface: some View { // Layering contract: terminal find UI is mounted in GhosttySurfaceScrollView (AppKit portal layer) // via `searchState`. Rendering `SurfaceSearchOverlay` in this SwiftUI container can hide it. GhosttyTerminalView( @@ -43,6 +135,87 @@ struct TerminalPanelView: View { } } +private enum TerminalSidekickDividerMetrics { + static let hitWidth = TerminalSidekickLayout.dividerWidth + static let hairlineWidth: CGFloat = 1 +} + +enum TerminalSidekickLayout { + static let dividerWidth: CGFloat = 8 + private static let minimumTerminalWidth: CGFloat = 280 + private static let minimumSidekickWidth: CGFloat = 260 + + static func sidekickWidth(totalWidth: CGFloat, splitRatio: Double) -> CGFloat { + guard totalWidth > dividerWidth else { return 0 } + let availableWidth = max(0, totalWidth - dividerWidth) + let targetWidth = availableWidth * CGFloat(TerminalSidekickState.clampedSplitRatio(splitRatio)) + return constrainedSidekickWidth(availableWidth: availableWidth, targetWidth: targetWidth) + } + + static func splitRatio( + totalWidth: CGFloat, + startRatio: Double, + translationWidth: CGFloat + ) -> Double? { + guard totalWidth > dividerWidth else { return nil } + let availableWidth = max(0, totalWidth - dividerWidth) + guard availableWidth > 0 else { return nil } + + let startWidth = sidekickWidth(totalWidth: totalWidth, splitRatio: startRatio) + let resizedWidth = startWidth - translationWidth + let boundedWidth = constrainedSidekickWidth( + availableWidth: availableWidth, + targetWidth: resizedWidth + ) + return TerminalSidekickState.clampedSplitRatio(Double(boundedWidth / availableWidth)) + } + + private static func constrainedSidekickWidth( + availableWidth: CGFloat, + targetWidth: CGFloat + ) -> CGFloat { + guard availableWidth >= minimumTerminalWidth + minimumSidekickWidth else { + return max(0, min(availableWidth, targetWidth)) + } + let maximumSidekickWidth = availableWidth - minimumTerminalWidth + return min(maximumSidekickWidth, max(minimumSidekickWidth, targetWidth)) + } + + static func terminalWidth(totalWidth: CGFloat, splitRatio: Double) -> CGFloat { + max(0, totalWidth - dividerWidth - sidekickWidth(totalWidth: totalWidth, splitRatio: splitRatio)) + } +} + +private struct TerminalSidekickDivider: View { + let color: Color + let onDrag: (CGFloat) -> Void + let onEndDrag: (CGFloat) -> Void + + var body: some View { + ZStack { + Color.clear + Rectangle() + .fill(color.opacity(0.9)) + .frame(width: TerminalSidekickDividerMetrics.hairlineWidth) + } + .frame(width: TerminalSidekickDividerMetrics.hitWidth) + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .backport.pointerStyle(.resizeLeftRight) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + onDrag(value.translation.width) + } + .onEnded { value in + onEndDrag(value.translation.width) + } + ) + .accessibilityIdentifier("TerminalSidekickResizeDivider") + .accessibilityLabel(String(localized: "terminalSidekick.resizeDivider", defaultValue: "Resize Sidekick")) + } +} + /// Shared appearance settings for panels struct PanelAppearance { let backgroundColor: NSColor diff --git a/Sources/Panels/TerminalSidekickCoordinator.swift b/Sources/Panels/TerminalSidekickCoordinator.swift new file mode 100644 index 000000000000..1cd0c46327b3 --- /dev/null +++ b/Sources/Panels/TerminalSidekickCoordinator.swift @@ -0,0 +1,250 @@ +import Foundation +import Combine + +nonisolated struct TerminalSidekickState: Codable, Equatable, Sendable { + static let defaultSplitRatio = 0.4 + private static let minimumSplitRatio = 0.25 + private static let maximumSplitRatio = 0.7 + + var urlString: String? + var isOpen: Bool + var splitRatio: Double + + init( + urlString: String? = nil, + isOpen: Bool = false, + splitRatio: Double = Self.defaultSplitRatio + ) { + self.urlString = Self.normalizedURLString(urlString) + self.isOpen = isOpen + self.splitRatio = Self.clampedSplitRatio(splitRatio) + } + + static func clampedSplitRatio(_ value: Double) -> Double { + guard value.isFinite else { return defaultSplitRatio } + return min(max(value, minimumSplitRatio), maximumSplitRatio) + } + + static func normalizedURLString(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + var url: URL? { + urlString.flatMap(URL.init(string:)) + } + + var hasPersistableValue: Bool { + isOpen || + urlString != nil || + abs(splitRatio - Self.defaultSplitRatio) > 0.0001 + } +} + +@MainActor +final class TerminalSidekickCoordinator { + private(set) var state = TerminalSidekickState() + private(set) var browserPanel: BrowserPanel? + + private var workspaceId: UUID + private let notifyChanged: @MainActor () -> Void + private var browserPanelCancellable: AnyCancellable? + + init( + workspaceId: UUID, + notifyChanged: @MainActor @escaping () -> Void + ) { + self.workspaceId = workspaceId + self.notifyChanged = notifyChanged + } + + func updateWorkspaceId(_ newWorkspaceId: UUID) { + workspaceId = newWorkspaceId + browserPanel?.updateWorkspaceId(newWorkspaceId) + } + + @discardableResult + func toggleSidekick() -> Bool { + guard BrowserAvailabilitySettings.isEnabled() else { return false } + if state.isOpen { + closeSidekick() + return true + } + return openSidekick() + } + + @discardableResult + func openSidekick(url: URL? = nil) -> Bool { + guard BrowserAvailabilitySettings.isEnabled() else { return false } + guard let browserPanel = ensureBrowserPanel(renderInitialNavigation: url == nil) else { return false } + + var next = state + next.isOpen = true + if let url { + next.urlString = url.absoluteString + } + replaceState(next) + + if let url { + browserPanel.navigate(to: url, recordTypedNavigation: true) + } + return true + } + + func closeSidekick() { + var next = state + next.urlString = currentURLString() + next.isOpen = false + replaceState(next) + closeBrowserPanel() + } + + func navigateSidekick(input: String) { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard BrowserAvailabilitySettings.isEnabled() else { return } + guard let browserPanel = ensureBrowserPanel(renderInitialNavigation: false) else { return } + var next = state + next.isOpen = true + replaceState(next) + browserPanel.navigateSmart(trimmed) + } + + func recordSidekickCurrentURL(_ url: URL?) { + let nextURLString = TerminalSidekickState.normalizedURLString(url?.absoluteString) + guard state.urlString != nextURLString else { return } + var next = state + next.urlString = nextURLString + replaceState(next) + } + + func setSidekickSplitRatio(_ splitRatio: Double) { + let clamped = TerminalSidekickState.clampedSplitRatio(splitRatio) + guard state.splitRatio != clamped else { return } + var next = state + next.splitRatio = clamped + replaceState(next) + } + + func restoreSidekick(_ snapshot: SessionTerminalSidekickSnapshot?) { + guard let snapshot else { + replaceState(TerminalSidekickState()) + closeBrowserPanel() + return + } + + replaceState( + TerminalSidekickState( + urlString: snapshot.urlString, + isOpen: snapshot.isOpen, + splitRatio: snapshot.splitRatio + ) + ) + + guard BrowserAvailabilitySettings.isEnabled() else { + var next = state + next.isOpen = false + replaceState(next) + closeBrowserPanel() + return + } + + guard state.isOpen else { + closeBrowserPanel() + return + } + + guard let url = state.url else { + closeBrowserPanel() + guard ensureBrowserPanel(renderInitialNavigation: false) != nil else { + var next = state + next.isOpen = false + replaceState(next) + return + } + return + } + + let hadBrowserPanel = browserPanel != nil + guard let browserPanel = ensureBrowserPanel() else { + var next = state + next.isOpen = false + replaceState(next) + return + } + if hadBrowserPanel { + browserPanel.navigate(to: url, recordTypedNavigation: false) + } + } + + func sessionSnapshot() -> SessionTerminalSidekickSnapshot? { + let urlString = currentURLString() + let snapshotState = TerminalSidekickState( + urlString: urlString, + isOpen: state.isOpen, + splitRatio: state.splitRatio + ) + guard snapshotState.hasPersistableValue else { return nil } + return SessionTerminalSidekickSnapshot( + urlString: snapshotState.urlString, + isOpen: snapshotState.isOpen, + splitRatio: snapshotState.splitRatio + ) + } + + func closeBrowserPanel() { + browserPanel?.close() + replaceBrowserPanel(nil) + } + + private func currentURLString() -> String? { + TerminalSidekickState.normalizedURLString( + browserPanel?.preferredURLStringForOmnibar() ?? state.urlString + ) + } + + private func observeBrowserPanelChanges() { + browserPanelCancellable = browserPanel?.objectWillChange.sink { [weak self] _ in + MainActor.assumeIsolated { + self?.notifyChanged() + } + } + } + + @discardableResult + private func ensureBrowserPanel(renderInitialNavigation: Bool = true) -> BrowserPanel? { + if let browserPanel { + return browserPanel + } + + let browserPanel = BrowserPanel( + workspaceId: workspaceId, + initialURL: state.url, + renderInitialNavigation: renderInitialNavigation && state.url != nil + ) + replaceBrowserPanel(browserPanel) + return browserPanel + } + + private func replaceState(_ next: TerminalSidekickState) { + guard state != next else { return } + state = next + notifyChanged() + } + + private func replaceBrowserPanel(_ next: BrowserPanel?) { + if browserPanel == nil, next == nil { + return + } + if let browserPanel, let next, browserPanel === next { + return + } + + browserPanel = next + observeBrowserPanelChanges() + notifyChanged() + } +} diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 68e6bf8f27ce..466a6107b1cd 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -586,6 +586,9 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { return inlineInput } guard allowLauncherScript else { return nil } + guard !Self.containsPreservedClaudeAuthSelectionEnvironment(command) else { + return nil + } guard let scriptURL = AgentResumeScriptStore.writeLauncherScript( command: command, kind: kind, @@ -599,6 +602,12 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { let scriptInput = "/bin/zsh \(shellSingleQuoted(scriptURL.path))\n" return scriptInput.utf8.count <= Self.maxInlineStartupInputBytes ? scriptInput : nil } + + private static func containsPreservedClaudeAuthSelectionEnvironment(_ command: String) -> Bool { + command.contains("'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1'") || + command.contains("\"CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1\"") || + command.contains(" CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1") + } } private enum AgentResumeScriptStore { diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 585a694d77ab..1cafa1618e1a 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -227,6 +227,46 @@ struct SessionGitBranchSnapshot: Codable, Sendable { var isDirty: Bool } +nonisolated struct SessionTerminalSidekickSnapshot: Codable, Equatable, Sendable { + var urlString: String? + var isOpen: Bool + var splitRatio: Double + + init( + urlString: String? = nil, + isOpen: Bool, + splitRatio: Double = TerminalSidekickState.defaultSplitRatio + ) { + self.urlString = Self.sanitizedURLString(urlString) + self.isOpen = isOpen + self.splitRatio = Self.sanitizedSplitRatio(splitRatio) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + urlString: try container.decodeIfPresent(String.self, forKey: .urlString), + isOpen: try container.decodeIfPresent(Bool.self, forKey: .isOpen) ?? false, + splitRatio: try container.decodeIfPresent(Double.self, forKey: .splitRatio) + ?? TerminalSidekickState.defaultSplitRatio + ) + } + + private static func sanitizedURLString(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty, + URL(string: trimmed) != nil else { + return nil + } + return trimmed + } + + private static func sanitizedSplitRatio(_ value: Double) -> Double { + guard value.isFinite else { return TerminalSidekickState.defaultSplitRatio } + return TerminalSidekickState.clampedSplitRatio(value) + } +} + enum SurfaceResumeApprovalPolicy: String, Codable, CaseIterable, Sendable { case manual case prompt @@ -1234,6 +1274,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { var scrollback: String? var agent: SessionRestorableAgentSnapshot? var tmuxStartCommand: String? + var sidekick: SessionTerminalSidekickSnapshot? var resumeBinding: SurfaceResumeBindingSnapshot? /// Whether the agent process was actively running when this snapshot was captured. /// Nil means unknown (legacy snapshots); treated as true for backwards compatibility. @@ -1244,6 +1285,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { scrollback: String? = nil, agent: SessionRestorableAgentSnapshot? = nil, tmuxStartCommand: String? = nil, + sidekick: SessionTerminalSidekickSnapshot? = nil, resumeBinding: SurfaceResumeBindingSnapshot? = nil, wasAgentRunning: Bool? = nil ) { @@ -1251,6 +1293,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { self.scrollback = scrollback self.agent = agent self.tmuxStartCommand = tmuxStartCommand + self.sidekick = sidekick self.resumeBinding = resumeBinding self.wasAgentRunning = wasAgentRunning } diff --git a/Sources/TabManager+CompatibilityTypes.swift b/Sources/TabManager+CompatibilityTypes.swift index 4569e8e03985..a9905158c0ab 100644 --- a/Sources/TabManager+CompatibilityTypes.swift +++ b/Sources/TabManager+CompatibilityTypes.swift @@ -5,7 +5,7 @@ import Bonsplit // MARK: - Direction Types for Backwards Compatibility /// Split direction for backwards compatibility with old API -enum SplitDirection: Sendable { +enum SplitDirection { case left, right, up, down var isHorizontal: Bool { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 1a60dd8b3e80..878618072d6e 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2290,16 +2290,45 @@ class TabManager: ObservableObject { selectedWorkspace?.focusedTerminalPanel } + private var focusedTerminalSidekickBrowserPanel: BrowserPanel? { + guard let panel = selectedTerminalPanel, + panel.sidekickState.isOpen, + let sidekickPanel = panel.sidekickBrowserPanel else { + return nil + } + let window = sidekickPanel.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + guard let window, + let responder = window.firstResponder, + sidekickPanel.ownedFocusIntent(for: responder, in: window) != nil else { + return nil + } + return sidekickPanel + } + var isFindVisible: Bool { - selectedTerminalPanel?.searchState != nil || focusedBrowserPanel?.searchState != nil + selectedTerminalPanel?.searchState != nil || + focusedBrowserPanel?.searchState != nil || + focusedTerminalSidekickBrowserPanel?.searchState != nil } var canUseSelectionForFind: Bool { - selectedTerminalPanel?.hasSelection() == true + guard focusedTerminalSidekickBrowserPanel == nil, + focusedBrowserPanel == nil else { + return false + } + return selectedTerminalPanel?.hasSelection() == true } @discardableResult func startSearch() -> Bool { + if let sidekickPanel = focusedTerminalSidekickBrowserPanel { + sidekickPanel.startFind() + return sidekickPanel.searchState != nil + } + if let browserPanel = focusedBrowserPanel { + browserPanel.startFind() + return browserPanel.searchState != nil + } if let panel = selectedTerminalPanel { let hadExistingSearch = panel.searchState != nil panel.hostedView.preparePanelFocusIntentForActivation(.findField) @@ -2316,18 +2345,18 @@ class TabManager: ObservableObject { "find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) " + "panel=\(panel.id.uuidString.prefix(5)) existing=\(hadExistingSearch ? "yes" : "no") " + "handled=\(handled ? 1 : 0) " + - "firstResponder=\(String(describing: panel.surface.uiWindow?.firstResponder))" + "firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))" ) #endif return handled } - guard let browserPanel = focusedBrowserPanel else { return false } - browserPanel.startFind() - return browserPanel.searchState != nil + return false } func searchSelection() { - guard let panel = selectedTerminalPanel else { return } + guard focusedTerminalSidekickBrowserPanel == nil, + focusedBrowserPanel == nil, + let panel = selectedTerminalPanel else { return } if panel.searchState == nil { panel.searchState = TerminalSurface.SearchState() } @@ -2342,6 +2371,10 @@ class TabManager: ObservableObject { } func findNext() { + if let sidekickPanel = focusedTerminalSidekickBrowserPanel { + sidekickPanel.findNext() + return + } if let panel = selectedTerminalPanel { _ = panel.performBindingAction("search:next") return @@ -2351,6 +2384,10 @@ class TabManager: ObservableObject { } func findPrevious() { + if let sidekickPanel = focusedTerminalSidekickBrowserPanel { + sidekickPanel.findPrevious() + return + } if let panel = selectedTerminalPanel { _ = panel.performBindingAction("search:previous") return @@ -2366,6 +2403,10 @@ class TabManager: ObservableObject { } func hideFind() { + if let sidekickPanel = focusedTerminalSidekickBrowserPanel { + sidekickPanel.hideFind() + return + } if let panel = selectedTerminalPanel { panel.searchState = nil return @@ -2456,7 +2497,6 @@ class TabManager: ObservableObject { initialTerminalCommand: String? = nil, initialTerminalInput: String? = nil, initialTerminalEnvironment: [String: String] = [:], - inheritWorkingDirectory: Bool = true, select: Bool = true, eagerLoadTerminal: Bool = false, placementOverride: NewWorkspacePlacement? = nil, @@ -2472,9 +2512,7 @@ class TabManager: ObservableObject { // entire creation path. Release ARC can otherwise drop retains early across the // helper/insertion chain, which reintroduces use-after-free crashes in optimized builds. return withExtendedLifetime((capturedTabs, sourceWorkspace)) { - let dir = inheritWorkingDirectory - ? implicitWorkingDirectoryForNewWorkspace(from: sourceWorkspace) - : nil + let dir = implicitWorkingDirectoryForNewWorkspace(from: sourceWorkspace) let font = inheritedTerminalFontPointsForNewWorkspace(workspace: sourceWorkspace) let snapshot = workspaceCreationSnapshotLite( currentTabs: capturedTabs, @@ -7909,7 +7947,7 @@ class TabManager: ObservableObject { timeoutSeconds: timeoutSeconds ) { panel in panel.surface.requestBackgroundSurfaceStartIfNeeded() - attached = panel.surface.isViewInWindow + attached = panel.hostedView.window != nil hasSurface = panel.surface.surface != nil firstResponder = panel.hostedView.isSurfaceViewFirstResponder() return attached && hasSurface @@ -8079,7 +8117,7 @@ class TabManager: ObservableObject { } if let terminal = panel as? TerminalPanel { selectedTerminalCount += 1 - if terminal.surface.isViewInWindow { + if terminal.hostedView.window != nil { selectedTerminalAttachedCount += 1 } let size = terminal.hostedView.bounds.size @@ -8547,7 +8585,7 @@ class TabManager: ObservableObject { panelId: rightPanel.id, timeoutSeconds: 2.0 ) { panel in - panel.surface.isViewInWindow && panel.surface.surface != nil + panel.hostedView.window != nil && panel.surface.surface != nil } // Use an explicit shell exit command for deterministic child-exit behavior across // startup timing variance; this still exercises the same SHOW_CHILD_EXITED path. @@ -8791,7 +8829,7 @@ class TabManager: ObservableObject { } self.ensureFocusedTerminalFirstResponder() } else if let exitPanel = tab.terminalPanel(for: exitPanelId) { - exitPanelAttachedBeforeCtrlD = exitPanel.surface.isViewInWindow + exitPanelAttachedBeforeCtrlD = exitPanel.hostedView.window != nil exitPanelHasSurfaceBeforeCtrlD = exitPanel.surface.surface != nil } @@ -8912,7 +8950,7 @@ class TabManager: ObservableObject { panelId: exitPanelId, timeoutSeconds: 5.0 ) { panel in - attachedBeforeTrigger = panel.surface.isViewInWindow + attachedBeforeTrigger = panel.hostedView.window != nil hasSurfaceBeforeTrigger = panel.surface.surface != nil return attachedBeforeTrigger && hasSurfaceBeforeTrigger } @@ -8922,7 +8960,7 @@ class TabManager: ObservableObject { return } } else if let panel = tab.terminalPanel(for: exitPanelId) { - attachedBeforeTrigger = panel.surface.isViewInWindow + attachedBeforeTrigger = panel.hostedView.window != nil hasSurfaceBeforeTrigger = panel.surface.surface != nil } write([ @@ -9345,7 +9383,3 @@ extension Notification.Name { static let browserPortalRegistryDidChange = Notification.Name("cmux.browserPortalRegistryDidChange") static let workspaceOrderDidChange = Notification.Name("cmux.workspaceOrderDidChange") } - -enum BrowserFirstResponderNotificationUserInfoKey { - static let pointerInitiated = "pointerInitiated" -} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c9c4bf8b599f..251524a17f33 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -94,39 +94,6 @@ class TerminalController { return MemoryLayout.size(ofValue: addr.sun_path) - 1 }() - private static var terminalProcessExitedMessage: String { - String( - localized: "socket.terminal.processExited", - defaultValue: "The terminal session has ended; reopen it or create a new terminal session." - ) - } - - private static var terminalInputQueueFullMessage: String { - String( - localized: "socket.terminal.inputQueueFull", - defaultValue: "The terminal can't accept more input right now. Wait a moment and retry, or reopen the terminal if it stays unavailable." - ) - } - - private static var terminalSurfaceUnavailableMessage: String { - String( - localized: "socket.terminal.surfaceUnavailable", - defaultValue: "The terminal surface is no longer available; reopen it or create a new terminal session." - ) - } - - private static var terminalProcessExitedSocketError: String { - "ERROR: \(terminalProcessExitedMessage)" - } - - private static var terminalInputQueueFullSocketError: String { - "ERROR: \(terminalInputQueueFullMessage)" - } - - private static var terminalSurfaceUnavailableSocketError: String { - "ERROR: \(terminalSurfaceUnavailableMessage)" - } - private struct ListenerStateSnapshot { let socketPath: String let boundSocketPathIdentity: SocketPathIdentity? @@ -7754,7 +7721,7 @@ class TerminalController { let terminals: [[String: Any]] = surfaces.enumerated().map { index, terminalSurface in let mapped = mappedLocations[ObjectIdentifier(terminalSurface)] let hostedView = terminalSurface.hostedView - let hostedWindow = mapped?.window ?? terminalSurface.uiWindow + let hostedWindow = mapped?.window ?? hostedView.window let fallbackWindowMetadata = resolvedWindowMetadata(for: hostedWindow) let resolvedWindowId = mapped?.windowId ?? fallbackWindowMetadata.windowId let resolvedWindowIndex = mapped?.windowIndex ?? fallbackWindowMetadata.windowIndex @@ -7818,8 +7785,7 @@ class TerminalController { "runtime_surface_ready": terminalSurface.surface != nil, "hosted_view_ptr": objectPointerString(hostedView), "hosted_view_class": className(hostedView) ?? "nil", - "hosted_view_in_window": terminalSurface.isViewInWindow, - "hosted_view_in_headless_bootstrap_window": terminalSurface.isHeadlessStartupWindow(hostedView.window), + "hosted_view_in_window": hostedView.window != nil, "hosted_view_has_superview": hostedView.superview != nil, "hosted_view_hidden": hostedView.isHidden, "hosted_view_hidden_or_ancestor_hidden": hostedView.isHiddenOrHasHiddenAncestor, @@ -7906,24 +7872,18 @@ class TerminalController { let sendStart = ProcessInfo.processInfo.systemUptime #endif let queued: Bool - switch terminalPanel.surface.sendInputResult(text) { - case .sent: + if let surface = terminalPanel.surface.surface { + sendSocketText(text, surface: surface) // Ensure we present a new frame after injecting input so snapshot-based tests (and // socket-driven agents) can observe the updated terminal without requiring a focus // change to trigger a draw. terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendText") queued = false - case .queued: + } else { + // Avoid blocking the main actor waiting for view/surface attachment. + terminalPanel.sendInput(text) + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() queued = true - case .inputQueueFull: - result = .err(code: "input_queue_full", message: Self.terminalInputQueueFullMessage, data: ["surface_id": surfaceId.uuidString]) - return - case .surfaceUnavailable: - result = .err(code: "surface_unavailable", message: Self.terminalSurfaceUnavailableMessage, data: ["surface_id": surfaceId.uuidString]) - return - case .processExited: - result = .err(code: "process_exited", message: Self.terminalProcessExitedMessage, data: ["surface_id": surfaceId.uuidString]) - return } #if DEBUG let sendMs = (ProcessInfo.processInfo.systemUptime - sendStart) * 1000.0 @@ -7931,7 +7891,7 @@ class TerminalController { "socket.surface.send_text workspace=\(ws.id.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8)) queued=\(queued ? 1 : 0) chars=\(text.count) ms=\(String(format: "%.2f", sendMs))" ) #endif - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "queued": queued, "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) + result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } return result } @@ -7968,26 +7928,15 @@ class TerminalController { result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) return } - let sendResult = terminalPanel.surface.sendNamedKey(key) - switch sendResult { - case .sent: - terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendKey") - case .queued: - break - case .unknownKey: + let surfaceWasReady = terminalPanel.surface.surface != nil + guard terminalPanel.surface.sendNamedKey(key) else { result = .err(code: "invalid_params", message: "Unknown key", data: ["key": key]) return - case .inputQueueFull: - result = .err(code: "input_queue_full", message: Self.terminalInputQueueFullMessage, data: ["surface_id": surfaceId.uuidString]) - return - case .surfaceUnavailable: - result = .err(code: "surface_unavailable", message: Self.terminalSurfaceUnavailableMessage, data: ["surface_id": surfaceId.uuidString]) - return - case .processExited: - result = .err(code: "process_exited", message: Self.terminalProcessExitedMessage, data: ["surface_id": surfaceId.uuidString]) - return } - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "queued": sendResult == .queued, "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) + if surfaceWasReady { + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendKey") + } + result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } return result } @@ -14250,7 +14199,7 @@ class TerminalController { panelId: panel.id, hostedView: panel.hostedView, urls: urls, - window: panel.surface.uiWindow + window: panel.hostedView.window ) result = handled ? .ok(["handled": true, "route": "text_destination", "payload": payload]) @@ -16625,6 +16574,68 @@ class TerminalController { return nil } + private func resolveTerminalSurface(from arg: String, tabManager: TabManager, waitUpTo timeout: TimeInterval = 0.6) -> ghostty_surface_t? { + guard let terminalPanel = resolveTerminalPanel(from: arg, tabManager: tabManager) else { return nil } + return waitForTerminalSurface(terminalPanel, waitUpTo: timeout) + } + + private func waitForTerminalSurface(_ terminalPanel: TerminalPanel, waitUpTo timeout: TimeInterval = 0.6) -> ghostty_surface_t? { + if let surface = terminalPanel.surface.surface { return surface } + + let terminalSurface = terminalPanel.surface + terminalSurface.requestBackgroundSurfaceStartIfNeeded() + var readyObserver: NSObjectProtocol? + var hostedViewObserver: NSObjectProtocol? + var didFinish = false + let cleanupObservers: () -> Void = { + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + if let hostedViewObserver { + NotificationCenter.default.removeObserver(hostedViewObserver) + } + readyObserver = nil + hostedViewObserver = nil + } + let waitResult: Void? = v2AwaitCallback(timeout: timeout) { finish in + let finishOnce: () -> Void = { + guard !didFinish else { return } + didFinish = true + cleanupObservers() + finish(()) + } + + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: terminalSurface, + queue: .main + ) { _ in + finishOnce() + } + hostedViewObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: terminalSurface, + queue: .main + ) { _ in + Task { @MainActor in + if terminalSurface.surface != nil { + finishOnce() + } + } + } + + if terminalSurface.surface != nil { + finishOnce() + } + } + if waitResult == nil, !didFinish { + didFinish = true + cleanupObservers() + } + + return terminalPanel.surface.surface + } + private func resolveSurfaceId(from arg: String, tab: Workspace) -> UUID? { if let uuid = UUID(uuidString: arg), tab.panels[uuid] != nil { return uuid @@ -16702,6 +16713,93 @@ class TerminalController { return result.isEmpty ? "ERROR: No tab selected" : result } + private func sendKeyEvent( + surface: ghostty_surface_t, + keycode: UInt32, + mods: ghostty_input_mods_e = GHOSTTY_MODS_NONE, + text: String? = nil + ) { + var keyEvent = ghostty_input_key_s() + keyEvent.action = GHOSTTY_ACTION_PRESS + keyEvent.keycode = keycode + keyEvent.mods = mods + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.unshifted_codepoint = 0 + keyEvent.composing = false + if let text { + text.withCString { ptr in + keyEvent.text = ptr + _ = ghostty_surface_key(surface, keyEvent) + } + } else { + keyEvent.text = nil + _ = ghostty_surface_key(surface, keyEvent) + } + } + + private func sendTextEvent(surface: ghostty_surface_t, text: String) { + sendKeyEvent(surface: surface, keycode: 0, text: text) + } + + enum SocketTextChunk: Equatable { + case text(String) + case control(UnicodeScalar) + } + + nonisolated static func socketTextChunks(_ text: String) -> [SocketTextChunk] { + guard !text.isEmpty else { return [] } + + var chunks: [SocketTextChunk] = [] + chunks.reserveCapacity(8) + var bufferedText = "" + bufferedText.reserveCapacity(text.count) + + func flushBufferedText() { + guard !bufferedText.isEmpty else { return } + chunks.append(.text(bufferedText)) + bufferedText.removeAll(keepingCapacity: true) + } + + for scalar in text.unicodeScalars { + if isSocketControlScalar(scalar) { + flushBufferedText() + chunks.append(.control(scalar)) + } else { + bufferedText.unicodeScalars.append(scalar) + } + } + flushBufferedText() + return chunks + } + + private nonisolated static func isSocketControlScalar(_ scalar: UnicodeScalar) -> Bool { + switch scalar.value { + case 0x0A, 0x0D, 0x09, 0x1B, 0x7F: + return true + default: + return false + } + } + + private func handleControlScalar(_ scalar: UnicodeScalar, surface: ghostty_surface_t) -> Bool { + switch scalar.value { + case 0x0A, 0x0D: + sendKeyEvent(surface: surface, keycode: UInt32(kVK_Return)) + return true + case 0x09: + sendKeyEvent(surface: surface, keycode: UInt32(kVK_Tab)) + return true + case 0x1B: + sendKeyEvent(surface: surface, keycode: UInt32(kVK_Escape)) + return true + case 0x7F: + sendKeyEvent(surface: surface, keycode: UInt32(kVK_Delete)) + return true + default: + return false + } + } + private func sendInput(_ text: String) -> String { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } @@ -16722,27 +16820,46 @@ class TerminalController { .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - switch terminalPanel.surface.sendInputResult(unescaped) { - case .sent: - terminalPanel.surface.forceRefresh(reason: "terminalController.sendInput") - success = true - case .queued: - success = true - case .inputQueueFull: - error = Self.terminalInputQueueFullSocketError - return - case .surfaceUnavailable: - error = Self.terminalSurfaceUnavailableSocketError - return - case .processExited: - error = Self.terminalProcessExitedSocketError - return - } + sendSocketInput(unescaped, to: terminalPanel) + success = true } if let error { return error } return success ? "OK" : "ERROR: Failed to send input" } + private func sendSocketText(_ text: String, surface: ghostty_surface_t) { + let chunks = Self.socketTextChunks(text) +#if DEBUG + let startedAt = ProcessInfo.processInfo.systemUptime +#endif + for chunk in chunks { + switch chunk { + case .text(let value): + sendTextEvent(surface: surface, text: value) + case .control(let scalar): + _ = handleControlScalar(scalar, surface: surface) + } + } +#if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 + if elapsedMs >= 8 || chunks.count > 1 { + cmuxDebugLog( + "socket.send_text.inject chars=\(text.count) chunks=\(chunks.count) ms=\(String(format: "%.2f", elapsedMs))" + ) + } +#endif + } + + private func sendSocketInput(_ text: String, to terminalPanel: TerminalPanel) { + if let surface = terminalPanel.surface.surface { + sendSocketText(text, surface: surface) + terminalPanel.surface.forceRefresh(reason: "terminalController.sendSocketInput") + } else { + terminalPanel.sendInput(text) + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } + } + private func sendInputToWorkspace(_ args: String) -> String { guard let tabManager else { return "ERROR: TabManager not available" } let parts = args.split(separator: " ", maxSplits: 1).map(String.init) @@ -16777,22 +16894,14 @@ class TerminalController { .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - switch terminalPanel.surface.sendInputResult(unescaped) { - case .sent: - terminalPanel.surface.forceRefresh(reason: "terminalController.sendWorkspace") - success = true - case .queued: - success = true - case .inputQueueFull: - error = Self.terminalInputQueueFullSocketError - return - case .surfaceUnavailable: - error = Self.terminalSurfaceUnavailableSocketError - return - case .processExited: - error = Self.terminalProcessExitedSocketError - return + // This DEBUG-only command is used by UI tests to enqueue shell work in an + // existing workspace. Return once the input is queued on main so a long + // payload does not hold the control-socket response open in CI. + TerminalMutationBus.shared.enqueueMainActorMutation { [weak self] in + guard let self else { return } + self.sendSocketInput(unescaped, to: terminalPanel) } + success = true } if let error { return error } @@ -16852,27 +16961,14 @@ class TerminalController { error = "ERROR: Surface not found" return } + let unescaped = text .replacingOccurrences(of: "\\n", with: "\r") .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - switch terminalPanel.surface.sendInputResult(unescaped) { - case .sent: - terminalPanel.surface.forceRefresh(reason: "terminalController.sendSurface") - success = true - case .queued: - success = true - case .inputQueueFull: - error = Self.terminalInputQueueFullSocketError - return - case .surfaceUnavailable: - error = Self.terminalSurfaceUnavailableSocketError - return - case .processExited: - error = Self.terminalProcessExitedSocketError - return - } + sendSocketInput(unescaped, to: terminalPanel) + success = true } if let error { return error } @@ -16892,24 +16988,10 @@ class TerminalController { return } - switch terminalPanel.surface.sendNamedKey(keyName) { - case .sent: - terminalPanel.surface.forceRefresh(reason: "terminalController.sendKey") - success = true - case .queued: - success = true - case .unknownKey: - error = "ERROR: Unknown key '\(keyName)'" - case .inputQueueFull: - error = Self.terminalInputQueueFullSocketError - case .surfaceUnavailable: - error = Self.terminalSurfaceUnavailableSocketError - case .processExited: - error = Self.terminalProcessExitedSocketError - } + success = terminalPanel.surface.sendNamedKey(keyName) } if let error { return error } - return success ? "OK" : "ERROR: Failed to send key" + return success ? "OK" : "ERROR: Unknown key '\(keyName)'" } private func sendKeyToSurface(_ args: String) -> String { @@ -16927,25 +17009,11 @@ class TerminalController { error = "ERROR: Surface not found" return } - switch terminalPanel.surface.sendNamedKey(keyName) { - case .sent: - terminalPanel.surface.forceRefresh(reason: "terminalController.sendKeyToSurface") - success = true - case .queued: - success = true - case .unknownKey: - error = "ERROR: Unknown key '\(keyName)'" - case .inputQueueFull: - error = Self.terminalInputQueueFullSocketError - case .surfaceUnavailable: - error = Self.terminalSurfaceUnavailableSocketError - case .processExited: - error = Self.terminalProcessExitedSocketError - } + success = terminalPanel.surface.sendNamedKey(keyName) } if let error { return error } - return success ? "OK" : "ERROR: Failed to send key" + return success ? "OK" : "ERROR: Unknown key '\(keyName)'" } private func openExternallyWhenBrowserDisabled(rawURL: String? = nil, url: URL?) -> String { diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 1300123cbc38..500e223d2a0f 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -767,22 +767,17 @@ final class TerminalNotificationStore: ObservableObject { @Published private(set) var notifications: [TerminalNotification] = [] { didSet { indexes = Self.buildIndexes(for: notifications) - refreshUnreadPresentation() + let nextMenuSnapshot = NotificationMenuSnapshotBuilder.make(notifications: notifications) + if notificationMenuSnapshot != nextMenuSnapshot { notificationMenuSnapshot = nextMenuSnapshot } + refreshDockBadge() if !suppressNotificationDiffPublishing { CmuxEventBus.shared.publishNotificationChanges(oldValue: oldValue, newValue: notifications) } } } @Published private(set) var notificationMenuSnapshot = NotificationMenuSnapshotBuilder.make(notifications: []) - // Workspace-level unread drives sidebar workspace badges; pane-level manual - // unread remains owned by Workspace.manualUnreadPanelIds. - @Published private(set) var manualUnreadWorkspaceIds: Set = [] { - didSet { refreshUnreadPresentation() } - } - @Published private(set) var panelDerivedUnreadWorkspaceIds: Set = [] { - didSet { refreshUnreadPresentation() } - } - @Published private(set) var restoredUnreadWorkspaceIds: Set = [] { - didSet { refreshUnreadPresentation() } - } + // Workspace-level manual unread drives sidebar workspace badges; pane-level + // manual unread remains owned by Workspace.manualUnreadPanelIds. + @Published private(set) var manualUnreadWorkspaceIds: Set = [] + @Published private(set) var restoredUnreadWorkspaceIds: Set = [] @Published private(set) var focusedReadIndicatorByTabId: [UUID: UUID] = [:] @Published private(set) var authorizationState: NotificationAuthorizationState = .unknown private var suppressNotificationDiffPublishing = false @@ -871,28 +866,13 @@ final class TerminalNotificationStore: ObservableObject { } var unreadCount: Int { - indexes.unreadCount + workspaceUnreadIndicatorCount + // Global badges count only notification-backed unread items. Per-workspace + // badges use unreadCount(forTabId:) and include manualUnreadWorkspaceIds. + indexes.unreadCount } var workspaceUnreadIndicatorIds: Set { - manualUnreadWorkspaceIds - .union(panelDerivedUnreadWorkspaceIds) - .union(restoredUnreadWorkspaceIds) - } - - private var workspaceUnreadIndicatorCount: Int { - workspaceUnreadIndicatorIds.count - } - - private func refreshUnreadPresentation() { - let nextMenuSnapshot = NotificationMenuSnapshotBuilder.make( - notifications: notifications, - workspaceUnreadIndicatorCount: workspaceUnreadIndicatorCount - ) - if notificationMenuSnapshot != nextMenuSnapshot { - notificationMenuSnapshot = nextMenuSnapshot - } - refreshDockBadge() + manualUnreadWorkspaceIds.union(restoredUnreadWorkspaceIds) } private func logAuthorization(_ message: String) { @@ -1015,38 +995,6 @@ final class TerminalNotificationStore: ObservableObject { manualUnreadWorkspaceIds = [] } - @discardableResult - private func setPanelDerivedWorkspaceUnread(_ isUnread: Bool, forTabId tabId: UUID) -> Bool { - var nextIds = panelDerivedUnreadWorkspaceIds - let didChange: Bool - if isUnread { - didChange = nextIds.insert(tabId).inserted - } else { - didChange = nextIds.remove(tabId) != nil - } - guard didChange else { return false } - panelDerivedUnreadWorkspaceIds = nextIds - return true - } - - private func clearPanelDerivedWorkspaceUnread() { - guard !panelDerivedUnreadWorkspaceIds.isEmpty else { return } - panelDerivedUnreadWorkspaceIds = [] - } - - private func clearWorkspacePanelUnread(forTabId tabId: UUID) { - guard let appDelegate = AppDelegate.shared else { return } - let workspace = appDelegate.workspaceFor(tabId: tabId) ?? - appDelegate.tabManager?.tabs.first(where: { $0.id == tabId }) - workspace?.clearAllPanelUnreadIndicatorsForWorkspaceRead() - } - - private func clearAllWorkspacePanelUnread(forTabIds tabIds: Set) { - for tabId in tabIds { - clearWorkspacePanelUnread(forTabId: tabId) - } - } - @discardableResult private func setWorkspaceRestoredUnread(_ isUnread: Bool, forTabId tabId: UUID) -> Bool { var nextIds = restoredUnreadWorkspaceIds @@ -1066,23 +1014,21 @@ final class TerminalNotificationStore: ObservableObject { restoredUnreadWorkspaceIds = [] } - func hasManualUnread(forTabId tabId: UUID) -> Bool { - manualUnreadWorkspaceIds.contains(tabId) + @discardableResult + private func clearWorkspaceUnreadState(forTabId tabId: UUID) -> Bool { + let clearedManual = setWorkspaceManualUnread(false, forTabId: tabId) + let clearedRestored = setWorkspaceRestoredUnread(false, forTabId: tabId) + return clearedManual || clearedRestored } - func hasPanelDerivedUnread(forTabId tabId: UUID) -> Bool { - panelDerivedUnreadWorkspaceIds.contains(tabId) + func hasManualUnread(forTabId tabId: UUID) -> Bool { + manualUnreadWorkspaceIds.contains(tabId) } func hasRestoredUnreadIndicator(forTabId tabId: UUID) -> Bool { restoredUnreadWorkspaceIds.contains(tabId) } - @discardableResult - func setPanelDerivedUnread(_ isUnread: Bool, forTabId tabId: UUID) -> Bool { - setPanelDerivedWorkspaceUnread(isUnread, forTabId: tabId) - } - @discardableResult func restoreUnreadIndicator(forTabId tabId: UUID) -> Bool { setWorkspaceRestoredUnread(true, forTabId: tabId) @@ -1098,11 +1044,10 @@ final class TerminalNotificationStore: ObservableObject { setWorkspaceManualUnread(false, forTabId: tabId) } - // Per-workspace badges treat workspace indicators as unread activity; - // summing these counts can exceed indexes.unreadCount. + // Per-workspace badges treat manual/restored workspace IDs as "has unread + // activity"; summing these counts can exceed indexes.unreadCount. func unreadCount(forTabId tabId: UUID) -> Int { let hasWorkspaceUnreadIndicator = manualUnreadWorkspaceIds.contains(tabId) || - panelDerivedUnreadWorkspaceIds.contains(tabId) || restoredUnreadWorkspaceIds.contains(tabId) return (indexes.unreadCountByTabId[tabId] ?? 0) + (hasWorkspaceUnreadIndicator ? 1 : 0) } @@ -1573,15 +1518,12 @@ final class TerminalNotificationStore: ObservableObject { idsToClear.append(updated[index].id.uuidString) } } - if !idsToClear.isEmpty { - notifications = updated - } setWorkspaceManualUnread(false, forTabId: tabId) - clearWorkspacePanelUnread(forTabId: tabId) - setPanelDerivedWorkspaceUnread(false, forTabId: tabId) setWorkspaceRestoredUnread(false, forTabId: tabId) if !idsToClear.isEmpty { + notifications = updated center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } } @@ -1595,15 +1537,11 @@ final class TerminalNotificationStore: ObservableObject { idsToClear.append(updated[index].id.uuidString) } } - if !idsToClear.isEmpty { - notifications = updated - } if surfaceId == nil { - clearWorkspacePanelUnread(forTabId: tabId) - setPanelDerivedWorkspaceUnread(false, forTabId: tabId) - setWorkspaceRestoredUnread(false, forTabId: tabId) + clearWorkspaceUnreadState(forTabId: tabId) } if !idsToClear.isEmpty { + notifications = updated center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } @@ -1665,22 +1603,16 @@ final class TerminalNotificationStore: ObservableObject { func markAllRead() { var updated = notifications var idsToClear: [String] = [] - var tabIdsToClearPanelUnread = panelDerivedUnreadWorkspaceIds for index in updated.indices { if !updated[index].isRead { - tabIdsToClearPanelUnread.insert(updated[index].tabId) updated[index].isRead = true idsToClear.append(updated[index].id.uuidString) } } - if !idsToClear.isEmpty { - notifications = updated - } clearWorkspaceManualUnread() - clearAllWorkspacePanelUnread(forTabIds: tabIdsToClearPanelUnread) - clearPanelDerivedWorkspaceUnread() clearWorkspaceRestoredUnread() if !idsToClear.isEmpty { + notifications = updated center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } @@ -1730,14 +1662,10 @@ final class TerminalNotificationStore: ObservableObject { guard !notifications.isEmpty || !focusedReadIndicatorByTabId.isEmpty || !manualUnreadWorkspaceIds.isEmpty || - !panelDerivedUnreadWorkspaceIds.isEmpty || !restoredUnreadWorkspaceIds.isEmpty else { return } - let tabIdsToClearPanelUnread = panelDerivedUnreadWorkspaceIds.union(notifications.map(\.tabId)) let ids = notifications.map { $0.id.uuidString } replaceNotificationsForClear([]) clearWorkspaceManualUnread() - clearAllWorkspacePanelUnread(forTabIds: tabIdsToClearPanelUnread) - clearPanelDerivedWorkspaceUnread() clearWorkspaceRestoredUnread() focusedReadIndicatorByTabId.removeAll() CmuxEventBus.shared.publishNotificationCleared(ids: ids, workspaceId: nil, surfaceId: nil) @@ -1752,7 +1680,8 @@ final class TerminalNotificationStore: ObservableObject { ) { if discardQueuedNotifications { TerminalMutationBus.shared.discardPendingNotifications(forTabId: tabId, surfaceId: surfaceId) } let hadFocusedReadIndicator = focusedReadIndicatorByTabId[tabId].map { $0 == surfaceId } ?? false - let hadRestoredWorkspaceUnread = surfaceId == nil && restoredUnreadWorkspaceIds.contains(tabId) + let hadWorkspaceUnread = surfaceId == nil && + (manualUnreadWorkspaceIds.contains(tabId) || restoredUnreadWorkspaceIds.contains(tabId)) var updated: [TerminalNotification] = [] updated.reserveCapacity(notifications.count) var idsToClear: [String] = [] @@ -1763,12 +1692,12 @@ final class TerminalNotificationStore: ObservableObject { updated.append(notification) } } - guard !idsToClear.isEmpty || hadFocusedReadIndicator || hadRestoredWorkspaceUnread else { return } + guard !idsToClear.isEmpty || hadFocusedReadIndicator || hadWorkspaceUnread else { return } if !idsToClear.isEmpty { replaceNotificationsForClear(updated) } if surfaceId == nil { - setWorkspaceRestoredUnread(false, forTabId: tabId) + clearWorkspaceUnreadState(forTabId: tabId) } clearFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId) if !idsToClear.isEmpty { @@ -1828,8 +1757,6 @@ final class TerminalNotificationStore: ObservableObject { } } setWorkspaceManualUnread(false, forTabId: tabId) - clearWorkspacePanelUnread(forTabId: tabId) - setPanelDerivedWorkspaceUnread(false, forTabId: tabId) setWorkspaceRestoredUnread(false, forTabId: tabId) guard !idsToClear.isEmpty || hadFocusedReadIndicator else { return } if !idsToClear.isEmpty { @@ -2228,7 +2155,6 @@ final class TerminalNotificationStore: ObservableObject { TerminalMutationBus.shared.discardPendingNotifications() self.notifications = notifications clearWorkspaceManualUnread() - clearPanelDerivedWorkspaceUnread() clearWorkspaceRestoredUnread() focusedReadIndicatorByTabId.removeAll() } diff --git a/Sources/TerminalPaneDropTargetView.swift b/Sources/TerminalPaneDropTargetView.swift index 76baedebdd8f..4efebc90b7e8 100644 --- a/Sources/TerminalPaneDropTargetView.swift +++ b/Sources/TerminalPaneDropTargetView.swift @@ -294,7 +294,7 @@ final class PaneDropTargetView: NSView { panelId: panelId, hostedView: terminalPanel.hostedView, urls: urls, - window: window ?? terminalPanel.surface.uiWindow + window: window ?? terminalPanel.hostedView.window ) } if let filePreviewPanel = panel as? FilePreviewPanel { diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 669a11de433d..5bb8444a96e2 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1653,14 +1653,14 @@ private struct NotificationsPopoverView: View { } .buttonStyle(.bordered) .accessibilityIdentifier("notificationsPopover.clearAll") - .disabled(notificationStore.notificationMenuSnapshot.hasNotifications == false) + .disabled(notificationStore.notifications.isEmpty) } .padding(.horizontal, 12) .padding(.vertical, 10) Divider() - if !notificationStore.notificationMenuSnapshot.hasNotifications { + if notificationStore.notifications.isEmpty { VStack(spacing: 8) { Image(systemName: "bell.slash") .font(.system(size: 28)) @@ -1672,15 +1672,6 @@ private struct NotificationsPopoverView: View { .foregroundColor(.secondary) } .frame(minWidth: 420, idealWidth: 520, maxWidth: 640, minHeight: 180) - } else if notificationStore.notifications.isEmpty { - VStack(spacing: 8) { - Image(systemName: "bell.badge") - .font(.system(size: 28)) - .foregroundColor(.secondary) - Text(notificationStore.notificationMenuSnapshot.stateHintTitle) - .font(.headline) - } - .frame(minWidth: 420, idealWidth: 520, maxWidth: 640, minHeight: 180) } else { ScrollView { LazyVStack(spacing: 8) { @@ -1711,7 +1702,7 @@ private struct NotificationsPopoverView: View { } private var hasUnreadNotifications: Bool { - notificationStore.notificationMenuSnapshot.hasUnreadNotifications + notificationStore.notifications.contains(where: { !$0.isRead }) } private func jumpToLatestUnread() { diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index 4539c67cfddf..29de06625d8c 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -8,7 +8,9 @@ extension AgentLaunchCommandSnapshot { executablePath: String?, arguments: [String], workingDirectory: String?, - environment: [String: String] + environment: [String: String], + capturedAt: TimeInterval? = nil, + source: String = "process" ) { var selectedEnvironment = AgentLaunchEnvironmentPolicy.selectedEnvironment(from: environment) if launcher == "opencode", @@ -22,8 +24,8 @@ extension AgentLaunchCommandSnapshot { arguments: arguments, workingDirectory: workingDirectory, environment: selectedEnvironment.isEmpty ? nil : selectedEnvironment, - capturedAt: nil, - source: "process" + capturedAt: capturedAt, + source: source ) } } @@ -122,7 +124,9 @@ extension RestorableAgentSessionIndex { executablePath: executablePath, arguments: arguments, workingDirectory: cwd, - environment: observed.environment + environment: observed.environment, + capturedAt: capturedAt, + source: "process" ), registration: registration ) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 0959bc7a89f1..93bf38ba242f 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -520,6 +520,7 @@ extension Workspace { scrollback: resolvedScrollback, agent: effectiveRestorableAgent, tmuxStartCommand: restorableTmuxStartCommand, + sidekick: terminalPanel.sidekickSessionSnapshot(), resumeBinding: resumeBinding, wasAgentRunning: agentWasRunning ) @@ -996,6 +997,7 @@ extension Workspace { } else { restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id) } + terminalPanel.restoreSidekick(snapshot.terminal?.sidekick) if let restorableAgent { restoredAgentSnapshotsByPanelId[terminalPanel.id] = restorableAgent if restoredAgentWillRunStartupInput { @@ -1415,7 +1417,6 @@ extension Workspace { } } pendingTerminalInputObserversByPanelId[panelId, default: []].append(registration) - panel.surface.requestBackgroundSurfaceStartIfNeeded() guard let timeout else { return } DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self, registration] in @@ -7484,12 +7485,11 @@ final class Workspace: Identifiable, ObservableObject { @Published var panelCustomTitles: [UUID: String] = [:] @Published var pinnedPanelIds: Set = [] @Published var manualUnreadPanelIds: Set = [] { - didSet { - guard manualUnreadPanelIds != oldValue else { return } - syncPanelDerivedWorkspaceUnread() - } + didSet { syncPanelDerivedWorkspaceUnreadState() } + } + @Published private(set) var restoredUnreadPanelIds: Set = [] { + didSet { syncPanelDerivedWorkspaceUnreadState() } } - @Published private(set) var restoredUnreadPanelIds: Set = [] @Published private(set) var tmuxLayoutSnapshot: LayoutSnapshot? @Published private(set) var tmuxWorkspaceFlashPanelId: UUID? @Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason? @@ -8271,7 +8271,7 @@ final class Workspace: Identifiable, ObservableObject { private var layoutFollowUpAttemptScheduled = false private var layoutFollowUpAttemptVersion: Int = 0 private var layoutFollowUpStalledAttemptCount = 0 - private var pendingReparentFocusSuppressionViews: [ObjectIdentifier: GhosttySurfaceScrollView] = [:] + private var layoutFollowUpReparentSuppressedViews: [GhosttySurfaceScrollView] = [] private var portalRenderingEnabled = true private var isAttemptingLayoutFollowUp = false private var isNormalizingPinnedTabOrder = false @@ -8603,8 +8603,18 @@ final class Workspace: Identifiable, ObservableObject { } } - func syncPanelDerivedWorkspaceUnread() { - AppDelegate.shared?.notificationStore?.setPanelDerivedUnread(!manualUnreadPanelIds.isEmpty, forTabId: id) + private func syncPanelDerivedWorkspaceUnreadState() { + let hasPanelUnreadIndicator = + !manualUnreadPanelIds.isEmpty || + !restoredUnreadPanelIds.isEmpty + if let notificationStore = AppDelegate.shared?.notificationStore { + if hasPanelUnreadIndicator { + notificationStore.restoreUnreadIndicator(forTabId: id) + } else { + notificationStore.clearRestoredUnreadIndicator(forTabId: id) + } + } + syncUnreadBadgeStateForAllPanels() } private func normalizePinnedTabs(in paneId: PaneID) { @@ -8680,7 +8690,6 @@ final class Workspace: Identifiable, ObservableObject { return panels[panelId] as? TerminalPanel } } - private func hasBackgroundSurfaceStartWork(for panel: TerminalPanel) -> Bool { panel.surface.hasDeferredStartupWorkForBackgroundStart() || pendingTerminalInputObserversByPanelId[panel.id]?.isEmpty == false @@ -8725,7 +8734,7 @@ final class Workspace: Identifiable, ObservableObject { let isVisibleSelection = bonsplitController.focusedPaneId == paneId && bonsplitController.selectedTab(inPane: paneId)?.id == tabId && - terminalPanel.surface.isViewInWindow && + terminalPanel.hostedView.window != nil && terminalPanel.hostedView.superview != nil if isVisibleSelection { @@ -8774,7 +8783,7 @@ final class Workspace: Identifiable, ObservableObject { let didInsertManual = manualUnreadPanelIds.insert(panelId).inserted guard didInsertManual || didClearRestored else { return } manualUnreadMarkedAt[panelId] = Date() - syncUnreadBadgeStateForPanel(panelId) + syncPanelDerivedWorkspaceUnreadState() } func preferredUnreadPanelIdForJump() -> UUID? { @@ -8799,7 +8808,7 @@ final class Workspace: Identifiable, ObservableObject { AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId) _ = clearManualUnreadState(panelId: panelId) _ = clearRestoredUnreadIndicatorState(panelId: panelId) - syncUnreadBadgeStateForPanel(panelId) + syncPanelDerivedWorkspaceUnreadState() } func clearUnreadAfterJump(panelId: UUID?) { @@ -8815,23 +8824,7 @@ final class Workspace: Identifiable, ObservableObject { let didRemoveManual = clearManualUnreadState(panelId: panelId) let didRemoveRestored = clearRestoredUnreadIndicatorState(panelId: panelId) guard didRemoveManual || didRemoveRestored else { return } - syncUnreadBadgeStateForPanel(panelId) - } - - @discardableResult - func clearAllPanelUnreadIndicatorsForWorkspaceRead() -> Bool { - let hadLocalUnreadIndicators = !manualUnreadPanelIds.isEmpty || !restoredUnreadPanelIds.isEmpty - let affectedPanelIds = Set(panels.keys) - .union(manualUnreadPanelIds) - .union(restoredUnreadPanelIds) - guard !affectedPanelIds.isEmpty else { return false } - manualUnreadPanelIds.removeAll() - restoredUnreadPanelIds.removeAll() - manualUnreadMarkedAt.removeAll() - for panelId in affectedPanelIds { - syncUnreadBadgeStateForPanel(panelId) - } - return hadLocalUnreadIndicators + syncPanelDerivedWorkspaceUnreadState() } private func clearManualUnreadState(panelId: UUID) -> Bool { @@ -8843,13 +8836,13 @@ final class Workspace: Identifiable, ObservableObject { func restorePanelUnreadIndicator(_ panelId: UUID) { guard panels[panelId] != nil else { return } guard restoredUnreadPanelIds.insert(panelId).inserted else { return } - syncUnreadBadgeStateForPanel(panelId) + syncPanelDerivedWorkspaceUnreadState() } func clearRestoredUnreadIndicator(panelId: UUID) { let didRemoveUnread = clearRestoredUnreadIndicatorState(panelId: panelId) guard didRemoveUnread else { return } - syncUnreadBadgeStateForPanel(panelId) + syncPanelDerivedWorkspaceUnreadState() } func hasRestoredUnreadIndicator(panelId: UUID) -> Bool { @@ -10570,14 +10563,8 @@ final class Workspace: Identifiable, ObservableObject { ) #endif - // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. - // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, - // stealing focus from the new panel and creating model/surface divergence. if focus { - suppressReparentFocusUntilLayoutFollowUp( - previousHostedView, - reason: "workspace.terminalSplitReparent" - ) + suppressReparentFocusUntilLayoutSettles(previousHostedView, reason: "terminal_split") focusPanel(newPanel.id, previousHostedView: previousHostedView) } else { preserveFocusAfterNonFocusSplit( @@ -10789,10 +10776,7 @@ final class Workspace: Identifiable, ObservableObject { // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. let previousHostedView = focusedTerminalPanel?.hostedView if focus { - suppressReparentFocusUntilLayoutFollowUp( - previousHostedView, - reason: "workspace.browserSplitReparent" - ) + suppressReparentFocusUntilLayoutSettles(previousHostedView, reason: "browser_split") focusPanel(browserPanel.id) } else { preserveFocusAfterNonFocusSplit( @@ -10978,10 +10962,7 @@ final class Workspace: Identifiable, ObservableObject { let previousHostedView = focusedTerminalPanel?.hostedView if focus { - suppressReparentFocusUntilLayoutFollowUp( - previousHostedView, - reason: "workspace.markdownSplitReparent" - ) + suppressReparentFocusUntilLayoutSettles(previousHostedView, reason: "markdown_split") focusPanel(markdownPanel.id) } else { preserveFocusAfterNonFocusSplit( @@ -12133,15 +12114,10 @@ final class Workspace: Identifiable, ObservableObject { return bonsplitController.focusedPaneId == targetPaneId && bonsplitController.selectedTab(inPane: targetPaneId)?.id == tabId }() - let targetHostedView = terminalPanel(for: panelId)?.hostedView - let targetHasPendingReparentSuppression = targetHostedView.map { hostedView in - hostedView.isSuppressingReparentFocusForLayoutFollowUp() || - pendingReparentFocusSuppressionViews.values.contains { $0 === hostedView } - } ?? false let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged && - targetHasPendingReparentSuppression + previousTerminalHostedView?.isSuppressingReparentFocus == true #if DEBUG let targetPaneShort = targetPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" let focusedPaneShort = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" @@ -12157,23 +12133,7 @@ final class Workspace: Identifiable, ObservableObject { "currentPanel=\(currentPanelShort)" ) #endif - if shouldSuppressReentrantRefocus, currentlyFocusedPanelId == panelId { - if let targetPaneId, let panel = panels[panelId] { - let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation() - applyTabSelection( - tabId: tabId, - inPane: targetPaneId, - reassertAppKitFocus: false, - focusIntent: activationIntent, - previousTerminalHostedView: previousTerminalHostedView - ) - } - beginEventDrivenLayoutFollowUp( - reason: "workspace.focusPanel.terminal", - terminalFocusPanelId: panelId - ) - return - } + if shouldSuppressReentrantRefocus, currentlyFocusedPanelId == panelId { return } if let targetPaneId, !selectionAlreadyConverged { #if DEBUG @@ -12652,6 +12612,31 @@ final class Workspace: Identifiable, ObservableObject { } } + private func suppressReparentFocusUntilLayoutSettles( + _ hostedView: GhosttySurfaceScrollView?, + reason: String + ) { + guard let hostedView else { return } + hostedView.beginSuppressingReparentFocus() + guard portalRenderingEnabled else { + DispatchQueue.main.async { + hostedView.endSuppressingReparentFocus() + } + return + } + layoutFollowUpReparentSuppressedViews.append(hostedView) + beginEventDrivenLayoutFollowUp(reason: "reparent_focus.\(reason)", includeGeometry: true) + } + + private func clearLayoutFollowUpReparentSuppressions() { + guard !layoutFollowUpReparentSuppressedViews.isEmpty else { return } + let suppressedViews = layoutFollowUpReparentSuppressedViews + layoutFollowUpReparentSuppressedViews.removeAll() + for hostedView in suppressedViews { + hostedView.endSuppressingReparentFocus() + } + } + private func beginEventDrivenLayoutFollowUp( reason: String, browserPanelId: UUID? = nil, @@ -12694,69 +12679,6 @@ final class Workspace: Identifiable, ObservableObject { scheduleLayoutFollowUpAttempt() } - private func suppressReparentFocusUntilLayoutFollowUp( - _ hostedView: GhosttySurfaceScrollView?, - reason: String - ) { - guard let hostedView else { return } - hostedView.suppressReparentFocus() - pendingReparentFocusSuppressionViews[ObjectIdentifier(hostedView)] = hostedView -#if DEBUG - cmuxDebugLog("focus.reparent.suppressPending reason=\(reason) count=\(pendingReparentFocusSuppressionViews.count)") -#endif - - guard portalRenderingEnabled else { - clearPendingReparentFocusSuppressions(reason: "\(reason).portalDisabled") - return - } - - beginEventDrivenLayoutFollowUp(reason: reason, includeGeometry: true) - } - - private func clearPendingReparentFocusSuppressions(reason: String) { - guard !pendingReparentFocusSuppressionViews.isEmpty else { return } - let hostedViews = Array(pendingReparentFocusSuppressionViews.values) - pendingReparentFocusSuppressionViews.removeAll() -#if DEBUG - cmuxDebugLog("focus.reparent.clearPending reason=\(reason) count=\(hostedViews.count)") -#endif - for hostedView in hostedViews { - hostedView.clearSuppressReparentFocus() - } - } - - private func clearReadyPendingReparentFocusSuppressions(reason: String) { - guard !pendingReparentFocusSuppressionViews.isEmpty else { return } - let readyKeys = pendingReparentFocusSuppressionViews.compactMap { key, hostedView in - hostedView.canClearPendingReparentFocusSuppressionAfterLayoutAttempt() ? key : nil - } - guard !readyKeys.isEmpty else { return } - let hostedViews = readyKeys.compactMap { pendingReparentFocusSuppressionViews[$0] } - for key in readyKeys { - pendingReparentFocusSuppressionViews.removeValue(forKey: key) - } -#if DEBUG - cmuxDebugLog("focus.reparent.clearReady reason=\(reason) count=\(hostedViews.count)") -#endif - for hostedView in hostedViews { - hostedView.clearSuppressReparentFocus() - } - } - -#if DEBUG - func debugBeginReparentFocusSuppressionForTesting(_ hostedView: GhosttySurfaceScrollView, reason: String) { - suppressReparentFocusUntilLayoutFollowUp(hostedView, reason: reason) - } - - func debugAttemptEventDrivenLayoutFollowUpForTesting() { - attemptEventDrivenLayoutFollowUp() - } - - func debugHasPendingReparentFocusSuppressionsForTesting() -> Bool { - !pendingReparentFocusSuppressionViews.isEmpty - } -#endif - private func installLayoutFollowUpObservers() { guard layoutFollowUpTimeoutWorkItem == nil else { return } @@ -12830,7 +12752,7 @@ final class Workspace: Identifiable, ObservableObject { } private func clearLayoutFollowUp() { - clearPendingReparentFocusSuppressions(reason: "workspace.layoutFollowUpEnd") + clearLayoutFollowUpReparentSuppressions() layoutFollowUpTimeoutWorkItem?.cancel() layoutFollowUpTimeoutWorkItem = nil layoutFollowUpObservers.forEach { NotificationCenter.default.removeObserver($0) } @@ -12944,7 +12866,6 @@ final class Workspace: Identifiable, ObservableObject { let terminalFocusPendingBefore = terminalFocusNeedsFollowUp() let browserPanelPendingBefore = browserPanelNeedsFollowUp() let browserExitPendingBefore = layoutFollowUpBrowserExitFocusPanelId != nil - let reparentFocusPendingBefore = !pendingReparentFocusSuppressionViews.isEmpty if layoutFollowUpNeedsGeometryPass { layoutFollowUpNeedsGeometryPass = reconcileTerminalGeometryPass() @@ -12964,8 +12885,6 @@ final class Workspace: Identifiable, ObservableObject { reconcileTerminalPortalVisibilityForCurrentRenderedLayout() let terminalPortalPending = terminalPortalVisibilityNeedsFollowUp() - clearReadyPendingReparentFocusSuppressions(reason: "workspace.layoutAttempt") - let reparentFocusPending = !pendingReparentFocusSuppressionViews.isEmpty let reason = layoutFollowUpReason ?? "workspace.layout" reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) @@ -13016,8 +12935,7 @@ final class Workspace: Identifiable, ObservableObject { browserVisibilityPending || terminalFocusPending || browserPanelPending || - browserExitPending || - reparentFocusPending + browserExitPending if !needsMoreWork { clearLayoutFollowUp() @@ -13030,8 +12948,7 @@ final class Workspace: Identifiable, ObservableObject { (browserVisibilityPendingBefore && !browserVisibilityPending) || (terminalFocusPendingBefore && !terminalFocusPending) || (browserPanelPendingBefore && !browserPanelPending) || - (browserExitPendingBefore && !browserExitPending) || - (reparentFocusPendingBefore && !reparentFocusPending) + (browserExitPendingBefore && !browserExitPending) if didMakeProgress { layoutFollowUpStalledAttemptCount = 0 @@ -13058,7 +12975,7 @@ final class Workspace: Identifiable, ObservableObject { let hostedView = terminalPanel.hostedView let hasUsableBounds = hostedView.bounds.width > 1 && hostedView.bounds.height > 1 let hasSurface = terminalPanel.surface.surface != nil - let isAttached = terminalPanel.surface.isViewInWindow && hostedView.superview != nil + let isAttached = hostedView.window != nil && hostedView.superview != nil // Split close/reparent churn can transiently detach a surviving terminal view. // Force one SwiftUI representable update so the portal binding reattaches it. @@ -13172,7 +13089,7 @@ final class Workspace: Identifiable, ObservableObject { let hostedView = terminalPanel.hostedView if shouldBeVisible { - if hostedView.isHidden || !terminalPanel.surface.isViewInWindow || hostedView.superview == nil { + if hostedView.isHidden || hostedView.window == nil || hostedView.superview == nil { return true } } else if !hostedView.isHidden { @@ -13566,13 +13483,12 @@ final class Workspace: Identifiable, ObservableObject { orientation: SplitOrientation, insertFirst: Bool, workingDirectory: String?, - initialInput: String?, - remoteStartupCommand: String? = nil + initialInput: String? ) -> TerminalPanel? { var inheritedConfig = inheritedTerminalConfig(inPane: paneId) - let requestedRemoteStartupCommand = remoteStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines) - let startupCommand = requestedRemoteStartupCommand?.isEmpty == false ? requestedRemoteStartupCommand : nil - if startupCommand != nil { + let remoteTerminalStartupCommand = remoteTerminalStartupCommand() + let localWorkingDirectory = remoteTerminalStartupCommand == nil ? workingDirectory : nil + if remoteTerminalStartupCommand != nil { var template = inheritedConfig ?? CmuxSurfaceConfigTemplate() template.waitAfterCommand = true inheritedConfig = template @@ -13582,15 +13498,15 @@ final class Workspace: Identifiable, ObservableObject { workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, - workingDirectory: workingDirectory, + workingDirectory: localWorkingDirectory, portOrdinal: portOrdinal, - initialCommand: startupCommand, + initialCommand: remoteTerminalStartupCommand, initialInput: initialInput ) configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle - if startupCommand != nil { + if remoteTerminalStartupCommand != nil { trackRemoteTerminalSurface(newPanel.id) } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) @@ -13610,7 +13526,7 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) - if startupCommand != nil { + if remoteTerminalStartupCommand != nil { untrackRemoteTerminalSurface(newPanel.id) } terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) @@ -13623,123 +13539,6 @@ final class Workspace: Identifiable, ObservableObject { return newPanel } - struct AgentConversationForkWorkspaceLaunch: Equatable { - var workingDirectory: String? - var terminalWorkingDirectory: String? - var initialTerminalCommand: String? - var initialTerminalInput: String - var remoteConfiguration: WorkspaceRemoteConfiguration? - var autoConnectRemoteConfiguration: Bool - } - - func forkAgentWorkspaceLaunch( - fromPanelId panelId: UUID, - snapshot: SessionRestorableAgentSnapshot, - fileManager: FileManager = .default, - temporaryDirectory: URL = FileManager.default.temporaryDirectory - ) -> AgentConversationForkWorkspaceLaunch? { - var launchSnapshot = snapshot - let workingDirectory = forkAgentWorkingDirectory(fromPanelId: panelId, snapshot: snapshot) - launchSnapshot.workingDirectory = workingDirectory - let remoteStartupCommand = forkAgentRemoteStartupCommand(fromPanelId: panelId) - let remoteConfiguration = forkAgentRemoteConfigurationForNewWorkspace(fromPanelId: panelId) - let isRemoteFork = remoteConfiguration?.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false - guard panels[panelId] is TerminalPanel, - let startupInput = launchSnapshot.forkStartupInput( - fileManager: fileManager, - temporaryDirectory: temporaryDirectory, - allowLauncherScript: !isRemoteFork - ) else { - return nil - } - - return AgentConversationForkWorkspaceLaunch( - workingDirectory: workingDirectory, - terminalWorkingDirectory: isRemoteFork ? nil : workingDirectory, - initialTerminalCommand: remoteConfiguration?.terminalStartupCommand ?? remoteStartupCommand, - initialTerminalInput: startupInput, - remoteConfiguration: remoteConfiguration, - autoConnectRemoteConfiguration: remoteConfiguration != nil - ) - } - - @discardableResult - func forkAgentConversation( - fromPanelId panelId: UUID, - snapshot: SessionRestorableAgentSnapshot, - direction: SplitDirection, - fileManager: FileManager = .default, - temporaryDirectory: URL = FileManager.default.temporaryDirectory - ) -> TerminalPanel? { - var launchSnapshot = snapshot - let workingDirectory = forkAgentWorkingDirectory(fromPanelId: panelId, snapshot: snapshot) - launchSnapshot.workingDirectory = workingDirectory - let remoteStartupCommand = forkAgentRemoteStartupCommand(fromPanelId: panelId) - guard panels[panelId] is TerminalPanel, - let paneId = paneId(forPanelId: panelId), - let startupInput = launchSnapshot.forkStartupInput( - fileManager: fileManager, - temporaryDirectory: temporaryDirectory, - allowLauncherScript: remoteStartupCommand == nil - ) else { - return nil - } - - let zoomedPaneId = bonsplitController.zoomedPaneId - if zoomedPaneId != nil { - clearSplitZoom() - } - let forkedPanel = splitPaneWithNewTerminal( - targetPane: paneId, - orientation: direction.orientation, - insertFirst: direction.insertFirst, - workingDirectory: remoteStartupCommand == nil ? workingDirectory : nil, - initialInput: startupInput, - remoteStartupCommand: remoteStartupCommand - ) - if let forkedPanel, - remoteStartupCommand != nil, - let workingDirectory { - updatePanelDirectory(panelId: forkedPanel.id, directory: workingDirectory) - } - if forkedPanel == nil, let zoomedPaneId { - _ = bonsplitController.togglePaneZoom(inPane: zoomedPaneId) - } - return forkedPanel - } - - func forkAgentWorkingDirectory( - fromPanelId panelId: UUID, - snapshot: SessionRestorableAgentSnapshot - ) -> String? { - Self.firstNonEmptyPath([ - snapshot.workingDirectory, - panelDirectories[panelId], - terminalPanel(for: panelId)?.requestedWorkingDirectory, - currentDirectory - ]) - } - - private func forkAgentRemoteStartupCommand(fromPanelId panelId: UUID) -> String? { - guard isRemoteTerminalSurface(panelId) else { return nil } - return remoteTerminalStartupCommand() - } - - private func forkAgentRemoteConfigurationForNewWorkspace(fromPanelId panelId: UUID) -> WorkspaceRemoteConfiguration? { - guard forkAgentRemoteStartupCommand(fromPanelId: panelId) != nil else { return nil } - return remoteConfiguration?.sessionSnapshot()?.workspaceConfiguration() ?? remoteConfiguration - } - - private static func firstNonEmptyPath(_ candidates: [String?]) -> String? { - for candidate in candidates { - let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) - if let trimmed, !trimmed.isEmpty { - return trimmed - } - } - return nil - } - func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool { // Session-index drag → spawn a brand new terminal at the destination instead // of moving an existing tab. @@ -14129,7 +13928,7 @@ extension Workspace: BonsplitDelegate { private func activationWindow(for panel: any Panel) -> NSWindow? { if let terminalPanel = panel as? TerminalPanel { - return terminalPanel.surface.uiWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + return terminalPanel.hostedView.window ?? NSApp.keyWindow ?? NSApp.mainWindow } if let browserPanel = panel as? BrowserPanel { return browserPanel.webView.window ?? browserPanel.portalAnchorView.window ?? NSApp.keyWindow ?? NSApp.mainWindow @@ -14837,7 +14636,7 @@ extension Workspace: BonsplitDelegate { guard let executable = surfaceTabBarCommandButtons[identifier] else { return } - let presentingWindow = selectedTerminalPanel(inPane: pane)?.surface.uiWindow + let presentingWindow = selectedTerminalPanel(inPane: pane)?.hostedView.window ?? NSApp.keyWindow ?? NSApp.mainWindow diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 7fae9209ecd2..585f3d7a6069 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -310,11 +310,6 @@ struct cmuxApp: App { } .disabled(!snapshot.hasUnreadNotifications) - splitCommandButton(title: String(localized: "menu.notifications.toggleUnread", defaultValue: "Toggle Unread"), shortcut: menuShortcut(for: .toggleUnread)) { - appDelegate.toggleFocusedNotificationUnread() - } - .disabled(activeTabManager.selectedWorkspace == nil) - Button(String(localized: "menu.notifications.markAllRead", defaultValue: "Mark All Read")) { notificationStore.markAllRead() } @@ -876,7 +871,7 @@ struct cmuxApp: App { } private var notificationMenuSnapshot: NotificationMenuSnapshot { - notificationStore.notificationMenuSnapshot + NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) } var activeTabManager: TabManager { @@ -4785,17 +4780,9 @@ enum CmdClickSupportedFileRouteSettings { return defaults.object(forKey: key) == nil ? defaultValue : defaults.bool(forKey: key) } - static func setEnabled( - _ enabled: Bool, - defaults: UserDefaults = .standard, - notificationCenter: NotificationCenter = .default - ) { + static func setEnabled(_ enabled: Bool, defaults: UserDefaults = .standard) { defaults.set(enabled, forKey: key) - notifyDidChange(notificationCenter: notificationCenter) - } - - static func notifyDidChange(notificationCenter: NotificationCenter = .default) { - notificationCenter.post(name: didChangeNotification, object: nil) + NotificationCenter.default.post(name: didChangeNotification, object: nil) } static func shouldRoute(path: String, defaults: UserDefaults = .standard) -> Bool { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index fe7cac4b2187..88ac523716cd 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -150,7 +150,6 @@ 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F016B5C09357B3226FA2E014 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift */; }; C3408A000000000000000001 /* ContentView+RightSidebarCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */; }; C3408A000000000000000005 /* ContentView+ViewCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3408A000000000000000006 /* ContentView+ViewCommandPalette.swift */; }; - C0DEF0A90000000000000001 /* ContentView+ForkAgentConversation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A90000000000000002 /* ContentView+ForkAgentConversation.swift */; }; C0DEF4110000000000000001 /* CommandPaletteSettingsToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF4110000000000000002 /* CommandPaletteSettingsToggle.swift */; }; D7AB00000000000000000003 /* ContentView+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */; }; C0DE32470000000000000001 /* ContentViewIdentifierCopyCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */; }; @@ -166,9 +165,6 @@ A5001800A1B2C3D4E5F60718 /* WindowBackdropController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001801A1B2C3D4E5F60718 /* WindowBackdropController.swift */; }; F57072635F25EBCA741E125D /* SidebarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */; }; A8CBA43C2DA5AB9E3A1E65A4 /* CommandPaletteSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A09EB2E92203B2E95923A7 /* CommandPaletteSearch.swift */; }; - C0DEFF100000000000000001 /* CommandPaletteNucleoSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFF100000000000000002 /* CommandPaletteNucleoSearch.swift */; }; - C0DEFF200000000000000001 /* CommandPaletteOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFF200000000000000002 /* CommandPaletteOverlay.swift */; }; - C0DEFF300000000000000001 /* CommandPaletteSearchOrchestrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFF300000000000000002 /* CommandPaletteSearchOrchestrator.swift */; }; B84BD7AD94EE485B8DDAB6FF /* GhosttySurfaceConfigurationRefresh.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DAB808A8EC74C40B95F7672 /* GhosttySurfaceConfigurationRefresh.swift */; }; C4041001000000000000001B /* CommandClickFileOpenRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4041001000000000000001A /* CommandClickFileOpenRouter.swift */; }; 0D56BE882EAD4B67AC44F96D /* TerminalDirectoryOpenSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B8E2E03F4A64C61B729CF19 /* TerminalDirectoryOpenSupport.swift */; }; @@ -266,6 +262,7 @@ F1A0C0DE0000000000000002 /* FindTextFieldSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0C0DE0000000000000001 /* FindTextFieldSupport.swift */; }; A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; }; A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; }; + C3866A000000000000000001 /* TerminalSidekickCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3866A000000000000000002 /* TerminalSidekickCoordinator.swift */; }; B42450010000000000000001 /* BrowserHiddenWebViewDiscardPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42450020000000000000001 /* BrowserHiddenWebViewDiscardPolicy.swift */; }; B42450030000000000000001 /* BrowserHiddenWebViewDiscardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42450040000000000000001 /* BrowserHiddenWebViewDiscardManager.swift */; }; A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; @@ -274,7 +271,6 @@ D7AB00000000000000000007 /* BrowserPanel+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000008 /* BrowserPanel+MoveTabToNewWorkspace.swift */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; }; - B0A501000000000000000001 /* BrowserOmnibarAppKitBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A501000000000000000002 /* BrowserOmnibarAppKitBridge.swift */; }; B0A500000000000000000001 /* BrowserOmnibarPerformanceSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A500000000000000000002 /* BrowserOmnibarPerformanceSupport.swift */; }; A5001405 /* PanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001415 /* PanelContentView.swift */; }; A5001406 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001416 /* Workspace.swift */; }; @@ -342,12 +338,11 @@ B35751000000000000000002 /* TmuxResumeParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35751000000000000000001 /* TmuxResumeParser.swift */; }; A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; }; C3873001C3873001C3873001 /* GhosttyCrashBreadcrumb.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3873002C3873002C3873002 /* GhosttyCrashBreadcrumb.swift */; }; - C3873003C3873003C3873003 /* GhosttyCrashReportMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3873004C3873004C3873004 /* GhosttyCrashReportMetadata.swift */; }; A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; }; A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; A5001660 /* RestorableAgentSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001661 /* RestorableAgentSession.swift */; }; - C0DEF0C10000000000000001 /* AgentForkSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0C10000000000000002 /* AgentForkSupport.swift */; }; - C0DEF0C20000000000000001 /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0C20000000000000002 /* SemanticVersion.swift */; }; + A5001662 /* AgentForkSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001663 /* AgentForkSupport.swift */; }; + A5001664 /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001665 /* SemanticVersion.swift */; }; A5001650 /* CmuxConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* CmuxConfig.swift */; }; C10D00030000000000000003 /* CmuxSurfaceTabBarBuiltInAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10D00040000000000000004 /* CmuxSurfaceTabBarBuiltInAction.swift */; }; E30750000000000000000004 /* CmuxWorkspaceDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30750000000000000000003 /* CmuxWorkspaceDefinition.swift */; }; @@ -360,9 +355,6 @@ A5008373 /* BrowserFindJavaScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008372 /* BrowserFindJavaScript.swift */; }; A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; }; A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; }; - C0DEC0DE000000000000F102 /* CommandPaletteNucleoFFITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE000000000000F101 /* CommandPaletteNucleoFFITests.swift */; }; - C0DEC0DE000000000000F202 /* CommandPaletteNucleoFFILibrarySupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE000000000000F201 /* CommandPaletteNucleoFFILibrarySupport.swift */; }; - C0DEC0DE000000000000F204 /* CommandPaletteNucleoFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE000000000000F203 /* CommandPaletteNucleoFixtures.swift */; }; A500RG01 /* ReactGrab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500RG00 /* ReactGrab.swift */; }; A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */; }; A5F10013A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore+Template.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F10012A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore+Template.swift */; }; @@ -681,7 +673,6 @@ C0DE35010000000000000002 /* SidebarScrim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarScrim.swift; sourceTree = ""; }; C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+RightSidebarCommandPalette.swift"; sourceTree = ""; }; C3408A000000000000000006 /* ContentView+ViewCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+ViewCommandPalette.swift"; sourceTree = ""; }; - C0DEF0A90000000000000002 /* ContentView+ForkAgentConversation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+ForkAgentConversation.swift"; sourceTree = ""; }; D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; D7AB34300000000000000002 /* SidebarDropPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDropPlanner.swift; sourceTree = ""; }; D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift; sourceTree = ""; }; @@ -700,9 +691,6 @@ A5001801A1B2C3D4E5F60718 /* WindowBackdropController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windowing/WindowBackdropController.swift; sourceTree = ""; }; D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarState.swift; sourceTree = ""; }; 38A09EB2E92203B2E95923A7 /* CommandPaletteSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette/CommandPaletteSearch.swift; sourceTree = ""; }; - C0DEFF100000000000000002 /* CommandPaletteNucleoSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette/CommandPaletteNucleoSearch.swift; sourceTree = ""; }; - C0DEFF200000000000000002 /* CommandPaletteOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette/CommandPaletteOverlay.swift; sourceTree = ""; }; - C0DEFF300000000000000002 /* CommandPaletteSearchOrchestrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette/CommandPaletteSearchOrchestrator.swift; sourceTree = ""; }; C0DEF4110000000000000002 /* CommandPaletteSettingsToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette/CommandPaletteSettingsToggle.swift; sourceTree = ""; }; 9DAB808A8EC74C40B95F7672 /* GhosttySurfaceConfigurationRefresh.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/GhosttySurfaceConfigurationRefresh.swift; sourceTree = ""; }; 6B8E2E03F4A64C61B729CF19 /* TerminalDirectoryOpenSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TerminalDirectoryOpenSupport.swift; sourceTree = ""; }; @@ -776,7 +764,6 @@ C7A505000000000000000001 /* TerminalControllerTopSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerTopSupport.swift; sourceTree = ""; }; A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; C3873002C3873002C3873002 /* GhosttyCrashBreadcrumb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyCrashBreadcrumb.swift; sourceTree = ""; }; - C3873004C3873004C3873004 /* GhosttyCrashReportMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyCrashReportMetadata.swift; sourceTree = ""; }; C3677002000000000000002 /* CmuxSSHURLRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxSSHURLRequest.swift; sourceTree = ""; }; C3677004000000000000002 /* AppDelegate+CmuxSSHURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+CmuxSSHURL.swift"; sourceTree = ""; }; E3309A02 /* AppDelegate+EqualizeSplitsShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+EqualizeSplitsShortcut.swift"; sourceTree = ""; }; @@ -832,6 +819,7 @@ F1A0C0DE0000000000000001 /* FindTextFieldSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/FindTextFieldSupport.swift; sourceTree = ""; }; A5001410 /* Panel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/Panel.swift; sourceTree = ""; }; A5001411 /* TerminalPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanel.swift; sourceTree = ""; }; + C3866A000000000000000002 /* TerminalSidekickCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalSidekickCoordinator.swift; sourceTree = ""; }; B42450020000000000000001 /* BrowserHiddenWebViewDiscardPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserHiddenWebViewDiscardPolicy.swift; sourceTree = ""; }; B42450040000000000000001 /* BrowserHiddenWebViewDiscardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserHiddenWebViewDiscardManager.swift; sourceTree = ""; }; A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = ""; }; @@ -842,7 +830,6 @@ D7AB00000000000000000008 /* BrowserPanel+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = ""; }; A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = ""; }; - B0A501000000000000000002 /* BrowserOmnibarAppKitBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserOmnibarAppKitBridge.swift; sourceTree = ""; }; B0A500000000000000000002 /* BrowserOmnibarPerformanceSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserOmnibarPerformanceSupport.swift; sourceTree = ""; }; A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = ""; }; A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; @@ -903,8 +890,8 @@ A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxApplicationSupportDirectories.swift; sourceTree = ""; }; A5001661 /* RestorableAgentSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentSession.swift; sourceTree = ""; }; - C0DEF0C10000000000000002 /* AgentForkSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentForkSupport.swift; sourceTree = ""; }; - C0DEF0C20000000000000002 /* SemanticVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemanticVersion.swift; sourceTree = ""; }; + A5001663 /* AgentForkSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentForkSupport.swift; sourceTree = ""; }; + A5001665 /* SemanticVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemanticVersion.swift; sourceTree = ""; }; A5001651 /* CmuxConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfig.swift; sourceTree = ""; }; C10D00040000000000000004 /* CmuxSurfaceTabBarBuiltInAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxSurfaceTabBarBuiltInAction.swift; sourceTree = ""; }; E30750000000000000000003 /* CmuxWorkspaceDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWorkspaceDefinition.swift; sourceTree = ""; }; @@ -917,9 +904,6 @@ A5008372 /* BrowserFindJavaScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindJavaScript.swift; sourceTree = ""; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; - C0DEC0DE000000000000F101 /* CommandPaletteNucleoFFITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteNucleoFFITests.swift; sourceTree = ""; }; - C0DEC0DE000000000000F201 /* CommandPaletteNucleoFFILibrarySupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteNucleoFFILibrarySupport.swift; sourceTree = ""; }; - C0DEC0DE000000000000F203 /* CommandPaletteNucleoFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteNucleoFixtures.swift; sourceTree = ""; }; A500RG00 /* ReactGrab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ReactGrab.swift; sourceTree = ""; }; A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettingsFileStore.swift; sourceTree = ""; }; A5F10012A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore+Template.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcutSettingsFileStore+Template.swift"; sourceTree = ""; }; @@ -1246,7 +1230,6 @@ D0B10015A1B2C3D4E5F60001 /* DragOverlayRoutingPolicy.swift */, C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */, C3408A000000000000000006 /* ContentView+ViewCommandPalette.swift */, - C0DEF0A90000000000000002 /* ContentView+ForkAgentConversation.swift */, D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */, D7AB34300000000000000002 /* SidebarDropPlanner.swift */, D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */, @@ -1263,9 +1246,6 @@ A5001801A1B2C3D4E5F60718 /* WindowBackdropController.swift */, D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */, 38A09EB2E92203B2E95923A7 /* CommandPaletteSearch.swift */, - C0DEFF100000000000000002 /* CommandPaletteNucleoSearch.swift */, - C0DEFF200000000000000002 /* CommandPaletteOverlay.swift */, - C0DEFF300000000000000002 /* CommandPaletteSearchOrchestrator.swift */, C0DEF4110000000000000002 /* CommandPaletteSettingsToggle.swift */, 9DAB808A8EC74C40B95F7672 /* GhosttySurfaceConfigurationRefresh.swift */, 6B8E2E03F4A64C61B729CF19 /* TerminalDirectoryOpenSupport.swift */, @@ -1372,7 +1352,6 @@ A5001090 /* AppDelegate.swift */, 3865B0063865B0063865B006 /* AppDelegate+GlobalSearch.swift */, C3873002C3873002C3873002 /* GhosttyCrashBreadcrumb.swift */, - C3873004C3873004C3873004 /* GhosttyCrashReportMetadata.swift */, C3677002000000000000002 /* CmuxSSHURLRequest.swift */, C3677004000000000000002 /* AppDelegate+CmuxSSHURL.swift */, E3309A02 /* AppDelegate+EqualizeSplitsShortcut.swift */, @@ -1397,6 +1376,7 @@ 3865B0033865B0033865B003 /* MenubarSearchPopover.swift */, A5001410 /* Panel.swift */, A5001411 /* TerminalPanel.swift */, + C3866A000000000000000002 /* TerminalSidekickCoordinator.swift */, B42450020000000000000001 /* BrowserHiddenWebViewDiscardPolicy.swift */, B42450040000000000000001 /* BrowserHiddenWebViewDiscardManager.swift */, A5001412 /* BrowserPanel.swift */, @@ -1408,7 +1388,6 @@ A500RG00 /* ReactGrab.swift */, A5001413 /* TerminalPanelView.swift */, A5001414 /* BrowserPanelView.swift */, - B0A501000000000000000002 /* BrowserOmnibarAppKitBridge.swift */, B0A500000000000000000002 /* BrowserOmnibarPerformanceSupport.swift */, A5007421 /* BrowserPopupWindowController.swift */, A5007423 /* BrowserWebAuthnSupport.swift */, @@ -1459,8 +1438,8 @@ A5001671 /* SessionRestoredTerminalCommandStore.swift */, B35751000000000000000001 /* TmuxResumeParser.swift */, A5001661 /* RestorableAgentSession.swift */, - C0DEF0C10000000000000002 /* AgentForkSupport.swift */, - C0DEF0C20000000000000002 /* SemanticVersion.swift */, + A5001663 /* AgentForkSupport.swift */, + A5001665 /* SemanticVersion.swift */, A5001641 /* RemoteRelayZshBootstrap.swift */, D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */, A5001651 /* CmuxConfig.swift */, @@ -1587,9 +1566,6 @@ A5008380 /* BrowserFindJavaScriptTests.swift */, C2B6A97D1F2E4C71A8B9D002 /* BrowserOmnibarPerformanceSupportTests.swift */, A5008382 /* CommandPaletteSearchEngineTests.swift */, - C0DEC0DE000000000000F101 /* CommandPaletteNucleoFFITests.swift */, - C0DEC0DE000000000000F201 /* CommandPaletteNucleoFFILibrarySupport.swift */, - C0DEC0DE000000000000F203 /* CommandPaletteNucleoFixtures.swift */, A50019B3 /* SettingsSearchIndexTests.swift */, D36090010000000000000004 /* SettingsWindowPresenterTests.swift */, 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */, @@ -1689,7 +1665,6 @@ D1320AA0D1320AA0D1320AA6 /* Copy Dock Tile Plugin */, A5001020 /* Embed Frameworks */, B900000AA1B2C3D4E5F60719 /* Copy CLI */, - C0DEFF100000000000000003 /* Build Command Palette Nucleo FFI */, A5001300A1B2C3D4E5F60719 /* ShellScript */, ); buildRules = ( @@ -1938,29 +1913,6 @@ shellPath = /bin/sh; shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nCMUX_SHELL_DEST=\"${DEST}/shell-integration\"\nBIN_DEST=\"${DEST}/bin\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nCMUX_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nCMUX_GHOSTTY_ZSH_SRC=\"${SRCROOT}/ghostty/src/shell-integration/zsh/ghostty-integration\"\nBUILD_GHOSTTY_HELPER=\"${SRCROOT}/scripts/build-ghostty-cli-helper.sh\"\nGHOSTTY_HELPER_DEST=\"${BIN_DEST}/ghostty\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any cmux-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\nif [ -d \"$CMUX_SHELL_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$CMUX_SHELL_SRC/.\" \"$CMUX_SHELL_DEST/\"\nfi\nif [ -f \"$CMUX_GHOSTTY_ZSH_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n rsync -a \"$CMUX_GHOSTTY_ZSH_SRC\" \"$CMUX_SHELL_DEST/ghostty-integration.zsh\"\nfi\nif [ ! -x \"$BUILD_GHOSTTY_HELPER\" ]; then\n echo \"error: missing Ghostty CLI helper build script at $BUILD_GHOSTTY_HELPER\" >&2\n exit 1\nfi\nARCHS_LIST=\" ${ARCHS:-} \"\nHAS_ARM64=0\nHAS_X86_64=0\nGHOSTTY_HELPER_TARGET=\"\"\ncase \"$ARCHS_LIST\" in\n *\" arm64 \"*) HAS_ARM64=1 ;;\nesac\ncase \"$ARCHS_LIST\" in\n *\" x86_64 \"*) HAS_X86_64=1 ;;\nesac\nif [ \"$HAS_ARM64\" -eq 1 ] && [ \"$HAS_X86_64\" -eq 1 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --universal --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"aarch64-macos\"\nelif [ \"$HAS_X86_64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"x86_64-macos\"\nfi\nif [ -n \"$GHOSTTY_HELPER_TARGET\" ]; then\n \"$BUILD_GHOSTTY_HELPER\" --target \"$GHOSTTY_HELPER_TARGET\" --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 0 ] || [ \"$HAS_X86_64\" -eq 0 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --output \"$GHOSTTY_HELPER_DEST\"\nfi\nif [ ! -x \"$GHOSTTY_HELPER_DEST\" ]; then\n echo \"error: Ghostty CLI helper was not created at $GHOSTTY_HELPER_DEST\" >&2\n exit 1\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :CMUXCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :CMUXCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n"; }; - C0DEFF100000000000000003 /* Build Command Palette Nucleo FFI */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "$(SRCROOT)/Native/CommandPaletteNucleoFFI/Cargo.toml", - "$(SRCROOT)/Native/CommandPaletteNucleoFFI/Cargo.lock", - "$(SRCROOT)/Native/CommandPaletteNucleoFFI/src/lib.rs", - "$(SRCROOT)/scripts/build-command-palette-nucleo-ffi.sh", - ); - name = "Build Command Palette Nucleo FFI"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/libcmux_command_palette_nucleo_ffi.dylib", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/scripts/build-command-palette-nucleo-ffi.sh\"\n"; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -1991,7 +1943,6 @@ C0DE35010000000000000001 /* SidebarScrim.swift in Sources */, C3408A000000000000000001 /* ContentView+RightSidebarCommandPalette.swift in Sources */, C3408A000000000000000005 /* ContentView+ViewCommandPalette.swift in Sources */, - C0DEF0A90000000000000001 /* ContentView+ForkAgentConversation.swift in Sources */, D7AB00000000000000000003 /* ContentView+MoveTabToNewWorkspace.swift in Sources */, D7AB34300000000000000001 /* SidebarDropPlanner.swift in Sources */, D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */, @@ -2008,9 +1959,6 @@ A5001800A1B2C3D4E5F60718 /* WindowBackdropController.swift in Sources */, F57072635F25EBCA741E125D /* SidebarState.swift in Sources */, A8CBA43C2DA5AB9E3A1E65A4 /* CommandPaletteSearch.swift in Sources */, - C0DEFF100000000000000001 /* CommandPaletteNucleoSearch.swift in Sources */, - C0DEFF200000000000000001 /* CommandPaletteOverlay.swift in Sources */, - C0DEFF300000000000000001 /* CommandPaletteSearchOrchestrator.swift in Sources */, C0DEF4110000000000000001 /* CommandPaletteSettingsToggle.swift in Sources */, B84BD7AD94EE485B8DDAB6FF /* GhosttySurfaceConfigurationRefresh.swift in Sources */, 0D56BE882EAD4B67AC44F96D /* TerminalDirectoryOpenSupport.swift in Sources */, @@ -2118,7 +2066,6 @@ A5001621 /* AppleScriptSupport.swift in Sources */, F4350A110000000000000001 /* AppBundleIconPersistencePolicy.swift in Sources */, C3873001C3873001C3873001 /* GhosttyCrashBreadcrumb.swift in Sources */, - C3873003C3873003C3873003 /* GhosttyCrashReportMetadata.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, C3677002000000000000001 /* CmuxSSHURLRequest.swift in Sources */, C3677004000000000000001 /* AppDelegate+CmuxSSHURL.swift in Sources */, @@ -2146,6 +2093,7 @@ 3865A0063865A0063865A006 /* AppDelegate+GlobalSearch.swift in Sources */, A5001400 /* Panel.swift in Sources */, A5001401 /* TerminalPanel.swift in Sources */, + C3866A000000000000000001 /* TerminalSidekickCoordinator.swift in Sources */, B42450010000000000000001 /* BrowserHiddenWebViewDiscardPolicy.swift in Sources */, B42450030000000000000001 /* BrowserHiddenWebViewDiscardManager.swift in Sources */, A5001402 /* BrowserPanel.swift in Sources */, @@ -2157,7 +2105,6 @@ A500RG01 /* ReactGrab.swift in Sources */, A5001403 /* TerminalPanelView.swift in Sources */, A5001404 /* BrowserPanelView.swift in Sources */, - B0A501000000000000000001 /* BrowserOmnibarAppKitBridge.swift in Sources */, B0A500000000000000000001 /* BrowserOmnibarPerformanceSupport.swift in Sources */, A5007420 /* BrowserPopupWindowController.swift in Sources */, A5007422 /* BrowserWebAuthnSupport.swift in Sources */, @@ -2208,8 +2155,8 @@ A5001670 /* SessionRestoredTerminalCommandStore.swift in Sources */, B35751000000000000000002 /* TmuxResumeParser.swift in Sources */, A5001660 /* RestorableAgentSession.swift in Sources */, - C0DEF0C10000000000000001 /* AgentForkSupport.swift in Sources */, - C0DEF0C20000000000000001 /* SemanticVersion.swift in Sources */, + A5001662 /* AgentForkSupport.swift in Sources */, + A5001664 /* SemanticVersion.swift in Sources */, A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, A5001650 /* CmuxConfig.swift in Sources */, C10D00030000000000000003 /* CmuxSurfaceTabBarBuiltInAction.swift in Sources */, @@ -2376,9 +2323,6 @@ FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, - C0DEC0DE000000000000F102 /* CommandPaletteNucleoFFITests.swift in Sources */, - C0DEC0DE000000000000F202 /* CommandPaletteNucleoFFILibrarySupport.swift in Sources */, - C0DEC0DE000000000000F204 /* CommandPaletteNucleoFixtures.swift in Sources */, A50019B2 /* SettingsSearchIndexTests.swift in Sources */, D36090010000000000000003 /* SettingsWindowPresenterTests.swift in Sources */, E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */, diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 5b74e7c3e703..ae03910bbd33 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -941,8 +941,11 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { let defaults = UserDefaults.standard let previousRightSidebarVisibility = defaults.object(forKey: "fileExplorer.isVisible") + let wasBrowserDisabled = BrowserAvailabilitySettings.isDisabled() + BrowserAvailabilitySettings.setDisabled(false) defer { restoreDefaultsValue(previousRightSidebarVisibility, forKey: "fileExplorer.isVisible", defaults: defaults) + BrowserAvailabilitySettings.setDisabled(wasBrowserDisabled) } let windowId = UUID() @@ -996,9 +999,44 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: leftSidebarEvent)) XCTAssertFalse(sidebarState.isVisible, "Cmd+B should toggle the Welcome window left sidebar") + BrowserAvailabilitySettings.setDisabled(true) XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: rightSidebarEvent)) _ = waitForCondition { fileExplorerState.isVisible } - XCTAssertTrue(fileExplorerState.isVisible, "Cmd+Option+B should toggle the Welcome window right sidebar") + XCTAssertFalse( + tabManager.selectedWorkspace?.focusedTerminalPanel?.sidekickState.isOpen == true, + "Cmd+Option+B should not open a terminal sidekick when browser surfaces are disabled" + ) + XCTAssertTrue(fileExplorerState.isVisible, "Cmd+Option+B should fall through to the right sidebar when browser surfaces are disabled") + fileExplorerState.setVisible(false) + BrowserAvailabilitySettings.setDisabled(false) + + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: rightSidebarEvent)) + _ = waitForCondition { + tabManager.selectedWorkspace?.focusedTerminalPanel?.sidekickState.isOpen == true + } + XCTAssertTrue( + tabManager.selectedWorkspace?.focusedTerminalPanel?.sidekickState.isOpen == true, + "Cmd+Option+B should toggle the focused terminal sidekick before falling back to the right sidebar" + ) + XCTAssertFalse(fileExplorerState.isVisible) + + let previousTerminalPanel = tabManager.selectedWorkspace?.focusedTerminalPanel + let previousSidekickState = previousTerminalPanel?.sidekickState + if let workspace = tabManager.selectedWorkspace, + let paneId = workspace.bonsplitController.allPaneIds.first, + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) { + workspace.focusPanel(browserPanel.id) + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: rightSidebarEvent)) + _ = waitForCondition { fileExplorerState.isVisible } + XCTAssertEqual( + previousTerminalPanel?.sidekickState, + previousSidekickState, + "Cmd+Option+B should not mutate the previous terminal sidekick when a browser panel is focused" + ) + XCTAssertTrue(fileExplorerState.isVisible, "Cmd+Option+B should toggle the right sidebar outside focused terminals") + } else { + XCTFail("Expected to create a browser panel for right-sidebar shortcut routing") + } #else XCTFail("debugHandleCustomShortcut is only available in DEBUG") #endif @@ -1638,9 +1676,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) workspace.focusPanel(rightPanel.id) XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected Bonsplit selection to stay on the right pane") - leftPanel.hostedView.suppressReparentFocus() + leftPanel.hostedView.beginSuppressingReparentFocus() XCTAssertTrue(window.makeFirstResponder(leftSurfaceView)) - leftPanel.hostedView.clearSuppressReparentFocus() + leftPanel.hostedView.endSuppressingReparentFocus() XCTAssertTrue(window.firstResponder === leftSurfaceView, "Expected left Ghostty surface to stay first responder") XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected selected pane to stay stale after first-responder change") XCTAssertEqual(leftSurfaceView.tabId, workspace.id, "Expected focused Ghostty view to keep its workspace ID") @@ -3243,9 +3281,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { field.panelId = browserPanelId field.stringValue = "example" contentView.addSubview(field) - BrowserOmnibarNativeFieldRegistry.shared.register(field, panelId: browserPanelId) defer { - BrowserOmnibarNativeFieldRegistry.shared.unregister(field, panelId: browserPanelId) field.removeFromSuperview() } @@ -3314,10 +3350,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { field.panelId = browserPanelId field.stringValue = "ㄉㄚˋ" contentView.addSubview(field) - BrowserOmnibarNativeFieldRegistry.shared.register(field, panelId: browserPanelId) defer { - BrowserOmnibarNativeFieldRegistry.shared.unregister(field, panelId: browserPanelId) field.removeFromSuperview() } @@ -3393,10 +3427,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { field.panelId = browserPanelId field.stringValue = "example" contentView.addSubview(field) - BrowserOmnibarNativeFieldRegistry.shared.register(field, panelId: browserPanelId) defer { NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: browserPanelId) - BrowserOmnibarNativeFieldRegistry.shared.unregister(field, panelId: browserPanelId) field.removeFromSuperview() } @@ -5099,6 +5131,19 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } + let wasBrowserDisabled = BrowserAvailabilitySettings.isDisabled() + BrowserAvailabilitySettings.setDisabled(false) + defer { BrowserAvailabilitySettings.setDisabled(wasBrowserDisabled) } + + guard let context = appDelegate.contextForMainTerminalWindow(window), + let workspace = context.tabManager.selectedWorkspace, + let paneId = workspace.bonsplitController.allPaneIds.first, + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + XCTFail("Expected a browser panel for non-terminal shortcut routing") + return + } + workspace.focusPanel(browserPanel.id) + let cases: [(action: KeyboardShortcutSettings.Action, modifiers: NSEvent.ModifierFlags, key: String, keyCode: UInt16)] = [ ( .toggleRightSidebar, @@ -5118,15 +5163,12 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { "f", 3 ), - ( - .toggleUnread, - [.command, .option], - "u", - 32 - ), ] for testCase in cases { + workspace.focusPanel(browserPanel.id) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01)) + var observedActions: [KeyboardShortcutSettings.Action] = [] #if DEBUG KeyboardShortcutSettings.shortcutLookupObserver = { action in diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index c00288a42c7b..81e29c3e60f3 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -115,128 +115,6 @@ private final class BrowserMarkedTextProbeTextView: NSTextView { } } -final class BrowserAddressBarTrackingPolicyTests: XCTestCase { - func testNonPointerWebViewFocusPreservesTrackedAddressBarWithLiveOmnibarField() { - XCTAssertTrue( - shouldPreserveBrowserAddressBarTrackingDuringWebViewFocus( - BrowserAddressBarTrackingContext( - trackedPanelMatchesWebView: true, - omnibarResponderActive: false, - preferredFocusIntentIsAddressBar: true, - suppressesWebViewFocus: false, - pointerInitiatedWebFocus: false, - liveOmnibarFieldExists: true - ) - ) - ) - } - - func testPointerWebViewFocusCanClearTrackedAddressBar() { - XCTAssertFalse( - shouldPreserveBrowserAddressBarTrackingDuringWebViewFocus( - BrowserAddressBarTrackingContext( - trackedPanelMatchesWebView: true, - omnibarResponderActive: false, - preferredFocusIntentIsAddressBar: true, - suppressesWebViewFocus: true, - pointerInitiatedWebFocus: true, - liveOmnibarFieldExists: true - ) - ) - ) - } - - func testOtherPanelWebViewFocusDoesNotPreserveAddressBarTracking() { - XCTAssertFalse( - shouldPreserveBrowserAddressBarTrackingDuringWebViewFocus( - BrowserAddressBarTrackingContext( - trackedPanelMatchesWebView: false, - omnibarResponderActive: true, - preferredFocusIntentIsAddressBar: true, - suppressesWebViewFocus: true, - pointerInitiatedWebFocus: false, - liveOmnibarFieldExists: true - ) - ) - ) - } -} - -final class BrowserOmnibarNativeFieldRegistryTests: XCTestCase { - @MainActor - func testSpecificWindowLookupDoesNotReturnFieldFromAnotherWindow() { - let panelId = UUID() - let registry = BrowserOmnibarNativeFieldRegistry.shared - let firstWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 200), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - let secondWindow = NSWindow( - contentRect: NSRect(x: 20, y: 20, width: 320, height: 200), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - let firstContainer = NSView(frame: firstWindow.contentRect(forFrameRect: firstWindow.frame)) - let secondContainer = NSView(frame: secondWindow.contentRect(forFrameRect: secondWindow.frame)) - firstWindow.contentView = firstContainer - secondWindow.contentView = secondContainer - - let field = OmnibarNativeTextField(frame: NSRect(x: 0, y: 0, width: 120, height: 24)) - field.panelId = panelId - firstContainer.addSubview(field) - registry.register(field, panelId: panelId) - - defer { - registry.unregister(field, panelId: panelId) - field.removeFromSuperview() - firstWindow.contentView = nil - secondWindow.contentView = nil - firstWindow.orderOut(nil) - secondWindow.orderOut(nil) - } - - XCTAssertTrue(registry.field(for: panelId, in: firstWindow) === field) - XCTAssertNil(registry.field(for: panelId, in: secondWindow)) - } - - @MainActor - func testNilWindowLookupPrefersAttachedFieldBeforeDetachedField() { - let panelId = UUID() - let registry = BrowserOmnibarNativeFieldRegistry.shared - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 200), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) - window.contentView = container - - let attachedField = OmnibarNativeTextField(frame: NSRect(x: 0, y: 0, width: 120, height: 24)) - attachedField.panelId = panelId - container.addSubview(attachedField) - - let detachedField = OmnibarNativeTextField(frame: NSRect(x: 0, y: 0, width: 120, height: 24)) - detachedField.panelId = panelId - - registry.register(attachedField, panelId: panelId) - registry.register(detachedField, panelId: panelId) - - defer { - registry.unregister(attachedField, panelId: panelId) - registry.unregister(detachedField, panelId: panelId) - attachedField.removeFromSuperview() - window.contentView = nil - window.orderOut(nil) - } - - XCTAssertTrue(registry.field(for: panelId) === attachedField) - } -} - final class CmuxWebViewKeyEquivalentTests: XCTestCase { private final class ActionSpy: NSObject { private(set) var invoked: Bool = false @@ -3725,6 +3603,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, paneId: paneId, + usesProvidedPaneContext: false, shouldAttachWebView: true, useLocalInlineHosting: false, shouldFocusWebView: false, @@ -3768,6 +3647,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, paneId: paneId, + usesProvidedPaneContext: false, shouldAttachWebView: true, useLocalInlineHosting: false, shouldFocusWebView: false, @@ -3879,6 +3759,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, paneId: paneId, + usesProvidedPaneContext: false, shouldAttachWebView: false, useLocalInlineHosting: true, shouldFocusWebView: false, @@ -3973,6 +3854,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, paneId: paneId, + usesProvidedPaneContext: false, shouldAttachWebView: false, useLocalInlineHosting: true, shouldFocusWebView: false, @@ -4068,7 +3950,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { } -final class BrowserOmnibarKeyboardNavigationTests: XCTestCase { +final class BrowserOmnibarCommandOrControlNavigationTests: XCTestCase { func testArrowNavigationDeltaRequiresFocusedAddressBarAndNoModifierFlags() { XCTAssertNil( browserOmnibarSelectionDeltaForArrowNavigation( @@ -4121,41 +4003,59 @@ final class BrowserOmnibarKeyboardNavigationTests: XCTestCase { ) } - func testControlNavigationDeltaRequiresFocusedAddressBarAndControlOnly() { + func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOrControlOnly() { XCTAssertNil( - browserOmnibarSelectionDeltaForControlNavigation( + browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: false, - flags: [.control], + flags: [.command], chars: "n" ) ) - XCTAssertNil( - browserOmnibarSelectionDeltaForControlNavigation( + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: true, flags: [.command], chars: "n" - ) + ), + 1 ) - XCTAssertNil( - browserOmnibarSelectionDeltaForControlNavigation( + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: true, flags: [.command], chars: "p" - ) + ), + -1 ) XCTAssertNil( - browserOmnibarSelectionDeltaForControlNavigation( + browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: true, flags: [.command, .shift], chars: "n" ) ) + XCTAssertNil( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.command, .control], + chars: "n" + ) + ) + + XCTAssertNil( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.command, .control], + chars: "p" + ) + ) + XCTAssertEqual( - browserOmnibarSelectionDeltaForControlNavigation( + browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: true, flags: [.control], chars: "p" @@ -4164,7 +4064,7 @@ final class BrowserOmnibarKeyboardNavigationTests: XCTestCase { ) XCTAssertEqual( - browserOmnibarSelectionDeltaForControlNavigation( + browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: true, flags: [.control], chars: "n" @@ -4173,21 +4073,22 @@ final class BrowserOmnibarKeyboardNavigationTests: XCTestCase { ) } - func testControlNavigationDeltaIgnoresCapsLockModifier() { + func testCommandNavigationDeltaIgnoresCapsLockModifier() { XCTAssertEqual( - browserOmnibarSelectionDeltaForControlNavigation( + browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: true, flags: [.control, .capsLock], chars: "n" ), 1 ) - XCTAssertNil( - browserOmnibarSelectionDeltaForControlNavigation( + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: true, flags: [.command, .capsLock], chars: "p" - ) + ), + -1 ) } @@ -4236,9 +4137,11 @@ final class BrowserOmnibarKeyboardNavigationTests: XCTestCase { ) } - func testControlNavigationRepeatLifecycleRequiresControlOnly() { + func testCommandNavigationRepeatLifecycleRequiresCommandOrControlOnly() { XCTAssertTrue(browserOmnibarShouldContinueControlNavigationRepeat(flags: [.control])) XCTAssertTrue(browserOmnibarShouldContinueControlNavigationRepeat(flags: [.control, .capsLock])) + XCTAssertTrue(browserOmnibarShouldContinueControlNavigationRepeat(flags: [.command])) + XCTAssertTrue(browserOmnibarShouldContinueControlNavigationRepeat(flags: [.command, .capsLock])) XCTAssertFalse(browserOmnibarShouldContinueControlNavigationRepeat(flags: [.control, .command])) XCTAssertFalse(browserOmnibarShouldContinueControlNavigationRepeat(flags: [.control, .option])) XCTAssertFalse(browserOmnibarShouldContinueControlNavigationRepeat(flags: [.control, .shift])) @@ -4947,33 +4850,6 @@ final class BrowserExternalNavigationSchemeTests: XCTestCase { XCTAssertFalse(browserShouldOpenURLExternally(javascript)) XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal)) } - - func testCustomAppSchemesRouteExternallyFromSubframes() throws { - let vscode = try XCTUnwrap(URL(string: "vscode://file/Users/example/project/README.md")) - - XCTAssertTrue(browserShouldRouteExternalNavigation(vscode)) - XCTAssertEqual(browserExternalNavigationAction(for: vscode), .promptToOpenApp(vscode)) - } - - func testEmbeddedSubframeNavigationStaysInWebView() throws { - let https = try XCTUnwrap(URL(string: "https://example.com/iframe")) - - XCTAssertFalse(browserShouldRouteExternalNavigation(https)) - } - - func testIntentBrowserFallbackURLExtraction() throws { - let intent = try XCTUnwrap(URL(string: "intent://join/abc#Intent;scheme=zoommtg;package=us.zoom.videomeetings;S.browser_fallback_url=https%3A%2F%2Fzoom.us%2Fjoin%2Fabc;end")) - let fallback = try XCTUnwrap(URL(string: "https://zoom.us/join/abc")) - - XCTAssertEqual(browserIntentFallbackURL(for: intent), fallback) - XCTAssertEqual(browserExternalNavigationAction(for: intent), .browserFallback(fallback)) - } - - func testIntentBrowserFallbackURLRejectsExternalSchemes() throws { - let intent = try XCTUnwrap(URL(string: "intent://open#Intent;S.browser_fallback_url=slack%3A%2F%2Fopen;end")) - - XCTAssertNil(browserIntentFallbackURL(for: intent)) - } } diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index c60ebcc58908..13e561633591 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -21,29 +21,6 @@ private func drainBrowserPanelMainQueue() { XCTWaiter().wait(for: [expectation], timeout: 1.0) } -private final class BrowserPanelTestNavigationDelegate: NSObject, WKNavigationDelegate { - let expectation: XCTestExpectation - var error: Error? - - init(expectation: XCTestExpectation) { - self.expectation = expectation - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - expectation.fulfill() - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - self.error = error - expectation.fulfill() - } - - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - self.error = error - expectation.fulfill() - } -} - @MainActor private func makeTemporaryBrowserPanelProfile(named prefix: String) throws -> BrowserProfileDefinition { try XCTUnwrap( @@ -88,129 +65,6 @@ final class BrowserPanelChromeBackgroundColorTests: XCTestCase { } -@MainActor -final class BrowserPanelFileSystemAccessBridgeTests: XCTestCase { - func testShowOpenFilePickerIsInstalledInBrowserPages() async throws { - let panel = try await loadFilePickerTestPage() - - let result = try await panel.evaluateJavaScript("typeof window.showOpenFilePicker") - XCTAssertEqual(result as? String, "function") - } - - func testShowOpenFilePickerRejectsWhenWindowFocusReturnsWithoutCancelEvent() async throws { - let panel = try await loadFilePickerTestPage() - - let result = try await panel.webView.callAsyncJavaScript( - """ - const inputCount = () => document.querySelectorAll("input[type='file']").length; - const originalClick = HTMLInputElement.prototype.click; - HTMLInputElement.prototype.click = function() {}; - const pickerPromise = window.showOpenFilePicker(); - HTMLInputElement.prototype.click = originalClick; - - return await new Promise((resolve) => { - pickerPromise.then( - () => resolve({ status: "resolved", inputCount: inputCount() }), - (error) => resolve({ - status: "rejected", - name: error && error.name, - inputCount: inputCount(), - }) - ); - - window.dispatchEvent(new Event("focus")); - setTimeout(() => resolve({ status: "pending", inputCount: inputCount() }), 100); - }); - """, - arguments: [:], - in: nil, - contentWorld: .page - ) - let dictionary = try XCTUnwrap(result as? [String: Any]) - let inputCount = try XCTUnwrap(dictionary["inputCount"] as? NSNumber) - XCTAssertEqual(dictionary["status"] as? String, "rejected") - XCTAssertEqual(dictionary["name"] as? String, "AbortError") - XCTAssertEqual(inputCount.intValue, 0) - } - - func testShowOpenFilePickerDoesNotRejectOnElementFocus() async throws { - let panel = try await loadFilePickerTestPage() - - let result = try await panel.webView.callAsyncJavaScript( - """ - const inputCount = () => document.querySelectorAll("input[type='file']").length; - const originalClick = HTMLInputElement.prototype.click; - HTMLInputElement.prototype.click = function() {}; - const pickerPromise = window.showOpenFilePicker(); - HTMLInputElement.prototype.click = originalClick; - - let settled = false; - pickerPromise.finally(() => { settled = true; }).catch(() => {}); - - const textInput = document.createElement("input"); - textInput.type = "text"; - document.body.appendChild(textInput); - textInput.focus(); - - await new Promise((resolve) => setTimeout(resolve, 50)); - const beforeWindowFocus = { - settled, - inputCount: inputCount(), - }; - - window.dispatchEvent(new Event("focus")); - const afterWindowFocus = await new Promise((resolve) => { - pickerPromise.then( - () => resolve({ status: "resolved", inputCount: inputCount() }), - (error) => resolve({ - status: "rejected", - name: error && error.name, - inputCount: inputCount(), - }) - ); - }); - - return { beforeWindowFocus, afterWindowFocus }; - """, - arguments: [:], - in: nil, - contentWorld: .page - ) - let dictionary = try XCTUnwrap(result as? [String: Any]) - let beforeWindowFocus = try XCTUnwrap(dictionary["beforeWindowFocus"] as? [String: Any]) - let beforeInputCount = try XCTUnwrap(beforeWindowFocus["inputCount"] as? NSNumber) - XCTAssertEqual(beforeWindowFocus["settled"] as? Bool, false) - XCTAssertEqual(beforeInputCount.intValue, 1) - - let afterWindowFocus = try XCTUnwrap(dictionary["afterWindowFocus"] as? [String: Any]) - let afterInputCount = try XCTUnwrap(afterWindowFocus["inputCount"] as? NSNumber) - XCTAssertEqual(afterWindowFocus["status"] as? String, "rejected") - XCTAssertEqual(afterWindowFocus["name"] as? String, "AbortError") - XCTAssertEqual(afterInputCount.intValue, 0) - } - - private func loadFilePickerTestPage() async throws -> BrowserPanel { - let panel = BrowserPanel(workspaceId: UUID()) - let baseURL = try XCTUnwrap(URL(string: "https://example.test/file-picker")) - let loaded = expectation(description: "browser panel test page loaded") - let previousDelegate = panel.webView.navigationDelegate - let loadDelegate = BrowserPanelTestNavigationDelegate(expectation: loaded) - panel.webView.navigationDelegate = loadDelegate - defer { panel.webView.navigationDelegate = previousDelegate } - - panel.webView.loadHTMLString( - "browser panel test page", - baseURL: baseURL - ) - await fulfillment(of: [loaded], timeout: 5) - if let error = loadDelegate.error { - throw error - } - return panel - } -} - - @MainActor final class BrowserPanelInitialNavigationTests: XCTestCase { func testInitialURLCanBePreservedWithoutRenderingWebView() throws { diff --git a/cmuxTests/CommandPaletteNucleoFFILibrarySupport.swift b/cmuxTests/CommandPaletteNucleoFFILibrarySupport.swift deleted file mode 100644 index be390c353a41..000000000000 --- a/cmuxTests/CommandPaletteNucleoFFILibrarySupport.swift +++ /dev/null @@ -1,283 +0,0 @@ -import Darwin -import XCTest - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -struct FFICandidateSpan { - let titleOffset: Int - let titleLength: Int - let searchOffset: Int - let searchLength: Int - let rank: Int32 -} - -struct FFIMatch { - var index: Int - var score: Double - var rank: Int32 -} - -final class NucleoLibrary { - private static let supportedVersion: UInt32 = 2 - private static let libraryFileName = "libcmux_command_palette_nucleo_ffi.dylib" - - typealias CreateIndex = @convention(c) ( - UnsafePointer?, - Int, - UnsafeRawPointer?, - Int - ) -> OpaquePointer? - typealias DestroyIndex = @convention(c) (OpaquePointer?) -> Void - typealias SearchIndex = @convention(c) ( - OpaquePointer?, - UnsafePointer?, - Int, - Int, - UnsafeMutableRawPointer?, - Int, - UnsafeMutablePointer? - ) -> Int32 - typealias Version = @convention(c) () -> UInt32 - - let handle: UnsafeMutableRawPointer - let createIndex: CreateIndex - let destroyIndex: DestroyIndex - let searchIndex: SearchIndex - let version: Version - - init() throws { - let environment = ProcessInfo.processInfo.environment - let paths = Self.defaultLibraryPaths(environment: environment) - guard let path = paths.first(where: { FileManager.default.fileExists(atPath: $0) }) else { - throw XCTSkip("Build or bundle the nucleo FFI dylib before running comparison tests") - } - guard let handle = dlopen(path, RTLD_NOW | RTLD_LOCAL) else { - throw NSError( - domain: "CommandPaletteNucleoFFITests", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "dlopen failed: \(Self.dlerrorText())"] - ) - } - self.handle = handle - self.createIndex = try Self.symbol( - "cmux_nucleo_index_create", - from: handle, - as: CreateIndex.self - ) - self.destroyIndex = try Self.symbol( - "cmux_nucleo_index_destroy", - from: handle, - as: DestroyIndex.self - ) - self.searchIndex = try Self.symbol( - "cmux_nucleo_index_search", - from: handle, - as: SearchIndex.self - ) - self.version = try Self.symbol( - "cmux_nucleo_ffi_version", - from: handle, - as: Version.self - ) - let resolvedVersion = self.version() - guard resolvedVersion == Self.supportedVersion else { - dlclose(handle) - throw NSError( - domain: "CommandPaletteNucleoFFITests", - code: 6, - userInfo: [ - NSLocalizedDescriptionKey: "unsupported cmux_nucleo_ffi_version \(resolvedVersion)" - ] - ) - } - } - - private static func defaultLibraryPaths(environment: [String: String]) -> [String] { - var paths: [String] = [] - if let environmentPath = environment["CMUX_NUCLEO_FFI_LIB"], !environmentPath.isEmpty { - paths.append(environmentPath) - } - if let privateFrameworksPath = Bundle.main.privateFrameworksPath { - paths.append( - URL(fileURLWithPath: privateFrameworksPath) - .appendingPathComponent(libraryFileName) - .path - ) - } - - let sourceRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - let crateTarget = sourceRoot.appendingPathComponent("Native/CommandPaletteNucleoFFI/target") - paths.append( - crateTarget - .appendingPathComponent("cmux-nucleo-ffi") - .appendingPathComponent(libraryFileName) - .path - ) - paths.append( - crateTarget - .appendingPathComponent("release") - .appendingPathComponent(libraryFileName) - .path - ) -#if arch(arm64) - paths.append( - crateTarget - .appendingPathComponent("aarch64-apple-darwin/release") - .appendingPathComponent(libraryFileName) - .path - ) -#elseif arch(x86_64) - paths.append( - crateTarget - .appendingPathComponent("x86_64-apple-darwin/release") - .appendingPathComponent(libraryFileName) - .path - ) -#endif - return paths - } - - deinit { - dlclose(handle) - } - - private static func symbol(_ name: String, from handle: UnsafeMutableRawPointer, as _: T.Type) throws -> T { - guard let pointer = dlsym(handle, name) else { - throw NSError( - domain: "CommandPaletteNucleoFFITests", - code: 2, - userInfo: [NSLocalizedDescriptionKey: "dlsym(\(name)) failed: \(dlerrorText())"] - ) - } - return unsafeBitCast(pointer, to: T.self) - } - - private static func dlerrorText() -> String { - guard let error = dlerror() else { return "unknown error" } - return String(cString: error) - } -} - -final class NucleoIndex { - let library: NucleoLibrary - let pointer: OpaquePointer - let entries: [FixtureEntry] - - init(library: NucleoLibrary, entries: [FixtureEntry]) throws { - self.library = library - self.entries = entries - - var blob: [UInt8] = [] - var spans: [FFICandidateSpan] = [] - blob.reserveCapacity(entries.reduce(0) { total, entry in - total + entry.title.utf8.count + entry.searchableTexts.reduce(0) { $0 + $1.utf8.count + 1 } - }) - spans.reserveCapacity(entries.count) - - for entry in entries { - let titleOffset = blob.count - blob.append(contentsOf: entry.title.utf8) - let titleLength = blob.count - titleOffset - - let searchOffset = blob.count - blob.append(contentsOf: entry.searchableTexts.joined(separator: "\n").utf8) - let searchLength = blob.count - searchOffset - - spans.append( - FFICandidateSpan( - titleOffset: titleOffset, - titleLength: titleLength, - searchOffset: searchOffset, - searchLength: searchLength, - rank: Int32(entry.rank) - ) - ) - } - - guard let pointer = blob.withUnsafeBufferPointer({ blobBuffer in - spans.withUnsafeBufferPointer { spanBuffer in - library.createIndex( - blobBuffer.baseAddress, - blobBuffer.count, - UnsafeRawPointer(spanBuffer.baseAddress), - spanBuffer.count - ) - } - }) else { - throw NSError( - domain: "CommandPaletteNucleoFFITests", - code: 3, - userInfo: [NSLocalizedDescriptionKey: "cmux_nucleo_index_create returned null"] - ) - } - self.pointer = pointer - } - - deinit { - library.destroyIndex(pointer) - } - - func search(query: String, limit: Int) throws -> [NucleoResult] { - var matches = Array( - repeating: FFIMatch(index: 0, score: 0, rank: 0), - count: max(1, limit) - ) - var count = 0 - let queryBytes = Array(query.utf8) - let status = queryBytes.withUnsafeBufferPointer { queryBuffer in - matches.withUnsafeMutableBufferPointer { matchBuffer in - library.searchIndex( - pointer, - queryBuffer.baseAddress, - queryBuffer.count, - limit, - UnsafeMutableRawPointer(matchBuffer.baseAddress), - matchBuffer.count, - &count - ) - } - } - guard status == 0 else { - throw NSError( - domain: "CommandPaletteNucleoFFITests", - code: Int(status), - userInfo: [NSLocalizedDescriptionKey: "cmux_nucleo_index_search failed with \(status)"] - ) - } - - guard count >= 0, count <= matches.count, count <= limit else { - throw NSError( - domain: "CommandPaletteNucleoFFITests", - code: 4, - userInfo: [ - NSLocalizedDescriptionKey: "cmux_nucleo_index_search returned invalid count \(count) for limit \(limit)" - ] - ) - } - - return try matches.prefix(count).map { match in - guard entries.indices.contains(match.index) else { - throw NSError( - domain: "CommandPaletteNucleoFFITests", - code: 5, - userInfo: [ - NSLocalizedDescriptionKey: "cmux_nucleo_index_search returned invalid index \(match.index)" - ] - ) - } - let entry = entries[match.index] - return NucleoResult( - id: entry.id, - rank: Int(match.rank), - title: entry.title, - score: match.score - ) - } - } -} diff --git a/cmuxTests/CommandPaletteNucleoFFITests.swift b/cmuxTests/CommandPaletteNucleoFFITests.swift deleted file mode 100644 index d643d52cbb2c..000000000000 --- a/cmuxTests/CommandPaletteNucleoFFITests.swift +++ /dev/null @@ -1,544 +0,0 @@ -import XCTest - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -final class CommandPaletteNucleoFFITests: XCTestCase { - func testNucleoFFIPrefersOpenFolderForOpenFolderQuery() throws { - let library = try NucleoLibrary() - XCTAssertEqual(library.version(), 2) - let entries = makeOpenFolderEntries() - let index = try NucleoIndex(library: library, entries: entries) - - let resultIDs = try index.search(query: "open folder", limit: 4).map(\.id) - - XCTAssertEqual( - Array(resultIDs.prefix(2)), - ["palette.openFolder", "palette.openFolderInVSCodeInline"] - ) - } - - func testNucleoFFIMatchesMultiTokenQueriesAcrossFieldsWithoutOrderDependency() throws { - let library = try NucleoLibrary() - let entries = makeOpenFolderEntries() - let index = try NucleoIndex(library: library, entries: entries) - - let resultIDs = try index.search(query: "directory open", limit: 4).map(\.id) - - XCTAssertEqual( - Array(resultIDs.prefix(2)), - ["palette.openFolder", "palette.openFolderInVSCodeInline"] - ) - } - - func testNucleoFFIPrefersTitleInitialismOverCompactInWordMatch() throws { - let library = try NucleoLibrary() - let entries = makeInitialismWorkspaceEntries() - let index = try NucleoIndex(library: library, entries: entries) - - let resultIDs = try index.search(query: "ims", limit: 5).map(\.id) - - XCTAssertEqual(resultIDs.first, "workspace.indigoMarkdownStudio") - } - - func testNucleoFFIPrefersExactAliasOverTitleInitialism() throws { - let library = try NucleoLibrary() - let entries = [ - FixtureEntry( - id: "palette.openWorkspacePRLinks", - rank: 0, - title: "Open All Workspace PR Links", - searchableTexts: [ - "Open All Workspace PR Links", - "Workspace", - "pull", - "request", - "review", - "merge", - "pr", - "mr", - "open", - "links", - "workspace", - ] - ), - FixtureEntry( - id: "palette.markWorkspaceRead", - rank: 1, - title: "Mark Workspace as Read", - searchableTexts: ["Mark Workspace as Read", "Workspace", "workspace", "read", "notification"] - ), - ] - let index = try NucleoIndex(library: library, entries: entries) - - let resultIDs = try index.search(query: "mr", limit: 5).map(\.id) - - XCTAssertEqual(resultIDs.first, "palette.openWorkspacePRLinks") - } - - func testNucleoFFIPrefersTitleMatchOverLongExactKeyword() throws { - let library = try NucleoLibrary() - let entries = [ - FixtureEntry( - id: "palette.checkForUpdates", - rank: 0, - title: "Check for Updates", - searchableTexts: ["Check for Updates", "Global", "update", "upgrade", "release"] - ), - FixtureEntry( - id: "palette.attemptUpdate", - rank: 1, - title: "Attempt Update", - searchableTexts: ["Attempt Update", "Global", "attempt", "check", "update", "upgrade", "release"] - ), - ] - let index = try NucleoIndex(library: library, entries: entries) - - let resultIDs = try index.search(query: "check", limit: 5).map(\.id) - - XCTAssertEqual(resultIDs.first, "palette.checkForUpdates") - } - - func testNucleoFFIDoesNotMatchSingleTokenAcrossSearchFields() throws { - let library = try NucleoLibrary() - let entries = [ - FixtureEntry( - id: "palette.crossFieldOnly", - rank: 0, - title: "Other Command", - searchableTexts: ["Other Command", "foo", "bar"] - ), - FixtureEntry( - id: "palette.frameRate", - rank: 1, - title: "Frame Rate", - searchableTexts: ["Frame Rate", "Display", "frame", "rate"] - ), - ] - let index = try NucleoIndex(library: library, entries: entries) - - let resultIDs = try index.search(query: "fr", limit: 5).map(\.id) - - XCTAssertEqual(resultIDs.first, "palette.frameRate") - XCTAssertFalse(resultIDs.contains("palette.crossFieldOnly")) - } - - func testNucleoFFIMatchesAsciiQueryAgainstDiacriticTitle() throws { - let library = try NucleoLibrary() - let entries = [ - FixtureEntry( - id: "workspace.cafe", - rank: 0, - title: "Café Notes", - searchableTexts: ["Café Notes", "Workspace"] - ), - FixtureEntry( - id: "workspace.cargo", - rank: 1, - title: "Cargo Notes", - searchableTexts: ["Cargo Notes", "Workspace"] - ), - ] - let index = try NucleoIndex(library: library, entries: entries) - - let resultIDs = try index.search(query: "cafe", limit: 5).map(\.id) - - XCTAssertEqual(resultIDs.first, "workspace.cafe") - } - - func testNucleoFFIHandlesEmptyQuery() throws { - let library = try NucleoLibrary() - let entries = makeOpenFolderEntries() - let index = try NucleoIndex(library: library, entries: entries) - - let resultIDs = try index.search(query: "", limit: 3).map(\.id) - - XCTAssertEqual(resultIDs, ["palette.newWorkspace", "palette.newWindow", "palette.openFolder"]) - } - - func testNucleoFFIFindsDeepWorkspaceMatch() throws { - let library = try NucleoLibrary() - let entries = makeLargeWorkspaceSwitcherEntries(count: 5_000) - let index = try NucleoIndex(library: library, entries: entries) - - let results = try index.search(query: "workspace 4913", limit: 10) - - XCTAssertEqual(results.first?.id, "workspace.large.4913") - XCTAssertLessThanOrEqual(results.count, 10) - } - - func testProductionNucleoSearchIndexFindsCommandPaletteCommands() throws { - let entries = makeOpenFolderEntries() - let corpus = searchCorpus(entries: entries) - guard let index = CommandPaletteNucleoSearchIndex(entries: corpus) else { - throw XCTSkip("Build the nucleo FFI dylib before running production wrapper tests") - } - - let resultIDs = index.search( - query: "open folder", - resultLimit: 4, - historyBoost: { _, _ in 0 } - )?.map(\.payload) - - XCTAssertEqual( - Array((resultIDs ?? []).prefix(2)), - ["palette.openFolder", "palette.openFolderInVSCodeInline"] - ) - } - - func testAppBundleContainsNucleoSearchLibrary() throws { - let privateFrameworksPath = try XCTUnwrap(Bundle.main.privateFrameworksPath) - let libraryPath = URL(fileURLWithPath: privateFrameworksPath) - .appendingPathComponent("libcmux_command_palette_nucleo_ffi.dylib") - .path - let libraryExists = FileManager.default.fileExists(atPath: libraryPath) - - if !libraryExists, - !Self.cargoIsAvailable(), - !Self.requiresCargoForFFIBuild() { - throw XCTSkip("Cargo is unavailable, so the optional nucleo FFI bundle build was skipped") - } - - XCTAssertTrue( - libraryExists, - "Expected bundled nucleo search library at \(libraryPath)" - ) - } - - private static func cargoIsAvailable() -> Bool { - let path = ProcessInfo.processInfo.environment["PATH"] ?? "" - return path.split(separator: ":").contains { directory in - let candidate = URL(fileURLWithPath: String(directory)) - .appendingPathComponent("cargo") - .path - return FileManager.default.isExecutableFile(atPath: candidate) - } - } - - private static func requiresCargoForFFIBuild() -> Bool { - let environment = ProcessInfo.processInfo.environment - if environmentFlagIsEnabled(environment["CI"]) { - return true - } - return environmentFlagIsEnabled(environment["CMUX_NUCLEO_FFI_REQUIRE_CARGO"]) - } - - private static func environmentFlagIsEnabled(_ value: String?) -> Bool { - switch value { - case "1", "true", "TRUE", "yes", "YES": - return true - default: - return false - } - } - - func testProductionNucleoSearchIndexAppliesHistoryBoostBeforeLimiting() throws { - let entries = makeOpenFolderEntries() - let corpus = searchCorpus(entries: entries) - guard let index = CommandPaletteNucleoSearchIndex(entries: corpus) else { - throw XCTSkip("Build the nucleo FFI dylib before running production wrapper tests") - } - - let results = index.search( - query: "", - resultLimit: 1, - historyBoost: { commandID, _ in commandID == "palette.openFolder" ? 600 : 0 } - ) - - XCTAssertEqual(results?.map(\.payload), ["palette.openFolder"]) - } - - func testNucleoFFILargeWorkspacePerformanceAndCorrectnessComparison() throws { - let library = try NucleoLibrary() - let entries = makeLargeWorkspaceSwitcherEntries(count: 800) - let corpus = searchCorpus(entries: entries) - let queries = repeatedQueries( - [ - "workspace 799", - "palette latency", - "feature 401", - "cmd-p-search", - "project-642", - "4207", - "9204", - "Window 3", - ], - repetitions: 3 - ) - - var index: NucleoIndex? - let buildMs = benchmarkElapsedMs { - index = try? NucleoIndex(library: library, entries: entries) - } - let nucleoIndex = try XCTUnwrap(index) - - for query in queries.prefix(8) { - _ = optimizedResults(corpus: corpus, query: query, resultLimit: 100) - _ = try nucleoIndex.search(query: query, limit: 100) - } - - let optimizedMs = benchmarkElapsedMs { - for query in queries { - _ = optimizedResults(corpus: corpus, query: query, resultLimit: 100) - } - } - let nucleoMs = benchmarkElapsedMs { - for query in queries { - _ = try? nucleoIndex.search(query: query, limit: 100) - } - } - - let comparison = try correctnessComparison( - corpus: corpus, - queries: Array(Set(queries)).sorted(), - index: nucleoIndex, - resultLimit: 20 - ) - print(String( - format: "BENCH cmd+p nucleo-ffi large-workspaces build=%.2fms swiftOptimized=%.2fms nucleo=%.2fms top1Agreement=%d/%d meanTop10Overlap=%.2f", - buildMs, - optimizedMs, - nucleoMs, - comparison.top1Agreement, - comparison.queryCount, - comparison.meanTop10Overlap - )) - if !comparison.top1Mismatches.isEmpty { - print("CHECK cmd+p nucleo-ffi top1Mismatches \(comparison.top1Mismatches.joined(separator: "; "))") - } - - XCTAssertGreaterThan(comparison.queryCount, 0) - XCTAssertGreaterThan(nucleoMs, 0) - } - - func testNucleoFFIFastTypingFrameBudgetComparison() throws { - let library = try NucleoLibrary() - let entries = makeLargeWorkspaceSwitcherEntries(count: 800) - let previewEntries = Array(entries.prefix(128)) - let corpus = searchCorpus(entries: entries) - let previewCorpus = searchCorpus(entries: previewEntries) - let fullIndex = try NucleoIndex(library: library, entries: entries) - let previewIndex = try NucleoIndex(library: library, entries: previewEntries) - let queries = repeatedQueries( - fastTypingPrefixes("cmd-p-search") + fastTypingPrefixes("palette latency"), - repetitions: 2 - ) - - for query in queries.prefix(8) { - _ = optimizedResults(corpus: corpus, query: query, resultLimit: 100) - _ = optimizedResults(corpus: previewCorpus, query: query, resultLimit: 48) - _ = try fullIndex.search(query: query, limit: 100) - _ = try previewIndex.search(query: query, limit: 48) - } - - var swiftFullDurationsMs: [Double] = [] - var swiftPreviewDurationsMs: [Double] = [] - var nucleoFullDurationsMs: [Double] = [] - var nucleoPreviewDurationsMs: [Double] = [] - swiftFullDurationsMs.reserveCapacity(queries.count) - swiftPreviewDurationsMs.reserveCapacity(queries.count) - nucleoFullDurationsMs.reserveCapacity(queries.count) - nucleoPreviewDurationsMs.reserveCapacity(queries.count) - - for query in queries { - swiftFullDurationsMs.append( - benchmarkElapsedMs { - _ = optimizedResults(corpus: corpus, query: query, resultLimit: 100) - } - ) - swiftPreviewDurationsMs.append( - benchmarkElapsedMs { - _ = optimizedResults(corpus: previewCorpus, query: query, resultLimit: 48) - } - ) - nucleoFullDurationsMs.append( - benchmarkElapsedMs { - _ = try? fullIndex.search(query: query, limit: 100) - } - ) - nucleoPreviewDurationsMs.append( - benchmarkElapsedMs { - _ = try? previewIndex.search(query: query, limit: 48) - } - ) - } - - print(String( - format: "BENCH cmd+p nucleo-ffi fast-typing swiftFull=%.2fms swiftPreview=%.2fms nucleoFull=%.2fms nucleoPreview=%.2fms maxSwiftFull=%.2fms maxSwiftPreview=%.2fms maxNucleoFull=%.2fms maxNucleoPreview=%.2fms swiftFullDroppedFrames=%d swiftPreviewDroppedFrames=%d nucleoFullDroppedFrames=%d nucleoPreviewDroppedFrames=%d", - swiftFullDurationsMs.reduce(0, +), - swiftPreviewDurationsMs.reduce(0, +), - nucleoFullDurationsMs.reduce(0, +), - nucleoPreviewDurationsMs.reduce(0, +), - swiftFullDurationsMs.max() ?? 0, - swiftPreviewDurationsMs.max() ?? 0, - nucleoFullDurationsMs.max() ?? 0, - nucleoPreviewDurationsMs.max() ?? 0, - estimatedDroppedFrames(for: swiftFullDurationsMs), - estimatedDroppedFrames(for: swiftPreviewDurationsMs), - estimatedDroppedFrames(for: nucleoFullDurationsMs), - estimatedDroppedFrames(for: nucleoPreviewDurationsMs) - )) - - XCTAssertLessThanOrEqual( - estimatedDroppedFrames(for: nucleoPreviewDurationsMs), - estimatedDroppedFrames(for: nucleoFullDurationsMs) - ) - } - - func testNucleoFFIEdgeCaseTypingFrameBudgetComparison() throws { - let entries = makeEdgeCasePaletteEntries(generatedWorkspaceCount: 2_000) - let corpus = searchCorpus(entries: entries) - var index: CommandPaletteNucleoSearchIndex? - let buildMs = benchmarkElapsedMs { - index = CommandPaletteNucleoSearchIndex(entries: corpus) - } - guard let productionIndex = index else { - throw XCTSkip("Build the nucleo FFI dylib before running production wrapper tests") - } - let queries = repeatedQueries(edgeCaseTypingQueries(), repetitions: 2) - - for query in queries.prefix(16) { - _ = optimizedResults(corpus: corpus, query: query, resultLimit: 100) - _ = productionIndex.search(query: query, resultLimit: 100) - } - - var swiftDurationsMs: [Double] = [] - var nucleoDurationsMs: [Double] = [] - var boostedNucleoDurationsMs: [Double] = [] - swiftDurationsMs.reserveCapacity(queries.count) - nucleoDurationsMs.reserveCapacity(queries.count) - boostedNucleoDurationsMs.reserveCapacity(queries.count) - - for query in queries { - swiftDurationsMs.append( - benchmarkElapsedMs { - _ = optimizedResults(corpus: corpus, query: query, resultLimit: 100) - } - ) - nucleoDurationsMs.append( - benchmarkElapsedMs { - _ = productionIndex.search(query: query, resultLimit: 100) - } - ) - boostedNucleoDurationsMs.append( - benchmarkElapsedMs { - _ = productionIndex.search( - query: query, - resultLimit: 100, - historyBoost: { commandID, queryIsEmpty in - if commandID == "palette.markWorkspaceUnread" { - return queryIsEmpty ? 300 : 120 - } - return 0 - } - ) - } - ) - } - - let expectedTopResults = [ - ("ims", "workspace.indigoMarkdownStudio"), - ("wunr", "palette.markWorkspaceUnread"), - ("open folder", "palette.openFolder"), - ("workspace 1901", "workspace.large.1901"), - ("cafe", "workspace.cafeUnicodeNotes"), - ] - for (query, expectedID) in expectedTopResults { - XCTAssertEqual( - productionIndex.search(query: query, resultLimit: 10)?.first?.payload, - expectedID, - "Unexpected top result for \(query)" - ) - } - - print(String( - format: "BENCH cmd+p nucleo-ffi edge-typing entries=%d queries=%d build=%.2fms swift=%.2fms nucleo=%.2fms boostedNucleo=%.2fms maxSwift=%.2fms p95Swift=%.2fms maxNucleo=%.2fms p95Nucleo=%.2fms maxBoostedNucleo=%.2fms p95BoostedNucleo=%.2fms swiftDroppedFrames=%d nucleoDroppedFrames=%d boostedNucleoDroppedFrames=%d", - entries.count, - queries.count, - buildMs, - swiftDurationsMs.reduce(0, +), - nucleoDurationsMs.reduce(0, +), - boostedNucleoDurationsMs.reduce(0, +), - swiftDurationsMs.max() ?? 0, - percentile(swiftDurationsMs, percentile: 0.95), - nucleoDurationsMs.max() ?? 0, - percentile(nucleoDurationsMs, percentile: 0.95), - boostedNucleoDurationsMs.max() ?? 0, - percentile(boostedNucleoDurationsMs, percentile: 0.95), - estimatedDroppedFrames(for: swiftDurationsMs), - estimatedDroppedFrames(for: nucleoDurationsMs), - estimatedDroppedFrames(for: boostedNucleoDurationsMs) - )) - - XCTAssertLessThanOrEqual( - estimatedDroppedFrames(for: nucleoDurationsMs), - estimatedDroppedFrames(for: swiftDurationsMs) - ) - } - - func testNucleoFFICallOverheadBenchmark() throws { - let library = try NucleoLibrary() - let entries = makeLargeWorkspaceSwitcherEntries(count: 800) - let corpus = searchCorpus(entries: entries) - let rawIndex = try NucleoIndex(library: library, entries: entries) - let productionIndex = try XCTUnwrap(CommandPaletteNucleoSearchIndex(entries: corpus)) - let noopIterations = 50_000 - let searchIterations = 200 - let queryBytes = Array("cmd-p-search".utf8) - var ffiNoopFailures = 0 - - var outCount = 0 - let ffiNoopMs = benchmarkElapsedMs { - for _ in 0.. [FixtureEntry] { - [ - FixtureEntry( - id: "palette.newWorkspace", - rank: 0, - title: "New Workspace", - searchableTexts: ["New Workspace", "Workspace", "create", "new", "workspace"] - ), - FixtureEntry( - id: "palette.newWindow", - rank: 1, - title: "New Window", - searchableTexts: ["New Window", "Window", "create", "new", "window"] - ), - FixtureEntry( - id: "palette.openFolder", - rank: 2, - title: "Open Folder...", - searchableTexts: ["Open Folder...", "Workspace", "open", "folder", "repository", "project", "directory"] - ), - FixtureEntry( - id: "palette.openFolderInVSCodeInline", - rank: 3, - title: "Open Folder in VS Code (Inline)...", - searchableTexts: [ - "Open Folder in VS Code (Inline)...", - "VS Code Inline", - "open", - "folder", - "directory", - "project", - "vs", - "code", - "inline", - "editor", - "browser", - ] - ), - ] -} - -func makeInitialismWorkspaceEntries() -> [FixtureEntry] { - [ - FixtureEntry( - id: "workspace.yarrowImageSorter", - rank: 0, - title: "Yarrow Image Sorter", - searchableTexts: ["Yarrow Image Sorter", "Workspace", "workspace", "switch", "go"] - ), - FixtureEntry( - id: "workspace.indigoMarkdownStudio", - rank: 1, - title: "Indigo Markdown Studio", - searchableTexts: ["Indigo Markdown Studio", "Workspace", "workspace", "switch", "go"] - ), - FixtureEntry( - id: "workspace.ivoryMeetingNotes", - rank: 2, - title: "Ivory Meeting Notes", - searchableTexts: ["Ivory Meeting Notes", "Workspace", "workspace", "switch", "go"] - ), - FixtureEntry( - id: "workspace.graniteMusicVault", - rank: 3, - title: "Granite Music Vault", - searchableTexts: ["Granite Music Vault", "Workspace", "workspace", "switch", "go"] - ), - FixtureEntry( - id: "workspace.nimbusInvoiceDesk", - rank: 4, - title: "Nimbus Invoice Desk", - searchableTexts: ["Nimbus Invoice Desk", "Workspace", "workspace", "switch", "go"] - ), - ] -} - -func makeEdgeCasePaletteEntries(generatedWorkspaceCount: Int) -> [FixtureEntry] { - var entries = makeOpenFolderEntries() - entries.append( - FixtureEntry( - id: "palette.markWorkspaceUnread", - rank: entries.count, - title: "Mark Workspace as Unread", - searchableTexts: [ - "Mark Workspace as Unread", - "Workspace", - "mark", - "unread", - "notification", - ] - ) - ) - entries.append(contentsOf: makeInitialismWorkspaceEntries().map { entry in - FixtureEntry( - id: entry.id, - rank: entries.count + entry.rank, - title: entry.title, - searchableTexts: entry.searchableTexts - ) - }) - entries.append( - FixtureEntry( - id: "workspace.cafeUnicodeNotes", - rank: entries.count, - title: "Café Unicode Notes", - searchableTexts: [ - "Café Unicode Notes", - "Cafe Unicode Notes", - "cafe", - "unicode", - "workspace", - ] - ) - ) - - let generatedRankOffset = entries.count - entries.append(contentsOf: makeLargeWorkspaceSwitcherEntries(count: generatedWorkspaceCount).map { entry in - FixtureEntry( - id: entry.id, - rank: generatedRankOffset + entry.rank, - title: entry.title, - searchableTexts: entry.searchableTexts - ) - }) - return entries -} - -func makeLargeWorkspaceSwitcherEntries(count: Int) -> [FixtureEntry] { - (0.. [CommandPaletteSearchCorpusEntry] { - entries.map { entry in - CommandPaletteSearchCorpusEntry( - payload: entry.id, - rank: entry.rank, - title: entry.title, - searchableTexts: entry.searchableTexts - ) - } -} - -func optimizedResults( - corpus: [CommandPaletteSearchCorpusEntry], - query: String, - resultLimit: Int -) -> [FixtureResult] { - CommandPaletteSearchEngine.search( - entries: corpus, - query: query, - resultLimit: resultLimit - ) { _, _ in 0 } - .map { - FixtureResult( - id: $0.payload, - rank: $0.rank, - title: $0.title, - score: $0.score - ) - } -} - -func optimizedResults( - entries: [FixtureEntry], - query: String, - resultLimit: Int -) -> [FixtureResult] { - optimizedResults(corpus: searchCorpus(entries: entries), query: query, resultLimit: resultLimit) -} - -func correctnessComparison( - corpus: [CommandPaletteSearchCorpusEntry], - queries: [String], - index: NucleoIndex, - resultLimit: Int -) throws -> ( - queryCount: Int, - top1Agreement: Int, - meanTop10Overlap: Double, - top1Mismatches: [String] -) { - var top1Agreement = 0 - var totalTop10Overlap = 0.0 - var top1Mismatches: [String] = [] - - for query in queries { - let swiftIDs = optimizedResults(corpus: corpus, query: query, resultLimit: resultLimit) - .map(\.id) - let nucleoIDs = try index.search(query: query, limit: resultLimit).map(\.id) - if swiftIDs.first == nucleoIDs.first { - top1Agreement += 1 - } else { - let swiftTop = swiftIDs.first ?? "" - let nucleoTop = nucleoIDs.first ?? "" - top1Mismatches.append("\(query): swift=\(swiftTop) nucleo=\(nucleoTop)") - } - - let swiftTop10 = Set(swiftIDs.prefix(10)) - let nucleoTop10 = Set(nucleoIDs.prefix(10)) - if !swiftTop10.isEmpty || !nucleoTop10.isEmpty { - totalTop10Overlap += Double(swiftTop10.intersection(nucleoTop10).count) / 10.0 - } - } - - return ( - queryCount: queries.count, - top1Agreement: top1Agreement, - meanTop10Overlap: queries.isEmpty ? 0 : totalTop10Overlap / Double(queries.count), - top1Mismatches: top1Mismatches - ) -} - -func fastTypingPrefixes(_ text: String) -> [String] { - text.indices.map { index in - String(text[...index]) - } -} - -func edgeCaseTypingQueries() -> [String] { - var queries: [String] = [] - for text in [ - "ims", - "wunr", - "open folder", - "workspace 1901", - "feature/palette-latency-177", - "project-1999", - "cmd-p-search", - "cafe unicode", - "zzzzzzzz", - ] { - queries.append(contentsOf: fastTypingPrefixes(text)) - } - queries.append(contentsOf: [ - "", - " ", - " OPEN FOLDER ", - "Window 3", - "3007", - "4207", - "9207", - "task/cmd-p-search-7", - "feature palette latency", - "project 42 cmd p", - "workspace/branch:177", - "café", - "Cafe", - "no-match-query", - ]) - return queries -} - -func estimatedDroppedFrames( - for queryDurationsMs: [Double], - frameBudgetMs: Double = 1000.0 / 60.0 -) -> Int { - queryDurationsMs.reduce(0) { total, durationMs in - total + max(0, Int(ceil(durationMs / frameBudgetMs)) - 1) - } -} - -func benchmarkElapsedMs(operation: () -> Void) -> Double { - let start = DispatchTime.now().uptimeNanoseconds - operation() - let elapsed = DispatchTime.now().uptimeNanoseconds - start - return Double(elapsed) / 1_000_000 -} - -func percentile(_ values: [Double], percentile: Double) -> Double { - guard !values.isEmpty else { return 0 } - let sorted = values.sorted() - let clampedPercentile = min(1, max(0, percentile)) - let index = Int((Double(sorted.count - 1) * clampedPercentile).rounded()) - return sorted[index] -} - -func repeatedQueries(_ baseQueries: [String], repetitions: Int) -> [String] { - Array(repeating: baseQueries, count: repetitions).flatMap { $0 } -} diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index c752b15413b7..5a29824f0694 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -93,47 +93,6 @@ final class CommandPaletteSearchEngineTests: XCTestCase { } } - private func makeLargeWorkspaceSwitcherEntries(count: Int) -> [FixtureEntry] { - (0.. [FixtureEntry] { [ FixtureEntry( @@ -182,8 +141,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { private func optimizedResults( entries: [FixtureEntry], - query: String, - resultLimit: Int? = nil + query: String ) -> [FixtureResult] { let corpus = entries.map { entry in CommandPaletteSearchCorpusEntry( @@ -194,7 +152,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) } - return CommandPaletteSearchEngine.search(entries: corpus, query: query, resultLimit: resultLimit) { _, _ in 0 } + return CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } .map { FixtureResult( id: $0.payload, @@ -241,21 +199,6 @@ final class CommandPaletteSearchEngineTests: XCTestCase { } } - private func fastTypingPrefixes(_ text: String) -> [String] { - text.indices.map { index in - String(text[...index]) - } - } - - private func estimatedDroppedFrames( - for queryDurationsMs: [Double], - frameBudgetMs: Double = 1000.0 / 60.0 - ) -> Int { - queryDurationsMs.reduce(0) { total, durationMs in - total + max(0, Int(ceil(durationMs / frameBudgetMs)) - 1) - } - } - private func weightedReferenceScore( query: String, entry: FixtureEntry @@ -315,129 +258,6 @@ final class CommandPaletteSearchEngineTests: XCTestCase { } } - func testMultiTokenSearchCanMatchAcrossTitleAndKeywordFields() { - let entries = [ - FixtureEntry( - id: "workspace.projectA", - rank: 0, - title: "Project A", - searchableTexts: ["Project A", "Workspace"] - ), - FixtureEntry( - id: "workspace.notes", - rank: 1, - title: "Notes", - searchableTexts: ["Notes", "Workspace"] - ), - ] - - XCTAssertEqual( - optimizedResults(entries: entries, query: "project workspace").first?.id, - "workspace.projectA" - ) - } - - func testLimitedSearchReturnsSameTopResultsAsFullSearch() { - let entries = makeLargeWorkspaceSwitcherEntries(count: 800) - let queries = [ - "workspace 799", - "palette latency", - "feature 401", - "cmd-p-search", - "project-642", - "Window 3", - ] - - for query in queries { - let fullResults = optimizedResults(entries: entries, query: query) - let limitedResults = optimizedResults(entries: entries, query: query, resultLimit: 48) - - XCTAssertEqual( - limitedResults, - Array(fullResults.prefix(48)), - "Limited search should preserve full-search ordering and highlight output for query \(query)" - ) - } - } - - func testLimitedSearchStillFindsDeepWorkspaceMatch() { - let entries = makeLargeWorkspaceSwitcherEntries(count: 5_000) - - let results = optimizedResults( - entries: entries, - query: "workspace 4913", - resultLimit: 10 - ) - - XCTAssertEqual(results.first?.id, "workspace.large.4913") - XCTAssertLessThanOrEqual(results.count, 10) - } - - func testLimitedSearchReturnsOnlyRequestedResultCountForBroadWorkspaceQuery() { - let entries = makeLargeWorkspaceSwitcherEntries(count: 1_200) - - let results = optimizedResults( - entries: entries, - query: "workspace", - resultLimit: 100 - ) - - XCTAssertEqual(results.count, 100) - XCTAssertEqual( - results, - Array(optimizedResults(entries: entries, query: "workspace").prefix(100)) - ) - } - - func testResolvedSearchMatchesReturnFullFinalResultSetWhenUnbounded() { - let entries = makeLargeWorkspaceSwitcherEntries(count: 150) - let corpus = entries.map { entry in - CommandPaletteSearchCorpusEntry( - payload: entry.id, - rank: entry.rank, - title: entry.title, - searchableTexts: entry.searchableTexts - ) - } - - let matches = CommandPaletteSearchOrchestrator.resolvedSearchMatches( - searchIndex: nil, - searchCorpus: corpus, - query: "workspace", - usageHistory: [:], - queryIsEmpty: false, - historyTimestamp: 0 - ) - - XCTAssertEqual(matches.count, entries.count) - } - - func testNucleoResolvedSearchMatchesReturnFullFinalResultSetWhenUnbounded() throws { - let entries = makeLargeWorkspaceSwitcherEntries(count: 150) - let corpus = entries.map { entry in - CommandPaletteSearchCorpusEntry( - payload: entry.id, - rank: entry.rank, - title: entry.title, - searchableTexts: entry.searchableTexts - ) - } - guard let searchIndex = CommandPaletteNucleoSearchIndex(entries: corpus) else { - throw XCTSkip("Build the nucleo FFI dylib before running production wrapper tests") - } - - let matches = CommandPaletteSearchOrchestrator.resolvedSearchMatches( - searchIndex: searchIndex, - searchCorpus: corpus, - query: "workspace", - usageHistory: [:], - queryIsEmpty: false, - historyTimestamp: 0 - ) - - XCTAssertEqual(matches.count, entries.count) - } - func testSearchCancellationReturnsNoResults() { let entries = makeCommandEntries(count: 512) let corpus = entries.map { entry in @@ -464,535 +284,6 @@ final class CommandPaletteSearchEngineTests: XCTestCase { XCTAssertGreaterThanOrEqual(cancellationChecks, 4) } - func testExactForkQueryPinsForkRightBeforeOtherForkCommands() { - let entries = [ - FixtureEntry( - id: "palette.forkAgentConversationLeft", - rank: 0, - title: "Fork Conversation to the Left", - searchableTexts: ["Fork Conversation to the Left", "Terminal", "fork", "left"] - ), - FixtureEntry( - id: "palette.forkAgentConversationRight", - rank: 4, - title: "Fork Conversation to the Right", - searchableTexts: ["Fork Conversation to the Right", "Terminal", "fork", "right"] - ), - FixtureEntry( - id: "palette.forkAgentConversationNewWorkspace", - rank: 1, - title: "Fork Conversation to New Workspace", - searchableTexts: ["Fork Conversation to New Workspace", "Workspace", "fork", "new", "workspace"] - ), - ] - let corpus = entries.map { entry in - CommandPaletteSearchCorpusEntry( - payload: entry.id, - rank: entry.rank, - title: entry.title, - searchableTexts: entry.searchableTexts - ) - } - - let results = CommandPaletteSearchEngine.search( - entries: corpus, - query: "fork" - ) { commandId, _ in - ContentView.commandPaletteForkPriorityBoost(commandId: commandId, query: "fork") - } - - XCTAssertEqual(results.map(\.payload).first, "palette.forkAgentConversationRight") - } - - func testForkableAgentCacheKeepsPanelVisibleWithoutFallbackSnapshot() { - let workspaceId = UUID() - let panelId = UUID() - let supportedKey = ContentView.commandPaletteForkableAgentPanelKey( - workspaceId: workspaceId, - panelId: panelId - ) - - XCTAssertFalse( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: panelId, - supportedPanelKeys: [], - fallbackSnapshot: nil - ) - ) - XCTAssertTrue( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: panelId, - supportedPanelKeys: [supportedKey], - fallbackSnapshot: nil - ) - ) - XCTAssertFalse( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: UUID(), - supportedPanelKeys: [supportedKey], - fallbackSnapshot: nil - ) - ) - XCTAssertFalse( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: UUID(), - panelId: panelId, - supportedPanelKeys: [supportedKey], - fallbackSnapshot: nil - ) - ) - } - - func testForkableAgentCacheRequiresMatchingRemoteContextWithoutFallbackSnapshot() { - let workspaceId = UUID() - let panelId = UUID() - let supportedKey = ContentView.commandPaletteForkableAgentPanelKey( - workspaceId: workspaceId, - panelId: panelId - ) - - XCTAssertTrue( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: panelId, - supportedPanelKeys: [supportedKey], - supportedRemoteContextsByPanelKey: [supportedKey: false], - fallbackSnapshot: nil, - isRemoteTerminal: false - ) - ) - XCTAssertFalse( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: panelId, - supportedPanelKeys: [supportedKey], - supportedRemoteContextsByPanelKey: [supportedKey: false], - fallbackSnapshot: nil, - isRemoteTerminal: true - ) - ) - XCTAssertTrue( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: panelId, - supportedPanelKeys: [supportedKey], - supportedRemoteContextsByPanelKey: [supportedKey: true], - fallbackSnapshot: nil, - isRemoteTerminal: true - ) - ) - XCTAssertFalse( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: panelId, - supportedPanelKeys: [supportedKey], - supportedRemoteContextsByPanelKey: [supportedKey: true], - fallbackSnapshot: nil, - isRemoteTerminal: false - ) - ) - } - - func testForkPostProbeContextRejectsFocusOrRemoteContextChanges() { - let workspaceId = UUID() - let panelId = UUID() - - XCTAssertTrue( - ContentView.commandPaletteForkPostProbeContextStillMatches( - expectedWorkspaceId: workspaceId, - expectedPanelId: panelId, - expectedIsRemoteContext: false, - currentWorkspaceId: workspaceId, - currentPanelId: panelId, - currentPanelIsTerminal: true, - currentIsRemoteContext: false - ) - ) - XCTAssertFalse( - ContentView.commandPaletteForkPostProbeContextStillMatches( - expectedWorkspaceId: workspaceId, - expectedPanelId: panelId, - expectedIsRemoteContext: false, - currentWorkspaceId: workspaceId, - currentPanelId: UUID(), - currentPanelIsTerminal: true, - currentIsRemoteContext: false - ) - ) - XCTAssertFalse( - ContentView.commandPaletteForkPostProbeContextStillMatches( - expectedWorkspaceId: workspaceId, - expectedPanelId: panelId, - expectedIsRemoteContext: false, - currentWorkspaceId: UUID(), - currentPanelId: panelId, - currentPanelIsTerminal: true, - currentIsRemoteContext: false - ) - ) - XCTAssertFalse( - ContentView.commandPaletteForkPostProbeContextStillMatches( - expectedWorkspaceId: workspaceId, - expectedPanelId: panelId, - expectedIsRemoteContext: false, - currentWorkspaceId: workspaceId, - currentPanelId: panelId, - currentPanelIsTerminal: false, - currentIsRemoteContext: false - ) - ) - XCTAssertFalse( - ContentView.commandPaletteForkPostProbeContextStillMatches( - expectedWorkspaceId: workspaceId, - expectedPanelId: panelId, - expectedIsRemoteContext: false, - currentWorkspaceId: workspaceId, - currentPanelId: panelId, - currentPanelIsTerminal: true, - currentIsRemoteContext: true - ) - ) - } - - func testForkableAgentFallbackSnapshotUsesSynchronousSupportOnly() { - let workspaceId = UUID() - let panelId = UUID() - let codex = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "codex-session", - workingDirectory: nil, - launchCommand: nil - ) - let directOpenCode = SessionRestorableAgentSnapshot( - kind: .opencode, - sessionId: "opencode-session", - workingDirectory: "/tmp/opencode repo", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "opencode", - executablePath: "/opt/homebrew/bin/opencode", - arguments: ["/opt/homebrew/bin/opencode"], - workingDirectory: "/tmp/opencode repo", - environment: nil, - capturedAt: 123, - source: "environment" - ) - ) - let omoOpenCode = SessionRestorableAgentSnapshot( - kind: .opencode, - sessionId: "omo-session", - workingDirectory: "/tmp/opencode repo", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "omo", - executablePath: "/usr/local/bin/cmux", - arguments: ["/usr/local/bin/cmux", "omo"], - workingDirectory: "/tmp/opencode repo", - environment: nil, - capturedAt: 123, - source: "environment" - ) - ) - - XCTAssertTrue( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: panelId, - supportedPanelKeys: [], - fallbackSnapshot: codex - ) - ) - XCTAssertFalse( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: panelId, - supportedPanelKeys: [], - fallbackSnapshot: directOpenCode - ) - ) - XCTAssertTrue( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: panelId, - supportedPanelKeys: [], - fallbackSnapshot: directOpenCode, - isRemoteTerminal: true - ) - ) - XCTAssertTrue( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: panelId, - supportedPanelKeys: [], - fallbackSnapshot: omoOpenCode - ) - ) - } - - func testForkableAgentRemoteFallbackRejectsCommandsThatRequireLocalLauncherScript() { - let workspaceId = UUID() - let panelId = UUID() - let supportedKey = ContentView.commandPaletteForkableAgentPanelKey( - workspaceId: workspaceId, - panelId: panelId - ) - let longPath = "/Users/cmux/" + String(repeating: "nested-project-", count: 120) - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: "/Users/cmux/project", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: [ - "/Users/example/.bun/bin/codex", - "--model", - "gpt-5.4", - "--add-dir", - longPath - ], - workingDirectory: "/Users/cmux/project", - environment: nil, - capturedAt: 123, - source: "process" - ) - ) - - XCTAssertNotNil(snapshot.forkStartupInput(allowLauncherScript: true)) - XCTAssertNil(snapshot.forkStartupInput(allowLauncherScript: false)) - XCTAssertTrue( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: panelId, - supportedPanelKeys: [], - fallbackSnapshot: snapshot - ) - ) - XCTAssertFalse( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: panelId, - supportedPanelKeys: [], - fallbackSnapshot: snapshot, - isRemoteTerminal: true - ) - ) - XCTAssertFalse( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: panelId, - supportedPanelKeys: [supportedKey], - fallbackSnapshot: snapshot, - isRemoteTerminal: true - ) - ) - } - - func testForkableAgentCacheDoesNotOverrideUnsupportedCurrentSnapshot() { - let workspaceId = UUID() - let panelId = UUID() - let supportedKey = ContentView.commandPaletteForkableAgentPanelKey( - workspaceId: workspaceId, - panelId: panelId - ) - let unsupported = SessionRestorableAgentSnapshot( - kind: .custom("unsupported-agent"), - sessionId: "unsupported-session", - workingDirectory: nil, - launchCommand: nil - ) - - XCTAssertFalse( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: panelId, - supportedPanelKeys: [supportedKey], - fallbackSnapshot: unsupported - ) - ) - } - - func testForkExecutionUsesCachedSnapshotAfterProcessSnapshotDisappears() { - let cached = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "stale-codex-session", - workingDirectory: "/tmp/stale repo", - launchCommand: nil - ) - let fallback = SessionRestorableAgentSnapshot( - kind: .claude, - sessionId: "fallback-claude-session", - workingDirectory: "/tmp/fallback repo", - launchCommand: nil - ) - let live = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "live-codex-session", - workingDirectory: "/tmp/live repo", - launchCommand: nil - ) - - XCTAssertEqual( - ContentView.commandPaletteForkExecutionSnapshot( - indexSnapshot: nil, - fallbackSnapshot: nil, - cachedSnapshot: cached - )?.sessionId, - cached.sessionId - ) - XCTAssertEqual( - ContentView.commandPaletteForkExecutionSnapshot( - indexSnapshot: nil, - fallbackSnapshot: fallback, - cachedSnapshot: cached - )?.sessionId, - fallback.sessionId - ) - XCTAssertEqual( - ContentView.commandPaletteForkExecutionSnapshot( - indexSnapshot: live, - fallbackSnapshot: fallback, - cachedSnapshot: cached - )?.sessionId, - live.sessionId - ) - } - - func testForkableAgentCacheKeepsVerifiedOpenCodeVisible() { - let workspaceId = UUID() - let panelId = UUID() - let supportedKey = ContentView.commandPaletteForkableAgentPanelKey( - workspaceId: workspaceId, - panelId: panelId - ) - let directOpenCode = SessionRestorableAgentSnapshot( - kind: .opencode, - sessionId: "opencode-session", - workingDirectory: "/tmp/opencode repo", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "opencode", - executablePath: "/opt/homebrew/bin/opencode", - arguments: ["/opt/homebrew/bin/opencode"], - workingDirectory: "/tmp/opencode repo", - environment: nil, - capturedAt: 123, - source: "environment" - ) - ) - - XCTAssertTrue( - ContentView.commandPalettePanelHasForkableAgent( - workspaceId: workspaceId, - panelId: panelId, - supportedPanelKeys: [supportedKey], - fallbackSnapshot: directOpenCode - ) - ) - } - - func testForkableAgentSnapshotFingerprintChangesWithSession() { - let first = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "first-session", - workingDirectory: "/tmp/repo", - launchCommand: nil - ) - let second = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "second-session", - workingDirectory: "/tmp/repo", - launchCommand: nil - ) - - XCTAssertNotEqual( - ContentView.commandPaletteForkSnapshotFingerprint(first), - ContentView.commandPaletteForkSnapshotFingerprint(second) - ) - } - - func testForkableAgentSnapshotFingerprintChangesWithForkCommand() { - let launchCommand = AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/usr/local/bin/codex", - arguments: ["/usr/local/bin/codex"], - workingDirectory: "/tmp/repo", - environment: nil, - capturedAt: 123, - source: "process" - ) - let first = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "codex-session", - workingDirectory: "/tmp/repo", - launchCommand: launchCommand - ) - var second = first - second.registration = CmuxVaultAgentRegistration( - id: "fork-fingerprint", - name: "Fork Fingerprint", - detect: CmuxVaultAgentDetectRule(processName: "fork-fingerprint"), - sessionIdSource: .argvOption("--session"), - resumeCommand: "{{executable}} resume {{sessionId}}", - cwd: .ignore - ) - - XCTAssertNotEqual(first.forkCommand, second.forkCommand) - XCTAssertNotEqual( - ContentView.commandPaletteForkSnapshotFingerprint(first), - ContentView.commandPaletteForkSnapshotFingerprint(second) - ) - } - - func testForkableAgentCacheFingerprintUsesFallbackFingerprintAfterProbe() { - let fallback = SessionRestorableAgentSnapshot( - kind: .opencode, - sessionId: "opencode-session", - workingDirectory: "/tmp/opencode repo", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "opencode", - executablePath: "/opt/homebrew/bin/opencode", - arguments: ["/opt/homebrew/bin/opencode"], - workingDirectory: "/tmp/opencode repo", - environment: nil, - capturedAt: 123, - source: "environment" - ) - ) - let processDetected = SessionRestorableAgentSnapshot( - kind: .opencode, - sessionId: "opencode-session", - workingDirectory: "/tmp/opencode repo", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "opencode", - executablePath: "/opt/homebrew/bin/opencode", - arguments: ["/opt/homebrew/bin/opencode"], - workingDirectory: "/tmp/opencode repo", - environment: nil, - capturedAt: nil, - source: "process" - ) - ) - let fallbackFingerprint = ContentView.commandPaletteForkSnapshotFingerprint(fallback) - let processFingerprint = ContentView.commandPaletteForkSnapshotFingerprint(processDetected) - - XCTAssertNotEqual(fallbackFingerprint, processFingerprint) - XCTAssertEqual( - ContentView.commandPaletteForkCacheFingerprint( - snapshot: processDetected, - fallbackFingerprint: fallbackFingerprint - ), - fallbackFingerprint - ) - XCTAssertEqual( - ContentView.commandPaletteForkCacheFingerprint( - snapshot: processDetected, - fallbackFingerprint: nil - ), - processFingerprint - ) - } - func testCommandPreviewSearchUsesFullCommandCorpus() { let entries = [ FixtureEntry( @@ -1017,11 +308,9 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) } let corpusByID = Dictionary(uniqueKeysWithValues: corpus.map { ($0.payload, $0) }) - let searchIndex = CommandPaletteNucleoSearchIndex(entries: corpus) - let previewCommandIDs = CommandPaletteSearchOrchestrator.commandPreviewMatchCommandIDsForTests( + let previewCommandIDs = ContentView.commandPaletteCommandPreviewMatchCommandIDsForTests( searchCorpus: corpus, - searchIndex: searchIndex, candidateCommandIDs: ["command.find"], searchCorpusByID: corpusByID, query: "finde", @@ -1031,310 +320,6 @@ final class CommandPaletteSearchEngineTests: XCTestCase { XCTAssertEqual(previewCommandIDs.first, "command.finder") } - func testNucleoEmptyResultsFallBackToSwiftSingleEditMatching() throws { - let entries = [ - FixtureEntry( - id: "palette.renameTab", - rank: 0, - title: "Rename Tab...", - searchableTexts: ["Rename Tab...", "rename", "tab", "title"] - ), - FixtureEntry( - id: "palette.openFolder", - rank: 1, - title: "Open Folder...", - searchableTexts: ["Open Folder...", "open", "folder", "directory"] - ), - ] - let corpus = entries.map { entry in - CommandPaletteSearchCorpusEntry( - payload: entry.id, - rank: entry.rank, - title: entry.title, - searchableTexts: entry.searchableTexts - ) - } - guard let searchIndex = CommandPaletteNucleoSearchIndex(entries: corpus) else { - throw XCTSkip("Build the nucleo FFI dylib before running production wrapper tests") - } - - let matches = CommandPaletteSearchOrchestrator.resolvedSearchMatches( - searchIndex: searchIndex, - searchCorpus: corpus, - query: "renamd", - usageHistory: [:], - queryIsEmpty: CommandPaletteFuzzyMatcher.preparedQuery("renamd").isEmpty, - historyTimestamp: 0, - resultLimit: 10 - ) - - XCTAssertEqual(matches.first?.commandID, "palette.renameTab") - } - - func testNucleoPartialResultsIncludeSwiftSingleEditFallback() throws { - let entries = [ - FixtureEntry( - id: "palette.reactNativeMarkdown", - rank: 0, - title: "React Native Markdown", - searchableTexts: ["React Native Markdown", "react", "native", "markdown"] - ), - FixtureEntry( - id: "palette.renameTab", - rank: 1, - title: "Rename Tab...", - searchableTexts: ["Rename Tab...", "rename", "tab", "title"] - ), - FixtureEntry( - id: "palette.openFolder", - rank: 2, - title: "Open Folder...", - searchableTexts: ["Open Folder...", "open", "folder", "directory"] - ), - ] - let corpus = entries.map { entry in - CommandPaletteSearchCorpusEntry( - payload: entry.id, - rank: entry.rank, - title: entry.title, - searchableTexts: entry.searchableTexts - ) - } - guard let searchIndex = CommandPaletteNucleoSearchIndex(entries: corpus) else { - throw XCTSkip("Build the nucleo FFI dylib before running production wrapper tests") - } - let nucleoOnlyMatches = try XCTUnwrap( - searchIndex.search(query: "renamd", resultLimit: 10) - ) - XCTAssertFalse(nucleoOnlyMatches.isEmpty) - - let matches = CommandPaletteSearchOrchestrator.resolvedSearchMatches( - searchIndex: searchIndex, - searchCorpus: corpus, - query: "renamd", - usageHistory: [:], - queryIsEmpty: CommandPaletteFuzzyMatcher.preparedQuery("renamd").isEmpty, - historyTimestamp: 0, - resultLimit: 10 - ) - - XCTAssertEqual(matches.first?.commandID, "palette.renameTab") - } - - func testNucleoFullPageResultsIncludeSwiftSingleEditFallback() throws { - var entries = (0..<150).map { index in - FixtureEntry( - id: "palette.reactNativeMarkdown.\(index)", - rank: index, - title: "React Native Markdown \(index)", - searchableTexts: ["React Native Markdown \(index)", "react", "native", "markdown"] - ) - } - entries.append( - FixtureEntry( - id: "palette.renameTab", - rank: 200, - title: "Rename Tab...", - searchableTexts: ["Rename Tab...", "rename", "tab", "title"] - ) - ) - let corpus = entries.map { entry in - CommandPaletteSearchCorpusEntry( - payload: entry.id, - rank: entry.rank, - title: entry.title, - searchableTexts: entry.searchableTexts - ) - } - guard let searchIndex = CommandPaletteNucleoSearchIndex(entries: corpus) else { - throw XCTSkip("Build the nucleo FFI dylib before running production wrapper tests") - } - let nucleoOnlyMatches = try XCTUnwrap( - searchIndex.search(query: "renamd", resultLimit: 10) - ) - XCTAssertEqual(nucleoOnlyMatches.count, 10) - XCTAssertNotEqual(nucleoOnlyMatches.first?.payload, "palette.renameTab") - - let matches = CommandPaletteSearchOrchestrator.resolvedSearchMatches( - searchIndex: searchIndex, - searchCorpus: corpus, - query: "renamd", - usageHistory: [:], - queryIsEmpty: CommandPaletteFuzzyMatcher.preparedQuery("renamd").isEmpty, - historyTimestamp: 0, - resultLimit: 10 - ) - - XCTAssertEqual(matches.first?.commandID, "palette.renameTab") - } - - func testSwiftFallbackMergeKeepsCombinedResultsSortedByScore() { - let entries = [ - FixtureEntry( - id: "palette.high", - rank: 0, - title: "High Score", - searchableTexts: ["High Score"] - ), - FixtureEntry( - id: "palette.medium", - rank: 1, - title: "Medium Score", - searchableTexts: ["Medium Score"] - ), - FixtureEntry( - id: "palette.fallback", - rank: 2, - title: "Fallback Score", - searchableTexts: ["Fallback Score"] - ), - ] - let corpus = entries.map { entry in - CommandPaletteSearchCorpusEntry( - payload: entry.id, - rank: entry.rank, - title: entry.title, - searchableTexts: entry.searchableTexts - ) - } - let corpusByID = Dictionary(uniqueKeysWithValues: corpus.map { ($0.payload, $0) }) - - let matches = CommandPaletteSearchOrchestrator.mergedSwiftFallbackMatchesForTests( - [ - CommandPaletteResolvedSearchMatch( - commandID: "palette.fallback", - score: 25, - titleMatchIndices: [] - ) - ], - nucleoMatches: [ - CommandPaletteResolvedSearchMatch( - commandID: "palette.medium", - score: 80, - titleMatchIndices: [] - ), - CommandPaletteResolvedSearchMatch( - commandID: "palette.high", - score: 100, - titleMatchIndices: [] - ), - ], - searchCorpusByID: corpusByID, - limit: 3 - ) - - XCTAssertEqual(matches.map(\.commandID), ["palette.high", "palette.medium", "palette.fallback"]) - } - - func testFirstValueDictionaryPreservesFirstDuplicateKey() { - let values = [ - (id: "palette.duplicate", title: "First"), - (id: "palette.unique", title: "Unique"), - (id: "palette.duplicate", title: "Second"), - ] - - let valuesByID = CommandPaletteSearchOrchestrator.firstValueDictionary(values) { $0.id } - - XCTAssertEqual(valuesByID["palette.duplicate"]?.title, "First") - XCTAssertEqual(valuesByID["palette.unique"]?.title, "Unique") - XCTAssertEqual(valuesByID.count, 2) - } - - func testNucleoExactPartialResultsDoNotRunSwiftSingleEditFallback() throws { - let entries = [ - FixtureEntry( - id: "workspace.project642", - rank: 0, - title: "Project 642 Command Palette", - searchableTexts: ["Project 642 Command Palette", "Workspace", "project-642", "cmd-p-search"] - ), - FixtureEntry( - id: "workspace.project641", - rank: 1, - title: "Project 641 Markdown Preview", - searchableTexts: ["Project 641 Markdown Preview", "Workspace", "project-641", "markdown-preview"] - ), - ] - let corpus = entries.map { entry in - CommandPaletteSearchCorpusEntry( - payload: entry.id, - rank: entry.rank, - title: entry.title, - searchableTexts: entry.searchableTexts - ) - } - guard let searchIndex = CommandPaletteNucleoSearchIndex(entries: corpus) else { - throw XCTSkip("Build the nucleo FFI dylib before running production wrapper tests") - } - let nucleoOnlyMatches = try XCTUnwrap( - searchIndex.search(query: "project-642", resultLimit: 10) - ) - XCTAssertLessThan(nucleoOnlyMatches.count, 10) - - var cancellationChecks = 0 - let matches = CommandPaletteSearchOrchestrator.resolvedSearchMatches( - searchIndex: searchIndex, - searchCorpus: corpus, - query: "project-642", - usageHistory: [:], - queryIsEmpty: CommandPaletteFuzzyMatcher.preparedQuery("project-642").isEmpty, - historyTimestamp: 0, - resultLimit: 10 - ) { - cancellationChecks += 1 - return false - } - - XCTAssertEqual(matches.first?.commandID, "workspace.project642") - XCTAssertEqual(cancellationChecks, 2) - } - - func testCommandSearchPrefersOpenFolderForOpenFolderQuery() { - let entries = [ - FixtureEntry( - id: "palette.newWorkspace", - rank: 0, - title: "New Workspace", - searchableTexts: ["New Workspace", "Workspace", "create", "new", "workspace"] - ), - FixtureEntry( - id: "palette.newWindow", - rank: 1, - title: "New Window", - searchableTexts: ["New Window", "Window", "create", "new", "window"] - ), - FixtureEntry( - id: "palette.openFolder", - rank: 2, - title: "Open Folder...", - searchableTexts: ["Open Folder...", "Workspace", "open", "folder", "repository", "project", "directory"] - ), - FixtureEntry( - id: "palette.openFolderInVSCodeInline", - rank: 3, - title: "Open Folder in VS Code (Inline)...", - searchableTexts: [ - "Open Folder in VS Code (Inline)...", - "VS Code Inline", - "open", - "folder", - "directory", - "project", - "vs", - "code", - "inline", - "editor", - "browser", - ] - ), - ] - - XCTAssertEqual( - optimizedResults(entries: entries, query: "open folder").prefix(2).map(\.id), - ["palette.openFolder", "palette.openFolderInVSCodeInline"] - ) - } - func testSearchMatchesSingleOmittedCharacterInCommandWordPrefix() { let entries = makeFinderCommandEntries() @@ -1453,52 +438,6 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) } - func testPendingActivationRebasesWhenIndexReadyRefreshRestartsSearch() { - XCTAssertEqual( - ContentView.commandPalettePendingActivation( - .selected(requestID: 41, fallbackSelectedIndex: 2, preferredCommandID: "command.2"), - rebasedTo: 42 - ), - .selected(requestID: 42, fallbackSelectedIndex: 2, preferredCommandID: "command.2") - ) - XCTAssertEqual( - ContentView.commandPalettePendingActivation( - .command(requestID: 41, commandID: "command.1"), - rebasedTo: 42 - ), - .command(requestID: 42, commandID: "command.1") - ) - XCTAssertNil(ContentView.commandPalettePendingActivation(nil, rebasedTo: 42)) - } - - func testPendingActivationResolutionClearsAndResolvesRebasedSynchronousSearch() { - let resultIDs = ["command.0", "command.1", "command.2"] - let rebasedActivation = ContentView.commandPalettePendingActivation( - .selected(requestID: 41, fallbackSelectedIndex: 0, preferredCommandID: "command.2"), - rebasedTo: 42 - ) - - let resolution = ContentView.commandPalettePendingActivationResolution( - rebasedActivation, - requestID: 42, - resultIDs: resultIDs - ) - - XCTAssertEqual(resolution.resolvedActivation, .selected(index: 2)) - XCTAssertTrue(resolution.shouldClearPendingActivation) - } - - func testPendingActivationResolutionKeepsStaleActivation() { - let resolution = ContentView.commandPalettePendingActivationResolution( - .command(requestID: 41, commandID: "command.1"), - requestID: 42, - resultIDs: ["command.1"] - ) - - XCTAssertNil(resolution.resolvedActivation) - XCTAssertFalse(resolution.shouldClearPendingActivation) - } - func testSelectionAnchorTracksVisiblePendingSelection() { let resultIDs = ["command.0", "command.1", "command.2"] let visibleAnchor = ContentView.commandPaletteSelectionAnchorCommandID( @@ -1523,7 +462,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { func testPreviewCandidateCommandIDsAreBounded() { let resultIDs = (0..<500).map { "command.\($0)" } - let previewCandidateIDs = CommandPaletteSearchOrchestrator.previewCandidateCommandIDs( + let previewCandidateIDs = ContentView.commandPalettePreviewCandidateCommandIDs( resultIDs: resultIDs, limit: 192 ) @@ -1533,40 +472,22 @@ final class CommandPaletteSearchEngineTests: XCTestCase { XCTAssertEqual(previewCandidateIDs.last, "command.191") } - func testSynchronousSeedRunsOnlyWhenScopeHasNoVisibleResultsAndSearchIndexIsReady() { + func testSynchronousSeedAlwaysRunsForRefreshes() { XCTAssertTrue( - CommandPaletteSearchOrchestrator.shouldSynchronouslySeedResults( - hasVisibleResultsForScope: false, - hasSearchIndex: true, - corpusCount: 5_000 + ContentView.commandPaletteShouldSynchronouslySeedResults( + hasVisibleResultsForScope: false ) ) XCTAssertTrue( - CommandPaletteSearchOrchestrator.shouldSynchronouslySeedResults( - hasVisibleResultsForScope: false, - hasSearchIndex: false, - corpusCount: 256 - ) - ) - XCTAssertFalse( - CommandPaletteSearchOrchestrator.shouldSynchronouslySeedResults( - hasVisibleResultsForScope: false, - hasSearchIndex: false, - corpusCount: 257 - ) - ) - XCTAssertFalse( - CommandPaletteSearchOrchestrator.shouldSynchronouslySeedResults( - hasVisibleResultsForScope: true, - hasSearchIndex: true, - corpusCount: 5_000 + ContentView.commandPaletteShouldSynchronouslySeedResults( + hasVisibleResultsForScope: true ) ) } func testPendingEmptyStateIsNotPreservedWhenSearchIsNotPending() { XCTAssertFalse( - CommandPaletteSearchOrchestrator.shouldPreserveEmptyStateWhileSearchPending( + ContentView.commandPaletteShouldPreserveEmptyStateWhileSearchPending( isSearchPending: false, visibleResultsScopeMatches: true, resolvedSearchScopeMatches: true, @@ -1578,7 +499,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { func testPendingEmptyStateIsPreservedForSameResolvedNoMatchQuery() { XCTAssertTrue( - CommandPaletteSearchOrchestrator.shouldPreserveEmptyStateWhileSearchPending( + ContentView.commandPaletteShouldPreserveEmptyStateWhileSearchPending( isSearchPending: true, visibleResultsScopeMatches: true, resolvedSearchScopeMatches: true, @@ -1590,7 +511,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { func testPendingEmptyStateIsPreservedForSameScopeNoMatchInPlaceEdit() { XCTAssertTrue( - CommandPaletteSearchOrchestrator.shouldPreserveEmptyStateWhileSearchPending( + ContentView.commandPaletteShouldPreserveEmptyStateWhileSearchPending( isSearchPending: true, visibleResultsScopeMatches: true, resolvedSearchScopeMatches: true, @@ -1602,7 +523,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { func testPendingEmptyStateIsNotPreservedWhenResolvedResultsMayBeStale() { XCTAssertFalse( - CommandPaletteSearchOrchestrator.shouldPreserveEmptyStateWhileSearchPending( + ContentView.commandPaletteShouldPreserveEmptyStateWhileSearchPending( isSearchPending: true, visibleResultsScopeMatches: false, resolvedSearchScopeMatches: true, @@ -1611,7 +532,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) ) XCTAssertFalse( - CommandPaletteSearchOrchestrator.shouldPreserveEmptyStateWhileSearchPending( + ContentView.commandPaletteShouldPreserveEmptyStateWhileSearchPending( isSearchPending: true, visibleResultsScopeMatches: true, resolvedSearchScopeMatches: false, @@ -1620,7 +541,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) ) XCTAssertFalse( - CommandPaletteSearchOrchestrator.shouldPreserveEmptyStateWhileSearchPending( + ContentView.commandPaletteShouldPreserveEmptyStateWhileSearchPending( isSearchPending: true, visibleResultsScopeMatches: true, resolvedSearchScopeMatches: true, @@ -1629,7 +550,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) ) XCTAssertFalse( - CommandPaletteSearchOrchestrator.shouldPreserveEmptyStateWhileSearchPending( + ContentView.commandPaletteShouldPreserveEmptyStateWhileSearchPending( isSearchPending: true, visibleResultsScopeMatches: true, resolvedSearchScopeMatches: true, @@ -1755,12 +676,12 @@ final class CommandPaletteSearchEngineTests: XCTestCase { let workspaceID = UUID() let base = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: "Window 2", selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Alpha", metadata: CommandPaletteSwitcherSearchMetadata( @@ -1776,12 +697,12 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) let changedMetadata = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: "Window 2", selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Alpha", metadata: CommandPaletteSwitcherSearchMetadata( @@ -1797,12 +718,12 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) let changedDisplayName = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: "Window 2", selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Beta", metadata: CommandPaletteSwitcherSearchMetadata( @@ -1828,17 +749,17 @@ final class CommandPaletteSearchEngineTests: XCTestCase { let base = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: nil, selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Alpha", metadata: CommandPaletteSwitcherSearchMetadata(), surfaces: [ - ContentView.CommandPaletteSwitcherFingerprintSurface( + CommandPaletteSwitcherFingerprintSurface( id: surfaceID, displayName: "Terminal", kindLabel: "Terminal", @@ -1856,17 +777,17 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) let changedSurfaceMetadata = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: nil, selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Alpha", metadata: CommandPaletteSwitcherSearchMetadata(), surfaces: [ - ContentView.CommandPaletteSwitcherFingerprintSurface( + CommandPaletteSwitcherFingerprintSurface( id: surfaceID, displayName: "Terminal", kindLabel: "Terminal", @@ -1884,17 +805,17 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) let changedSurfaceKind = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: nil, selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Alpha", metadata: CommandPaletteSwitcherSearchMetadata(), surfaces: [ - ContentView.CommandPaletteSwitcherFingerprintSurface( + CommandPaletteSwitcherFingerprintSurface( id: surfaceID, displayName: "Terminal", kindLabel: "Browser", @@ -1992,147 +913,4 @@ final class CommandPaletteSearchEngineTests: XCTestCase { "Optimized switcher search regressed significantly: reference=\(referenceMs) optimized=\(optimizedMs)" ) } - - func testLargeWorkspaceSwitcherSearchBenchmarkAvoidsPerQueryPreparationCost() { - let entries = makeLargeWorkspaceSwitcherEntries(count: 800) - let corpus = entries.map { entry in - CommandPaletteSearchCorpusEntry( - payload: entry.id, - rank: entry.rank, - title: entry.title, - searchableTexts: entry.searchableTexts - ) - } - let queries = repeatedQueries( - [ - "workspace 799", - "palette latency", - "feature 401", - "cmd-p-search", - "project-642", - "4207", - "9204", - "Window 3", - ], - repetitions: 3 - ) - - for query in queries.prefix(8) { - _ = referenceResults(entries: entries, query: query) - _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } - } - - let referenceMs = benchmarkElapsedMs { - for query in queries { - _ = referenceResults(entries: entries, query: query) - } - } - let optimizedMs = benchmarkElapsedMs { - for query in queries { - _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } - } - } - - print(String(format: "BENCH cmd+p large-workspaces reference=%.2fms optimized=%.2fms", referenceMs, optimizedMs)) - XCTAssertLessThan( - optimizedMs, - referenceMs * 0.80, - "Large switcher search should reuse prepared corpus data: reference=\(referenceMs) optimized=\(optimizedMs)" - ) - } - - func testFastTypingPreviewSearchBenchmarkReportsEstimatedDroppedFrames() { - let entries = makeLargeWorkspaceSwitcherEntries(count: 800) - let corpus = entries.map { entry in - CommandPaletteSearchCorpusEntry( - payload: entry.id, - rank: entry.rank, - title: entry.title, - searchableTexts: entry.searchableTexts - ) - } - let visibleCandidateCorpus = Array(corpus.prefix(128)) - let queries = repeatedQueries( - fastTypingPrefixes("cmd-p-search") + fastTypingPrefixes("palette latency"), - repetitions: 2 - ) - - for query in queries.prefix(8) { - _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } - _ = CommandPaletteSearchEngine.search(entries: corpus, query: query, resultLimit: 100) { _, _ in 0 } - _ = CommandPaletteSearchEngine.search(entries: visibleCandidateCorpus, query: query, resultLimit: 48) { _, _ in 0 } - } - - var fullDurationsMs: [Double] = [] - var cappedFullDurationsMs: [Double] = [] - var previewDurationsMs: [Double] = [] - fullDurationsMs.reserveCapacity(queries.count) - cappedFullDurationsMs.reserveCapacity(queries.count) - previewDurationsMs.reserveCapacity(queries.count) - - for query in queries { - fullDurationsMs.append( - benchmarkElapsedMs { - _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } - } - ) - cappedFullDurationsMs.append( - benchmarkElapsedMs { - _ = CommandPaletteSearchEngine.search(entries: corpus, query: query, resultLimit: 100) { _, _ in 0 } - } - ) - previewDurationsMs.append( - benchmarkElapsedMs { - _ = CommandPaletteSearchEngine.search(entries: visibleCandidateCorpus, query: query, resultLimit: 48) { _, _ in 0 } - } - ) - } - - let fullMs = fullDurationsMs.reduce(0, +) - let cappedFullMs = cappedFullDurationsMs.reduce(0, +) - let previewMs = previewDurationsMs.reduce(0, +) - let fullDroppedFrames = estimatedDroppedFrames(for: fullDurationsMs) - let cappedFullDroppedFrames = estimatedDroppedFrames(for: cappedFullDurationsMs) - let previewDroppedFrames = estimatedDroppedFrames(for: previewDurationsMs) - let maxFullMs = fullDurationsMs.max() ?? 0 - let maxCappedFullMs = cappedFullDurationsMs.max() ?? 0 - let maxPreviewMs = previewDurationsMs.max() ?? 0 - let maxPreviewQuery = previewDurationsMs.enumerated().max(by: { $0.element < $1.element }).map { - queries[$0.offset] - } ?? "" - - print(String( - format: "BENCH cmd+p fast-typing full=%.2fms cappedFull=%.2fms visiblePreview=%.2fms maxFull=%.2fms maxCappedFull=%.2fms maxVisiblePreview=%.2fms maxVisiblePreviewQuery=%@ fullDroppedFrames=%d cappedFullDroppedFrames=%d visiblePreviewDroppedFrames=%d", - fullMs, - cappedFullMs, - previewMs, - maxFullMs, - maxCappedFullMs, - maxPreviewMs, - maxPreviewQuery, - fullDroppedFrames, - cappedFullDroppedFrames, - previewDroppedFrames - )) - XCTAssertLessThan( - cappedFullMs, - fullMs, - "Capped full-corpus search should avoid preparing results the UI cannot render: full=\(fullMs) capped=\(cappedFullMs)" - ) - XCTAssertLessThanOrEqual( - cappedFullDroppedFrames, - fullDroppedFrames, - "Capped full-corpus search should not increase estimated frame-budget misses: full=\(fullDroppedFrames) capped=\(cappedFullDroppedFrames)" - ) - XCTAssertLessThan( - previewMs, - cappedFullMs, - "Visible-candidate preview search should avoid full-corpus work during fast typing: capped=\(cappedFullMs) preview=\(previewMs)" - ) - XCTAssertLessThanOrEqual( - previewDroppedFrames, - cappedFullDroppedFrames, - "Preview search should not increase estimated frame-budget misses: capped=\(cappedFullDroppedFrames) preview=\(previewDroppedFrames)" - ) - } } diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index ab725ce7e314..b86f2d9da11a 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -22,7 +22,7 @@ final class FeedCoordinatorTests: XCTestCase { XCTAssertFalse(FeedPermissionActionPolicy.supportsBypassPermissions(source: .hermesAgent)) } - func testBlockingIngestExpiresItemWhenHookTimesOut() async { + func testBlockingIngestExpiresItemWhenHookTimesOut() async throws { await MainActor.run { let store = WorkstreamStore(ringCapacity: 10) FeedCoordinator.shared.install(store: store) @@ -135,6 +135,77 @@ final class FeedCoordinatorTests: XCTestCase { ) } + func testBlockingIngestExpiresWaiterWhenAgentProcessExitsBeforeNotificationDisplay() async throws { +#if DEBUG + let requestId = "agent-exit-request" + let ppid = 424_242 + let notifications = NotificationRequestRecorder() + + addTeardownBlock { + Self.resetFeedCoordinatorTestHooks() + } + + await MainActor.run { + let store = WorkstreamStore(ringCapacity: 10) + FeedCoordinator.shared.install(store: store) + FeedCoordinatorTestHooks.afterBlockingEventIngested = { _, ingestedRequestId in + guard ingestedRequestId == requestId else { return } + MainActor.assumeIsolated { + FeedCoordinator.shared.debugExpirePendingItemsAndWaiters(forPpid: ppid) + } + } + FeedCoordinatorTestHooks.isAppActiveOverride = { false } + FeedCoordinatorTestHooks.notificationPostObserver = { _, postedRequestId in + notifications.record(postedRequestId) + } + } + + let event = WorkstreamEvent( + sessionId: "claude-agent-exit-test", + hookEventName: .permissionRequest, + source: "claude", + cwd: "/tmp", + toolName: "Bash", + toolInputJSON: #"{"command":"true"}"#, + requestId: requestId, + ppid: ppid + ) + + let done = expectation(description: "blocking ingest expired after agent exit") + let resultBox = IngestResultBox() + + DispatchQueue.global(qos: .userInitiated).async { + resultBox.value = FeedCoordinator.shared.ingestBlocking( + event: event, + waitTimeout: 5 + ) + done.fulfill() + } + + await fulfillment(of: [done], timeout: 2) + await MainActor.run {} + + guard case .timedOut = resultBox.value else { + XCTFail("expected agent-exit hook to unblock as timed out") + return + } + + let status = await MainActor.run { + FeedCoordinator.shared.store.items.first?.status + } + guard case .expired = status else { + XCTFail("agent-exit hook item should be expired") + return + } + XCTAssertTrue( + notifications.requestIds.isEmpty, + "expired hook requests should not post native notifications" + ) +#else + throw XCTSkip("debug feed test hooks are only available in DEBUG") +#endif + } + private static func resetFeedCoordinatorTestHooks() { let reset: @Sendable () -> Void = { MainActor.assumeIsolated { diff --git a/cmuxTests/KeyboardShortcutContextTests.swift b/cmuxTests/KeyboardShortcutContextTests.swift index 6c3d29e2deb8..d3a5b50ecfdc 100644 --- a/cmuxTests/KeyboardShortcutContextTests.swift +++ b/cmuxTests/KeyboardShortcutContextTests.swift @@ -36,6 +36,34 @@ final class KeyboardShortcutContextTests: XCTestCase { ) } + func testTerminalSidekickAndRightSidebarCanShareFallbackShortcut() { + let shortcut = KeyboardShortcutSettings.Action.toggleTerminalSidekick.defaultShortcut + + XCTAssertEqual(shortcut, KeyboardShortcutSettings.Action.toggleRightSidebar.defaultShortcut) + XCTAssertFalse( + KeyboardShortcutSettings.Action.toggleTerminalSidekick.conflicts( + with: shortcut, + proposedAction: .toggleRightSidebar, + configuredShortcut: shortcut + ) + ) + XCTAssertFalse( + KeyboardShortcutSettings.Action.toggleRightSidebar.conflicts( + with: shortcut, + proposedAction: .toggleTerminalSidekick, + configuredShortcut: shortcut + ) + ) + XCTAssertEqual( + KeyboardShortcutSettings.Action.toggleTerminalSidekick.normalizedRecordedShortcutResult(shortcut), + .accepted(shortcut) + ) + XCTAssertEqual( + KeyboardShortcutSettings.Action.toggleRightSidebar.normalizedRecordedShortcutResult(shortcut), + .accepted(shortcut) + ) + } + func testRenameTabCanReassignCommandRAfterUnbindingWithoutBrowserReloadConflict() throws { let originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore let directoryURL = try makeTemporaryDirectory() diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift index 86e2aff3fe16..04aaa4f04dde 100644 --- a/cmuxTests/NotificationAndMenuBarTests.swift +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -1659,18 +1659,6 @@ final class NotificationMenuSnapshotBuilderTests: XCTestCase { XCTAssertEqual(snapshot.recentNotifications.map(\.id), Array(notifications.prefix(3)).map(\.id)) } - func testSnapshotCountsWorkspaceUnreadIndicatorsWithoutNotificationRecords() { - let snapshot = NotificationMenuSnapshotBuilder.make( - notifications: [], - workspaceUnreadIndicatorCount: 2 - ) - - XCTAssertEqual(snapshot.unreadCount, 2) - XCTAssertTrue(snapshot.hasNotifications) - XCTAssertTrue(snapshot.hasUnreadNotifications) - XCTAssertTrue(snapshot.recentNotifications.isEmpty) - } - func testStateHintTitleHandlesSingularPluralAndZero() { XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 0), "No unread notifications") XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 1), "1 unread notification") diff --git a/cmuxTests/OmnibarAndToolsTests.swift b/cmuxTests/OmnibarAndToolsTests.swift index f9ce2c9302c7..2ad695401f87 100644 --- a/cmuxTests/OmnibarAndToolsTests.swift +++ b/cmuxTests/OmnibarAndToolsTests.swift @@ -399,47 +399,6 @@ final class VSCodeServeWebControllerTests: XCTestCase { final class OmnibarStateMachineTests: XCTestCase { - func testPointerFocusCanPreserveInitialClickSelection() throws { - var state = OmnibarState() - - let effects = omnibarReduce( - state: &state, - event: .focusGained(currentURLString: "https://example.com/", shouldSelectAll: false) - ) - - XCTAssertTrue(state.isFocused) - XCTAssertEqual(state.buffer, "https://example.com/") - XCTAssertFalse(effects.shouldSelectAll) - } - - func testRefocusRequestPreservesEditingBuffer() throws { - var state = OmnibarState() - - _ = omnibarReduce( - state: &state, - event: .focusGained(currentURLString: "https://example.com/") - ) - _ = omnibarReduce(state: &state, event: .bufferChanged("abcdef")) - - let effects = omnibarReduce( - state: &state, - event: .focusReasserted( - shouldSelectAll: browserOmnibarShouldSelectAllOnFocusReassertion(isUserEditing: state.isUserEditing) - ) - ) - - XCTAssertTrue(state.isFocused) - XCTAssertTrue(state.isUserEditing) - XCTAssertEqual(state.currentURLString, "https://example.com/") - XCTAssertEqual(state.buffer, "abcdef") - XCTAssertFalse(effects.shouldSelectAll) - } - - func testFocusReassertionDoesNotSelectAllDuringUserEdit() throws { - XCTAssertFalse(browserOmnibarShouldSelectAllOnFocusReassertion(isUserEditing: true)) - XCTAssertTrue(browserOmnibarShouldSelectAllOnFocusReassertion(isUserEditing: false)) - } - func testEscapeRevertsWhenEditingThenBlursOnSecondEscape() throws { var state = OmnibarState() @@ -628,160 +587,6 @@ final class OmnibarStateMachineTests: XCTestCase { } } -@MainActor -final class BrowserOmnibarNativeFieldRegistryWindowSelectionTests: XCTestCase { - func testFieldLookupPrefersMatchingWindowAndNilWindowPrefersAttachedField() throws { - let panelId = UUID() - let registry = BrowserOmnibarNativeFieldRegistry() - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 240, height: 32), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 32)) - let visibleField = OmnibarNativeTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) - let offWindowField = OmnibarNativeTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) - visibleField.panelId = panelId - offWindowField.panelId = panelId - contentView.addSubview(visibleField) - window.contentView = contentView - defer { - registry.unregister(visibleField, panelId: panelId) - registry.unregister(offWindowField, panelId: panelId) - visibleField.removeFromSuperview() - window.contentView = nil - window.orderOut(nil) - } - - registry.register(visibleField, panelId: panelId) - registry.register(offWindowField, panelId: panelId) - - XCTAssertTrue(registry.field(for: panelId, in: window) === visibleField) - XCTAssertTrue(registry.field(for: panelId, in: nil) === visibleField) - XCTAssertTrue(registry.field(for: panelId) === visibleField) - - registry.unregister(offWindowField, panelId: panelId) - - XCTAssertTrue(registry.field(for: panelId) === visibleField) - } - - func testWindowLookupDoesNotFallBackAcrossWindows() throws { - let panelId = UUID() - let registry = BrowserOmnibarNativeFieldRegistry() - let sourceWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 240, height: 32), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - let requestedWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 240, height: 32), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 32)) - let sourceField = OmnibarNativeTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) - sourceField.panelId = panelId - contentView.addSubview(sourceField) - sourceWindow.contentView = contentView - defer { - registry.unregister(sourceField, panelId: panelId) - sourceField.removeFromSuperview() - sourceWindow.contentView = nil - sourceWindow.orderOut(nil) - requestedWindow.orderOut(nil) - } - - registry.register(sourceField, panelId: panelId) - - XCTAssertTrue(registry.field(for: panelId, in: sourceWindow) === sourceField) - XCTAssertNil(registry.field(for: panelId, in: requestedWindow)) - } - - func testInteractionOverlayPassesThroughUntilFieldIsRegisteredInWindow() throws { - let panelId = UUID() - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 240, height: 32), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 32)) - let field = OmnibarNativeTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) - let interactionView = BrowserOmnibarInteractionView(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) - field.panelId = panelId - interactionView.panelId = panelId - contentView.addSubview(field) - contentView.addSubview(interactionView) - window.contentView = contentView - defer { - BrowserOmnibarNativeFieldRegistry.shared.unregister(field, panelId: panelId) - field.removeFromSuperview() - interactionView.removeFromSuperview() - window.contentView = nil - window.orderOut(nil) - } - - XCTAssertNil( - interactionView.hitTest(NSPoint(x: 12, y: 12)), - "The overlay must not swallow the first click before it has a forwarding target" - ) - - BrowserOmnibarNativeFieldRegistry.shared.register(field, panelId: panelId) - - XCTAssertTrue( - interactionView.hitTest(NSPoint(x: 12, y: 12)) === interactionView, - "The overlay should capture events once it can forward to the same-window native field" - ) - } -} - -@MainActor -final class BrowserPortalOmnibarSuggestionsTests: XCTestCase { - func testPortalSuggestionsOverlayPassesHitTestingOutsidePopupFrame() { - let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 420, height: 260)) - let item = OmnibarSuggestion.search(engineName: "Google", query: "news") - let popupFrame = CGRect( - x: 40, - y: 12, - width: 220, - height: OmnibarSuggestionsView.popupHeight(for: [item]) - ) - - slot.setOmnibarSuggestions( - BrowserPortalOmnibarSuggestionsConfiguration( - panelId: UUID(), - popupFrame: popupFrame, - colorScheme: .dark, - engineName: "Google", - items: [item], - selectedIndex: 0, - isLoadingRemoteSuggestions: false, - searchSuggestionsEnabled: true, - onCommit: { _ in XCTFail("Unexpected commit") }, - onHighlight: { _ in XCTFail("Unexpected highlight") } - ) - ) - slot.layoutSubtreeIfNeeded() - - let overlay = slot.subviews.first { - String(describing: type(of: $0)).contains("OmnibarSuggestionsHostingView") - } - XCTAssertNotNil(overlay) - guard let overlay else { return } - - XCTAssertNil(overlay.hitTest(NSPoint(x: 8, y: 8))) - - let insideTopLeftPoint = NSPoint(x: popupFrame.midX, y: popupFrame.midY) - let insidePoint = overlay.isFlipped - ? insideTopLeftPoint - : NSPoint(x: insideTopLeftPoint.x, y: overlay.bounds.height - insideTopLeftPoint.y) - XCTAssertNotNil(overlay.hitTest(insidePoint)) - } -} - @MainActor private final class OmnibarInlineDeletionHarness { var state = OmnibarState() @@ -908,57 +713,6 @@ private final class OmnibarInlineDeletionHarness { } -@MainActor -final class BrowserOmnibarFieldEditorResolutionTests: XCTestCase { - func testPanelIdResolutionUsesLiveOmnibarFieldWhenFieldEditorResponderChainIsStale() { - _ = NSApplication.shared - - let panelId = UUID() - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 80), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) - window.contentView = contentView - - let staleWebView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 420, height: 80), configuration: WKWebViewConfiguration()) - contentView.addSubview(staleWebView) - - let field = OmnibarNativeTextField(frame: NSRect(x: 8, y: 28, width: 300, height: 24)) - field.panelId = panelId - contentView.addSubview(field) - - window.makeKeyAndOrderFront(nil) - defer { - field.removeFromSuperview() - staleWebView.removeFromSuperview() - window.contentView = nil - window.orderOut(nil) - } - - XCTAssertTrue(window.makeFirstResponder(field)) - guard let editor = field.currentEditor() as? NSTextView else { - XCTFail("Expected omnibar field editor after focusing text field") - return - } - - let originalNextResponder = editor.nextResponder - editor.nextResponder = staleWebView - defer { - editor.nextResponder = originalNextResponder - } - - XCTAssertEqual( - browserOmnibarPanelId(for: editor), - panelId, - "A live omnibar field editor must resolve to its owning omnibar field even when AppKit leaves a stale browser responder chain behind" - ) - } -} - - final class OmnibarRemoteSuggestionMergeTests: XCTestCase { func testMergeRemoteSuggestionsInsertsBelowSearchAndDedupes() { let now = Date() diff --git a/cmuxTests/RestorableAgentHookProviderResumeTests.swift b/cmuxTests/RestorableAgentHookProviderResumeTests.swift index 164d347aa0a9..526231cda528 100644 --- a/cmuxTests/RestorableAgentHookProviderResumeTests.swift +++ b/cmuxTests/RestorableAgentHookProviderResumeTests.swift @@ -111,36 +111,6 @@ extension SocketListenerAcceptPolicyTests { ) } - func testCursorResumeCommandDropsCapturedNodeRuntimeFlags() { - let snapshot = SessionRestorableAgentSnapshot( - kind: .cursor, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: "~/.cursor", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "cursor", - executablePath: "/usr/local/bin/agent", - arguments: [ - "/usr/local/bin/agent", - "agent", - "--use-system-ca", - "--model", - "gpt-5.4", - "--resume", - "old-chat" - ], - workingDirectory: "~/.cursor", - environment: nil, - capturedAt: 123, - source: "process" - ) - ) - - XCTAssertEqual( - snapshot.resumeCommand, - "cd '~/.cursor' && '/usr/local/bin/agent' '--resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4'" - ) - } - func testAdditionalHookAgentResumeCommandsUseVerifiedCLIResumeFlags() { let cursor = SessionRestorableAgentSnapshot( kind: .cursor, diff --git a/cmuxTests/RightSidebarCommandPaletteTests.swift b/cmuxTests/RightSidebarCommandPaletteTests.swift index 59c68f41845c..c32ea03056d7 100644 --- a/cmuxTests/RightSidebarCommandPaletteTests.swift +++ b/cmuxTests/RightSidebarCommandPaletteTests.swift @@ -14,7 +14,7 @@ final class RightSidebarCommandPaletteTests: XCTestCase { defaults.removeObject(forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) let contributions = ContentView.commandPaletteRightSidebarModeCommandContributions() let contributionsByID = Dictionary(uniqueKeysWithValues: contributions.map { ($0.commandId, $0) }) - let context = ContentView.CommandPaletteContextSnapshot() + let context = CommandPaletteContextSnapshot() for mode in RightSidebarMode.availableModes() { let commandID = ContentView.commandPaletteRightSidebarModeCommandID(mode) @@ -57,11 +57,7 @@ final class RightSidebarCommandPaletteTests: XCTestCase { } } - func testCommandPaletteUnreadActionsUseConfigurableShortcutActions() { - XCTAssertEqual( - ContentView.commandPaletteShortcutAction(forCommandID: "palette.toggleUnread"), - .toggleUnread - ) + func testCommandPaletteUnreadDeferActionUsesConfigurableShortcutAction() { XCTAssertEqual( ContentView.commandPaletteShortcutAction(forCommandID: "palette.markOldestUnreadAndJumpNext"), .markOldestUnreadAndJumpNext diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 880f7b48301b..a233e8704d0b 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -2056,6 +2056,47 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual(permissions, 0o600) } + func testRestorableAgentStartupInputSkipsLauncherScriptForPreservedClaudeAuthSelectionEnvironment() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-resume-test-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let longBaseURL = "https://api.example.test/" + String(repeating: "very-long-segment/", count: 90) + let snapshot = SessionRestorableAgentSnapshot( + kind: .claude, + sessionId: "claude-session-env", + workingDirectory: nil, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "claude", + executablePath: "claude", + arguments: ["claude"], + workingDirectory: nil, + environment: [ + "ANTHROPIC_BASE_URL": longBaseURL, + "ANTHROPIC_MODEL": "sonnet" + ], + capturedAt: nil, + source: nil + ) + ) + + let resumeCommand = try XCTUnwrap(snapshot.resumeCommand) + XCTAssertTrue(resumeCommand.contains("'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1'")) + XCTAssertTrue( + resumeCommand.contains( + "'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=ANTHROPIC_BASE_URL,ANTHROPIC_MODEL'" + ) + ) + XCTAssertGreaterThan( + resumeCommand.appending("\n").utf8.count, + SessionRestorableAgentSnapshot.maxInlineStartupInputBytes + ) + XCTAssertNil(snapshot.resumeStartupInput(temporaryDirectory: tempDir)) + let scriptDirectory = tempDir.appendingPathComponent("cmux-agent-resume", isDirectory: true) + XCTAssertFalse(FileManager.default.fileExists(atPath: scriptDirectory.path)) + } + func testRestorableAgentStartupInputSkipsOversizedCommandWhenScriptCannotBeWritten() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-resume-test-\(UUID().uuidString)", isDirectory: true) diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 1e36a81eb662..10b9d4bed691 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -1361,12 +1361,7 @@ final class MainWindowFocusControllerRightSidebarHideTests: XCTestCase { XCTAssertEqual(controller.debugPendingRightSidebarFocusMode, .sessions) let focusHost = RightSidebarKeyboardFocusView(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) - defer { - _ = window.makeFirstResponder(nil) - focusHost.removeFromSuperview() - window.contentView = nil - window.orderOut(nil) - } + defer { _ = window.makeFirstResponder(nil); focusHost.removeFromSuperview(); window.contentView = nil; window.close() } contentView.addSubview(focusHost) controller.registerRightSidebarHost(focusHost) diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 31535fc23060..85648c406bd5 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1,3 +1,5 @@ +import CoreGraphics +import Foundation import XCTest #if canImport(cmux_DEV) @@ -34,6 +36,197 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(restored.tabs[1].customTitle, "Second") } + func testTerminalSidekickStatePersistsAndRestoresWithTerminalSnapshot() throws { + let wasBrowserDisabled = BrowserAvailabilitySettings.isDisabled() + BrowserAvailabilitySettings.setDisabled(false) + defer { BrowserAvailabilitySettings.setDisabled(wasBrowserDisabled) } + + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let terminalPanel = try XCTUnwrap(workspace.focusedTerminalPanel) + let sidekickURL = try XCTUnwrap(URL(string: "data:text/html,Sidekick")) + + XCTAssertTrue(terminalPanel.openSidekick(url: sidekickURL)) + terminalPanel.setSidekickSplitRatio(0.45) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + let panelSnapshot = try XCTUnwrap( + snapshot.workspaces.first?.panels.first { $0.id == terminalPanel.id } + ) + let sidekickSnapshot = try XCTUnwrap(panelSnapshot.terminal?.sidekick) + XCTAssertEqual(sidekickSnapshot.urlString, sidekickURL.absoluteString) + XCTAssertTrue(sidekickSnapshot.isOpen) + XCTAssertEqual(sidekickSnapshot.splitRatio, 0.45, accuracy: 0.001) + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + let restoredTerminal = try XCTUnwrap(restored.selectedWorkspace?.focusedTerminalPanel) + XCTAssertEqual(restoredTerminal.sidekickState.urlString, sidekickURL.absoluteString) + XCTAssertTrue(restoredTerminal.sidekickState.isOpen) + XCTAssertEqual(restoredTerminal.sidekickState.splitRatio, 0.45, accuracy: 0.001) + XCTAssertNotNil(restoredTerminal.sidekickBrowserPanel) + } + + func testTerminalSidekickSplitRatioClampsWhenSetAndRestored() throws { + let manager = TabManager() + let terminalPanel = try XCTUnwrap(manager.selectedWorkspace?.focusedTerminalPanel) + + terminalPanel.setSidekickSplitRatio(0.1) + XCTAssertEqual(terminalPanel.sidekickState.splitRatio, 0.25, accuracy: 0.001) + + terminalPanel.restoreSidekick( + SessionTerminalSidekickSnapshot( + urlString: nil, + isOpen: false, + splitRatio: 0.9 + ) + ) + + XCTAssertEqual(terminalPanel.sidekickState.splitRatio, 0.7, accuracy: 0.001) + } + + func testTerminalSidekickSnapshotSanitizesPersistedValues() throws { + let directSnapshot = SessionTerminalSidekickSnapshot( + urlString: " ", + isOpen: true, + splitRatio: .nan + ) + + XCTAssertNil(directSnapshot.urlString) + XCTAssertEqual(directSnapshot.splitRatio, TerminalSidekickState.defaultSplitRatio, accuracy: 0.001) + + let decoded = try JSONDecoder().decode( + SessionTerminalSidekickSnapshot.self, + from: Data( + #"{"urlString":" https://sidekick.example/restored ","isOpen":true,"splitRatio":9.0}"# + .utf8 + ) + ) + + XCTAssertEqual(decoded.urlString, "https://sidekick.example/restored") + XCTAssertTrue(decoded.isOpen) + XCTAssertEqual(decoded.splitRatio, 0.7, accuracy: 0.001) + } + + func testTerminalSidekickResizeRatioMatchesDisplayedMinimumSidekickWidth() throws { + let totalWidth: CGFloat = 1000 + let ratio = try XCTUnwrap( + TerminalSidekickLayout.splitRatio( + totalWidth: totalWidth, + startRatio: 0.25, + translationWidth: 0 + ) + ) + + let renderedSidekickWidth = TerminalSidekickLayout.sidekickWidth( + totalWidth: totalWidth, + splitRatio: ratio + ) + let availableWidth = totalWidth - TerminalSidekickLayout.dividerWidth + + XCTAssertEqual(renderedSidekickWidth, 260, accuracy: 0.001) + XCTAssertEqual(ratio, Double(renderedSidekickWidth / availableWidth), accuracy: 0.001) + } + + func testTerminalSidekickResizeRatioPreservesMinimumTerminalWidth() throws { + let totalWidth: CGFloat = 720 + let ratio = try XCTUnwrap( + TerminalSidekickLayout.splitRatio( + totalWidth: totalWidth, + startRatio: 0.7, + translationWidth: -400 + ) + ) + + let renderedSidekickWidth = TerminalSidekickLayout.sidekickWidth( + totalWidth: totalWidth, + splitRatio: ratio + ) + let renderedTerminalWidth = totalWidth - TerminalSidekickLayout.dividerWidth - renderedSidekickWidth + + XCTAssertEqual(renderedTerminalWidth, 280, accuracy: 0.001) + XCTAssertEqual( + ratio, + Double(renderedSidekickWidth / (totalWidth - TerminalSidekickLayout.dividerWidth)), + accuracy: 0.001 + ) + } + + func testTerminalSidekickRestoreStaysClosedWhenBrowserIsDisabled() throws { + let wasBrowserDisabled = BrowserAvailabilitySettings.isDisabled() + BrowserAvailabilitySettings.setDisabled(true) + defer { BrowserAvailabilitySettings.setDisabled(wasBrowserDisabled) } + + let manager = TabManager() + let terminalPanel = try XCTUnwrap(manager.selectedWorkspace?.focusedTerminalPanel) + + terminalPanel.restoreSidekick( + SessionTerminalSidekickSnapshot( + urlString: "https://sidekick.example/restored", + isOpen: true, + splitRatio: 0.4 + ) + ) + + XCTAssertFalse(terminalPanel.sidekickState.isOpen) + XCTAssertEqual(terminalPanel.sidekickState.urlString, "https://sidekick.example/restored") + XCTAssertNil(terminalPanel.sidekickBrowserPanel) + } + + func testTerminalSidekickManualNavigationDoesNotPreloadStoredURL() throws { + let wasBrowserDisabled = BrowserAvailabilitySettings.isDisabled() + BrowserAvailabilitySettings.setDisabled(false) + defer { BrowserAvailabilitySettings.setDisabled(wasBrowserDisabled) } + + let manager = TabManager() + let terminalPanel = try XCTUnwrap(manager.selectedWorkspace?.focusedTerminalPanel) + + terminalPanel.restoreSidekick( + SessionTerminalSidekickSnapshot( + urlString: "https://sidekick.example/stale", + isOpen: false, + splitRatio: 0.4 + ) + ) + terminalPanel.navigateSidekick(input: "https://sidekick.example/current") + + let browserPanel = try XCTUnwrap(terminalPanel.sidekickBrowserPanel) + XCTAssertTrue(terminalPanel.sidekickState.isOpen) + XCTAssertEqual(browserPanel.currentURL?.absoluteString, "https://sidekick.example/current") + } + + func testTerminalSidekickRestoreNavigatesExistingPanelToSnapshotURL() throws { + let wasBrowserDisabled = BrowserAvailabilitySettings.isDisabled() + BrowserAvailabilitySettings.setDisabled(false) + defer { BrowserAvailabilitySettings.setDisabled(wasBrowserDisabled) } + + let manager = TabManager() + let terminalPanel = try XCTUnwrap(manager.selectedWorkspace?.focusedTerminalPanel) + + terminalPanel.restoreSidekick( + SessionTerminalSidekickSnapshot( + urlString: "https://sidekick.example/first", + isOpen: true, + splitRatio: 0.4 + ) + ) + let browserPanel = try XCTUnwrap(terminalPanel.sidekickBrowserPanel) + XCTAssertEqual(browserPanel.currentURL?.absoluteString, "https://sidekick.example/first") + + terminalPanel.restoreSidekick( + SessionTerminalSidekickSnapshot( + urlString: "https://sidekick.example/second", + isOpen: true, + splitRatio: 0.4 + ) + ) + + let restoredBrowserPanel = try XCTUnwrap(terminalPanel.sidekickBrowserPanel) + XCTAssertTrue(browserPanel === restoredBrowserPanel) + XCTAssertEqual(restoredBrowserPanel.currentURL?.absoluteString, "https://sidekick.example/second") + } + func testRestoreSessionSnapshotWithNoWorkspacesKeepsSingleFallbackWorkspace() { let manager = TabManager() let emptySnapshot = SessionTabManagerSnapshot( diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 6aeb9f00e756..15e66b677ef0 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -938,242 +938,6 @@ final class GhosttyPasteboardHelperTests: XCTestCase { } } -@MainActor -final class TerminalOffscreenStartupTests: XCTestCase { - func testPlainSurfaceDoesNotStartRuntimeBeforeWindowAttachmentOrInput() { - let panel = TerminalPanel(workspaceId: UUID()) - - XCTAssertNil(panel.hostedView.window) - XCTAssertFalse(panel.surface.debugHasHeadlessStartupWindowForTesting()) - XCTAssertEqual( - panel.surface.debugRuntimeSurfaceCreateAttemptCountForTesting(), - 0, - "Empty terminal surfaces should stay lazy until they attach or receive input so tests and background helpers do not spawn idle PTYs." - ) - } - - func testPlainHostedViewWindowAttachmentCreatesRuntimeSurface() throws { - let panel = TerminalPanel(workspaceId: UUID()) - XCTAssertEqual(panel.hostedView.debugSurfaceId, panel.surface.id) - XCTAssertNil(panel.surface.surface) - XCTAssertFalse(panel.surface.debugHasHeadlessStartupWindowForTesting()) - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { - panel.hostedView.removeFromSuperview() - panel.surface.teardownSurface() - window.orderOut(nil) - } - - let contentView = try XCTUnwrap(window.contentView) - panel.hostedView.frame = contentView.bounds - panel.hostedView.autoresizingMask = [.width, .height] - contentView.addSubview(panel.hostedView) - - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - contentView.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - XCTAssertNotNil( - panel.surface.surface, - "A direct AppKit-hosted terminal view must create its runtime surface once it enters a real window." - ) - XCTAssertGreaterThan(panel.surface.debugRuntimeSurfaceCreateAttemptCountForTesting(), 0) - } - - func testInitialInputSurfaceAttemptsRuntimeCreationBeforeWindowAttachment() { - let panel = TerminalPanel( - workspaceId: UUID(), - initialInput: "echo resume\n" - ) - - XCTAssertTrue( - panel.surface.debugHasHeadlessStartupWindowForTesting(), - "Restored auto-resume input should bootstrap through a hidden window rather than waiting for a user-focused portal." - ) - XCTAssertGreaterThan( - panel.surface.debugRuntimeSurfaceCreateAttemptCountForTesting(), - 0, - "Restored auto-resume input must start the terminal runtime without waiting for a window attach." - ) - } - - func testInitialCommandSurfaceAttemptsRuntimeCreationBeforeWindowAttachment() { - let panel = TerminalPanel( - workspaceId: UUID(), - initialCommand: "echo startup" - ) - - XCTAssertTrue( - panel.surface.debugHasHeadlessStartupWindowForTesting(), - "Command-launched offscreen terminals should bootstrap through a hidden window rather than waiting for a user-focused portal." - ) - XCTAssertGreaterThan( - panel.surface.debugRuntimeSurfaceCreateAttemptCountForTesting(), - 0, - "Offscreen command-launched terminals must start the runtime without waiting for a window attach." - ) - } - - func testHeadlessStartupWindowDoesNotCountAsViewInWindowForHealth() { - let panel = TerminalPanel( - workspaceId: UUID(), - initialCommand: "echo startup" - ) - - XCTAssertTrue(panel.surface.debugHasHeadlessStartupWindowForTesting()) - XCTAssertNotNil(panel.hostedView.window) - XCTAssertNil(panel.surface.uiWindow) - XCTAssertFalse(panel.hostedView.debugPortalVisibleInUI) - XCTAssertFalse(panel.hostedView.debugPortalActive) - XCTAssertFalse( - panel.surface.isViewInWindow, - "surface.health must keep reporting offscreen bootstrap terminals as unhosted." - ) - } - - func testForceRefreshIgnoresHeadlessStartupWindow() throws { -#if DEBUG - let panel = TerminalPanel( - workspaceId: UUID(), - initialCommand: "echo startup" - ) - XCTAssertTrue(panel.surface.debugHasHeadlessStartupWindowForTesting()) - XCTAssertNotNil(panel.hostedView.window) - XCTAssertNil(panel.surface.uiWindow) - - panel.surface.resetDebugForceRefreshCount() - panel.surface.forceRefresh(reason: "test.headless") - - XCTAssertEqual( - panel.surface.debugForceRefreshCount(), - 0, - "forceRefresh should ignore hidden bootstrap windows and wait for a real UI host." - ) -#else - throw XCTSkip("Debug-only regression test") -#endif - } - - func testColdSocketInputQueuesInsteadOfDroppingWhenRuntimeSurfaceIsMissing() { - let panel = TerminalPanel(workspaceId: UUID()) - - panel.surface.releaseSurfaceForTesting() - XCTAssertNil(panel.surface.surface) - panel.surface.sendInput("touch /tmp/cmux-cold-send\n") - - let pending = panel.surface.debugPendingSocketInputForTesting() - XCTAssertGreaterThan( - pending.items, - 0, - "Socket input sent before runtime surface creation must be queued or the caller must receive an error." - ) - XCTAssertGreaterThan(pending.bytes, 0) - } - - func testColdSocketInputRejectsOversizedQueueInsteadOfDroppingExistingInput() { - let panel = TerminalPanel(workspaceId: UUID()) - - panel.surface.releaseSurfaceForTesting() - XCTAssertTrue(panel.surface.sendInput("echo keep-me\n")) - - let oversizedInput = String(repeating: "x", count: 1_100_000) - XCTAssertFalse( - panel.surface.sendInput(oversizedInput), - "Cold socket input that cannot fit in the pending queue must be rejected instead of evicting previously accepted input." - ) - - let pending = panel.surface.debugPendingSocketInputForTesting() - XCTAssertGreaterThan(pending.items, 0) - XCTAssertLessThan(pending.bytes, 1_100_000) - } - - func testColdSocketInputQueuesBackspaceControlCharacterAsKeyEvent() { - let panel = TerminalPanel(workspaceId: UUID()) - - panel.surface.releaseSurfaceForTesting() - XCTAssertTrue(panel.surface.sendInput("abc\u{08}")) - - let pending = panel.surface.debugPendingSocketInputForTesting() - XCTAssertGreaterThan( - pending.keyEvents, - 0, - "Backspace control input must be queued as a key event for cold terminals instead of being pasted as literal text." - ) - } - - func testTeardownClosesHeadlessStartupWindow() { - let panel = TerminalPanel( - workspaceId: UUID(), - initialCommand: "echo startup" - ) - XCTAssertTrue(panel.surface.debugHasHeadlessStartupWindowForTesting()) - - panel.surface.teardownSurface() - - XCTAssertFalse( - panel.surface.debugHasHeadlessStartupWindowForTesting(), - "Explicit terminal teardown should close the hidden bootstrap window immediately instead of waiting for deinit." - ) - } - - func testClosedSurfaceRejectsColdSocketInputInsteadOfQueueingIt() { - let panel = TerminalPanel(workspaceId: UUID()) - - panel.surface.releaseSurfaceForTesting() - panel.surface.beginPortalCloseLifecycle(reason: "test.closed") - - XCTAssertFalse(panel.surface.sendInput("echo should-not-queue\n")) - XCTAssertEqual( - panel.surface.sendInputResult("echo should-not-queue\n"), - .surfaceUnavailable - ) - XCTAssertEqual(panel.surface.sendNamedKey("enter"), .surfaceUnavailable) - - let pending = panel.surface.debugPendingSocketInputForTesting() - XCTAssertEqual( - pending.items, - 0, - "Socket input accepted after terminal lifecycle closure would be stranded because the surface cannot be restarted." - ) - XCTAssertEqual(pending.bytes, 0) - } - - func testDaemonSendWorkspaceQueuesColdControlInputInsteadOfReportingDroppedOK() throws { - let previousManager = TerminalController.shared.activeTabManagerForCallerNotification() - let manager = TabManager() - TerminalController.shared.setActiveTabManager(manager) - defer { - TerminalController.shared.setActiveTabManager(previousManager) - } - - let workspace = try XCTUnwrap(manager.selectedWorkspace) - let panel = try XCTUnwrap(workspace.focusedTerminalPanel) - panel.surface.releaseSurfaceForTesting() - XCTAssertNil(panel.surface.surface) - - let response = TerminalController.shared.handleSocketLine( - "send_workspace \(workspace.id.uuidString) touch /tmp/cmux-daemon-cold-send\\n" - ) - XCTAssertEqual(response, "OK") - TerminalMutationBus.shared.drainForTesting() - - let pending = panel.surface.debugPendingSocketInputForTesting() - XCTAssertGreaterThan(pending.items, 0) - XCTAssertGreaterThan( - pending.keyEvents, - 0, - "A daemon send that accepts newline input for a cold terminal must queue the Return event instead of reporting OK for bytes that cannot execute." - ) - } -} - @MainActor final class FeedbackComposerMessageEditorViewTests: XCTestCase { func testLongMessageCreatesScrollableDocumentContent() { @@ -4706,6 +4470,40 @@ final class TerminalCmdClickPathPunctuationTrimmingTests: XCTestCase { } +final class TerminalControllerSocketTextChunkTests: XCTestCase { + func testSocketTextChunksReturnsSingleChunkForPlainText() { + XCTAssertEqual( + TerminalController.socketTextChunks("echo hello"), + [.text("echo hello")] + ) + } + + func testSocketTextChunksSplitsControlScalars() { + XCTAssertEqual( + TerminalController.socketTextChunks("abc\rdef\tghi"), + [ + .text("abc"), + .control("\r".unicodeScalars.first!), + .text("def"), + .control("\t".unicodeScalars.first!), + .text("ghi") + ] + ) + } + + func testSocketTextChunksDoesNotEmitEmptyTextChunksAroundConsecutiveControls() { + XCTAssertEqual( + TerminalController.socketTextChunks("\r\n\t"), + [ + .control("\r".unicodeScalars.first!), + .control("\n".unicodeScalars.first!), + .control("\t".unicodeScalars.first!) + ] + ) + } +} + + final class GhosttyModifierFlagsChangedActionTests: XCTestCase { func testLeftShiftPressReturnsPress() { XCTAssertEqual( diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index be0cc85871e1..8e4e0ff47336 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -178,10 +178,7 @@ final class MainWindowFocusRedrawTests: XCTestCase { ) window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(windowId.uuidString)") window.contentView = contentView - defer { - window.orderOut(nil) - window.contentView = nil - } + defer { window.close() } contentView.layoutSubtreeIfNeeded() splitView.adjustSubviews() @@ -499,8 +496,8 @@ final class FocusFlashPatternTests: XCTestCase { XCTAssertEqual(FocusFlashPattern.keyTimes, [0, 0.25, 0.5, 0.75, 1]) XCTAssertEqual(FocusFlashPattern.duration, 0.9, accuracy: 0.0001) XCTAssertEqual(FocusFlashPattern.curves, [.easeOut, .easeIn, .easeOut, .easeIn]) - XCTAssertEqual(FocusFlashPattern.ringInset, Double(PanelOverlayRingMetrics.inset), accuracy: 0.0001) - XCTAssertEqual(FocusFlashPattern.ringCornerRadius, Double(PanelOverlayRingMetrics.cornerRadius), accuracy: 0.0001) + XCTAssertEqual(FocusFlashPattern.ringInset, 6, accuracy: 0.0001) + XCTAssertEqual(FocusFlashPattern.ringCornerRadius, 10, accuracy: 0.0001) } func testFocusFlashPatternSegmentsCoverFullDoublePulseTimeline() { @@ -2536,6 +2533,41 @@ final class FilePreviewDragPasteboardWriterTests: XCTestCase { } +private actor ControlledFilePreviewTextSaver { + private var capturedContent: String? + private var startContinuation: CheckedContinuation? + private var finishContinuation: CheckedContinuation? + + func save( + content: String, + to url: URL, + encoding: String.Encoding + ) async -> FilePreviewTextSaver.Result { + _ = url + _ = encoding + capturedContent = content + startContinuation?.resume(returning: content) + startContinuation = nil + return await withCheckedContinuation { continuation in + finishContinuation = continuation + } + } + + func waitForSaveStart() async -> String { + if let capturedContent { + return capturedContent + } + return await withCheckedContinuation { continuation in + startContinuation = continuation + } + } + + func finishSave(with result: FilePreviewTextSaver.Result = .saved) { + finishContinuation?.resume(returning: result) + finishContinuation = nil + } +} + @MainActor final class FilePreviewPanelTextSavingTests: XCTestCase { func testNativePreviewSessionsDetachAndReuseViewsAcrossRecreation() throws { @@ -2634,27 +2666,26 @@ final class FilePreviewPanelTextSavingTests: XCTestCase { let url = try temporaryTextFile(contents: "original", encoding: .utf8) defer { try? FileManager.default.removeItem(at: url) } - let panel = FilePreviewPanel(workspaceId: UUID(), filePath: url.path) + let saveProbe = ControlledFilePreviewTextSaver() + let panel = FilePreviewPanel( + workspaceId: UUID(), + filePath: url.path, + textSaver: { content, url, encoding in + await saveProbe.save(content: content, to: url, encoding: encoding) + } + ) await panel.loadTextContent().value panel.updateTextContent("first save") - try FileManager.default.removeItem(at: url) - XCTAssertEqual(mkfifo(url.path, 0o600), 0) - let firstSave = try XCTUnwrap(panel.saveTextContent()) XCTAssertTrue(panel.isSaving) + let savedContent = await saveProbe.waitForSaveStart() + XCTAssertEqual(savedContent, "first save") panel.updateTextContent("second save") XCTAssertNil(panel.saveTextContent()) - let pipeRead = Task.detached { () throws -> String in - let handle = try FileHandle(forReadingFrom: url) - defer { try? handle.close() } - return String(data: handle.availableData, encoding: .utf8) ?? "" - } - - let savedContent = try await pipeRead.value - XCTAssertEqual(savedContent, "first save") + await saveProbe.finishSave() await firstSave.value XCTAssertEqual(panel.textContent, "second save") diff --git a/cmuxTests/WorkspaceManualUnreadTests.swift b/cmuxTests/WorkspaceManualUnreadTests.swift index 864fd7c9c4ea..dbf66124f577 100644 --- a/cmuxTests/WorkspaceManualUnreadTests.swift +++ b/cmuxTests/WorkspaceManualUnreadTests.swift @@ -424,6 +424,34 @@ final class WorkspaceManualUnreadTests: XCTestCase { } } + func testManualWorkspaceUnreadClearsFromWorkspaceReadAndClearFlows() { + let store = TerminalNotificationStore.shared + + func assertManualUnreadClears(_ action: (UUID) -> Void, line: UInt = #line) { + let workspaceId = UUID() + store.replaceNotificationsForTesting([]) + store.markUnread(forTabId: workspaceId) + + XCTAssertTrue(store.hasManualUnread(forTabId: workspaceId), line: line) + XCTAssertEqual(store.unreadCount(forTabId: workspaceId), 1, line: line) + + action(workspaceId) + + XCTAssertFalse(store.hasManualUnread(forTabId: workspaceId), line: line) + XCTAssertEqual(store.unreadCount(forTabId: workspaceId), 0, line: line) + } + + assertManualUnreadClears { workspaceId in + store.markRead(forTabId: workspaceId, surfaceId: nil) + } + assertManualUnreadClears { workspaceId in + store.clearNotifications(forTabId: workspaceId, surfaceId: nil, discardQueuedNotifications: false) + } + assertManualUnreadClears { workspaceId in + store.clearNotifications(forTabId: workspaceId, discardQueuedNotifications: false) + } + } + func testManualWorkspaceUnreadSurvivesNonTerminalDirectInteraction() { let appDelegate = AppDelegate.shared ?? AppDelegate() let manager = TabManager() @@ -519,75 +547,6 @@ final class WorkspaceManualUnreadTests: XCTestCase { XCTAssertEqual(store.unreadCount(forTabId: workspaceId), 1) } - func testToggleFocusedNotificationUnreadTogglesCurrentWorkspaceWithoutJumping() { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let store = TerminalNotificationStore.shared - - let originalNotificationStore = appDelegate.notificationStore - - store.replaceNotificationsForTesting([]) - appDelegate.notificationStore = store - let windowId = appDelegate.createMainWindow(shouldActivate: false) - - defer { - appDelegate.windowForMainWindowId(windowId)?.performClose(nil) - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) - store.replaceNotificationsForTesting([]) - appDelegate.notificationStore = originalNotificationStore - } - - guard let window = appDelegate.windowForMainWindowId(windowId), - let manager = appDelegate.tabManagerFor(windowId: windowId) else { - XCTFail("Expected test window and tab manager") - return - } - guard let currentWorkspace = manager.selectedWorkspace else { - XCTFail("Expected selected workspace") - return - } - let laterWorkspace = manager.addWorkspace(select: false, eagerLoadTerminal: false) - let currentNotificationId = UUID() - let laterNotificationId = UUID() - let now = Date() - store.replaceNotificationsForTesting([ - TerminalNotification( - id: currentNotificationId, - tabId: currentWorkspace.id, - surfaceId: nil, - title: "Current", - subtitle: "", - body: "", - createdAt: now, - isRead: true - ), - TerminalNotification( - id: laterNotificationId, - tabId: laterWorkspace.id, - surfaceId: nil, - title: "Later", - subtitle: "", - body: "", - createdAt: now.addingTimeInterval(-1), - isRead: true - ), - ]) - store.markUnread(forTabId: laterWorkspace.id) - - XCTAssertTrue(appDelegate.toggleFocusedNotificationUnread(preferredWindow: window)) - - XCTAssertEqual(manager.selectedTabId, currentWorkspace.id) - XCTAssertEqual(store.notifications.map(\.id), [currentNotificationId, laterNotificationId]) - XCTAssertEqual(store.notifications.map(\.isRead), [true, true]) - XCTAssertTrue(store.workspaceIsUnread(forTabId: currentWorkspace.id)) - XCTAssertTrue(store.workspaceIsUnread(forTabId: laterWorkspace.id)) - - XCTAssertTrue(appDelegate.toggleFocusedNotificationUnread(preferredWindow: window)) - - XCTAssertEqual(manager.selectedTabId, currentWorkspace.id) - XCTAssertFalse(store.workspaceIsUnread(forTabId: currentWorkspace.id)) - XCTAssertTrue(store.workspaceIsUnread(forTabId: laterWorkspace.id)) - } - func testMarkLatestNotificationAsOldestUnreadAppendsWhenNoOtherUnreadNotificationsRemain() { let store = TerminalNotificationStore.shared let currentWorkspaceId = UUID() @@ -658,408 +617,6 @@ final class WorkspaceManualUnreadTests: XCTestCase { XCTAssertFalse(workspace.manualUnreadPanelIds.contains(panelId)) } - func testMarkPanelUnreadMarksWorkspaceUnread() { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let store = TerminalNotificationStore.shared - let originalNotificationStore = appDelegate.notificationStore - - store.replaceNotificationsForTesting([]) - appDelegate.notificationStore = store - - defer { - store.replaceNotificationsForTesting([]) - appDelegate.notificationStore = originalNotificationStore - } - - let workspace = Workspace() - guard let panelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace with focused panel") - return - } - - XCTAssertFalse(store.workspaceIsUnread(forTabId: workspace.id)) - XCTAssertTrue(store.canMarkWorkspaceUnread(forTabIds: [workspace.id])) - - workspace.markPanelUnread(panelId) - - XCTAssertTrue(workspace.manualUnreadPanelIds.contains(panelId)) - XCTAssertFalse(store.hasManualUnread(forTabId: workspace.id)) - XCTAssertTrue(store.hasPanelDerivedUnread(forTabId: workspace.id)) - XCTAssertEqual(store.unreadCount(forTabId: workspace.id), 1) - XCTAssertTrue(store.workspaceIsUnread(forTabId: workspace.id)) - XCTAssertTrue(store.canMarkWorkspaceRead(forTabIds: [workspace.id])) - XCTAssertFalse(store.canMarkWorkspaceUnread(forTabIds: [workspace.id])) - } - - func testMarkPanelUnreadContributesToGlobalUnreadSurfaces() throws { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let store = TerminalNotificationStore.shared - let originalNotificationStore = appDelegate.notificationStore - - store.replaceNotificationsForTesting([]) - appDelegate.notificationStore = store - - defer { - store.replaceNotificationsForTesting([]) - appDelegate.notificationStore = originalNotificationStore - } - - let workspace = Workspace() - let panelId = try XCTUnwrap(workspace.focusedPanelId) - - XCTAssertEqual(store.unreadCount, 0) - XCTAssertEqual(store.notificationMenuSnapshot.unreadCount, 0) - - workspace.markPanelUnread(panelId) - - XCTAssertEqual(store.unreadCount, 1) - XCTAssertEqual(store.notificationMenuSnapshot.unreadCount, 1) - XCTAssertTrue(store.notificationMenuSnapshot.hasUnreadNotifications) - - store.markRead(forTabId: workspace.id) - - XCTAssertEqual(store.unreadCount, 0) - XCTAssertEqual(store.notificationMenuSnapshot.unreadCount, 0) - XCTAssertFalse(store.notificationMenuSnapshot.hasUnreadNotifications) - } - - func testMarkPanelReadClearsPanelDerivedWorkspaceUnread() { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let store = TerminalNotificationStore.shared - let originalNotificationStore = appDelegate.notificationStore - - store.replaceNotificationsForTesting([]) - appDelegate.notificationStore = store - - defer { - store.replaceNotificationsForTesting([]) - appDelegate.notificationStore = originalNotificationStore - } - - let workspace = Workspace() - guard let panelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace with focused panel") - return - } - - workspace.markPanelUnread(panelId) - workspace.markPanelRead(panelId) - - XCTAssertFalse(workspace.manualUnreadPanelIds.contains(panelId)) - XCTAssertFalse(store.hasPanelDerivedUnread(forTabId: workspace.id)) - XCTAssertFalse(store.workspaceIsUnread(forTabId: workspace.id)) - XCTAssertTrue(store.canMarkWorkspaceUnread(forTabIds: [workspace.id])) - } - - func testMarkPanelReadKeepsExplicitWorkspaceUnread() { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let store = TerminalNotificationStore.shared - let originalNotificationStore = appDelegate.notificationStore - - store.replaceNotificationsForTesting([]) - appDelegate.notificationStore = store - - defer { - store.replaceNotificationsForTesting([]) - appDelegate.notificationStore = originalNotificationStore - } - - let workspace = Workspace() - guard let panelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace with focused panel") - return - } - - workspace.markPanelUnread(panelId) - store.markUnread(forTabId: workspace.id) - - XCTAssertTrue(store.hasPanelDerivedUnread(forTabId: workspace.id)) - XCTAssertTrue(store.hasManualUnread(forTabId: workspace.id)) - XCTAssertEqual(store.unreadCount(forTabId: workspace.id), 1) - - workspace.markPanelRead(panelId) - - XCTAssertFalse(workspace.manualUnreadPanelIds.contains(panelId)) - XCTAssertFalse(store.hasPanelDerivedUnread(forTabId: workspace.id)) - XCTAssertTrue(store.hasManualUnread(forTabId: workspace.id)) - XCTAssertTrue(store.workspaceIsUnread(forTabId: workspace.id)) - XCTAssertEqual(store.unreadCount(forTabId: workspace.id), 1) - } - - func testMarkWorkspaceReadClearsPanelDerivedWorkspaceUnreadDurably() throws { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let manager = TabManager() - let store = TerminalNotificationStore.shared - let originalTabManager = appDelegate.tabManager - let originalNotificationStore = appDelegate.notificationStore - - store.replaceNotificationsForTesting([]) - appDelegate.tabManager = manager - appDelegate.notificationStore = store - - defer { - store.replaceNotificationsForTesting([]) - appDelegate.tabManager = originalTabManager - appDelegate.notificationStore = originalNotificationStore - } - - let workspace = try XCTUnwrap(manager.selectedWorkspace) - let panelId = try XCTUnwrap(workspace.focusedPanelId) - - workspace.markPanelUnread(panelId) - XCTAssertTrue(workspace.manualUnreadPanelIds.contains(panelId)) - XCTAssertTrue(store.hasPanelDerivedUnread(forTabId: workspace.id)) - - store.markRead(forTabId: workspace.id) - - XCTAssertFalse(workspace.manualUnreadPanelIds.contains(panelId)) - XCTAssertFalse(store.hasPanelDerivedUnread(forTabId: workspace.id)) - XCTAssertFalse(store.workspaceIsUnread(forTabId: workspace.id)) - - workspace.pruneSurfaceMetadata(validSurfaceIds: Set(workspace.panels.keys)) - - XCTAssertFalse(store.hasPanelDerivedUnread(forTabId: workspace.id)) - XCTAssertFalse(store.workspaceIsUnread(forTabId: workspace.id)) - } - - func testWorkspaceSurfaceMarkReadClearsPanelDerivedWorkspaceUnreadDurably() throws { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let manager = TabManager() - let store = TerminalNotificationStore.shared - let originalTabManager = appDelegate.tabManager - let originalNotificationStore = appDelegate.notificationStore - - store.replaceNotificationsForTesting([]) - appDelegate.tabManager = manager - appDelegate.notificationStore = store - - defer { - store.replaceNotificationsForTesting([]) - appDelegate.tabManager = originalTabManager - appDelegate.notificationStore = originalNotificationStore - } - - let workspace = try XCTUnwrap(manager.selectedWorkspace) - let panelId = try XCTUnwrap(workspace.focusedPanelId) - - workspace.markPanelUnread(panelId) - XCTAssertTrue(store.hasPanelDerivedUnread(forTabId: workspace.id)) - - store.markRead(forTabId: workspace.id, surfaceId: nil) - - XCTAssertFalse(workspace.manualUnreadPanelIds.contains(panelId)) - XCTAssertFalse(store.hasPanelDerivedUnread(forTabId: workspace.id)) - XCTAssertFalse(store.workspaceIsUnread(forTabId: workspace.id)) - - workspace.pruneSurfaceMetadata(validSurfaceIds: Set(workspace.panels.keys)) - - XCTAssertFalse(store.hasPanelDerivedUnread(forTabId: workspace.id)) - XCTAssertFalse(store.workspaceIsUnread(forTabId: workspace.id)) - } - - func testWorkspaceReadFlowsClearRepresentativeBadgeWhenPanelAndWorkspaceAreUnread() throws { - try assertWorkspaceReadFlowClearsRepresentativeBadge { store, workspaceId in - store.markRead(forTabId: workspaceId) - } - try assertWorkspaceReadFlowClearsRepresentativeBadge { store, _ in - store.markAllRead() - } - try assertWorkspaceReadFlowClearsRepresentativeBadge { store, _ in - store.clearAll(discardQueuedNotifications: false) - } - } - - func testWorkspaceReadFlowsClearNotificationBackedPanelBadges() throws { - try assertWorkspaceReadFlowClearsNotificationBackedPanelBadge { store, workspaceId in - store.markRead(forTabId: workspaceId) - } - try assertWorkspaceReadFlowClearsNotificationBackedPanelBadge { store, _ in - store.markAllRead() - } - } - - private func assertWorkspaceReadFlowClearsRepresentativeBadge( - _ action: (TerminalNotificationStore, UUID) -> Void, - line: UInt = #line - ) throws { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let manager = TabManager() - let store = TerminalNotificationStore.shared - let originalTabManager = appDelegate.tabManager - let originalNotificationStore = appDelegate.notificationStore - - store.replaceNotificationsForTesting([]) - appDelegate.tabManager = manager - appDelegate.notificationStore = store - - defer { - store.replaceNotificationsForTesting([]) - appDelegate.tabManager = originalTabManager - appDelegate.notificationStore = originalNotificationStore - } - - let workspace = try XCTUnwrap(manager.selectedWorkspace, line: line) - let panelId = try XCTUnwrap(workspace.focusedPanelId, line: line) - let tabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(panelId), line: line) - - workspace.markPanelUnread(panelId) - store.markUnread(forTabId: workspace.id) - - XCTAssertTrue(workspace.bonsplitController.tab(tabId)?.showsNotificationBadge ?? false, line: line) - - action(store, workspace.id) - - XCTAssertFalse(workspace.manualUnreadPanelIds.contains(panelId), line: line) - XCTAssertFalse(store.hasManualUnread(forTabId: workspace.id), line: line) - XCTAssertFalse(store.hasPanelDerivedUnread(forTabId: workspace.id), line: line) - XCTAssertFalse(store.workspaceIsUnread(forTabId: workspace.id), line: line) - XCTAssertFalse(workspace.bonsplitController.tab(tabId)?.showsNotificationBadge ?? true, line: line) - } - - private func assertWorkspaceReadFlowClearsNotificationBackedPanelBadge( - _ action: (TerminalNotificationStore, UUID) -> Void, - line: UInt = #line - ) throws { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let manager = TabManager() - let store = TerminalNotificationStore.shared - let originalTabManager = appDelegate.tabManager - let originalNotificationStore = appDelegate.notificationStore - - store.replaceNotificationsForTesting([]) - appDelegate.tabManager = manager - appDelegate.notificationStore = store - - defer { - store.replaceNotificationsForTesting([]) - appDelegate.tabManager = originalTabManager - appDelegate.notificationStore = originalNotificationStore - } - - let workspace = try XCTUnwrap(manager.selectedWorkspace, line: line) - let manualPanelId = try XCTUnwrap(workspace.focusedPanelId, line: line) - let notificationPanel = try XCTUnwrap( - workspace.newTerminalSplit(from: manualPanelId, orientation: .horizontal, focus: false), - line: line - ) - let manualTabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(manualPanelId), line: line) - let notificationTabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(notificationPanel.id), line: line) - - workspace.markPanelUnread(manualPanelId) - store.replaceNotificationsForTesting([ - TerminalNotification( - id: UUID(), - tabId: workspace.id, - surfaceId: notificationPanel.id, - title: "Unread", - subtitle: "", - body: "", - createdAt: Date(), - isRead: false - ), - ]) - workspace.bonsplitController.updateTab(notificationTabId, showsNotificationBadge: true) - - XCTAssertTrue(workspace.bonsplitController.tab(manualTabId)?.showsNotificationBadge ?? false, line: line) - XCTAssertTrue(workspace.bonsplitController.tab(notificationTabId)?.showsNotificationBadge ?? false, line: line) - - action(store, workspace.id) - - XCTAssertFalse(workspace.manualUnreadPanelIds.contains(manualPanelId), line: line) - XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: notificationPanel.id), line: line) - XCTAssertFalse(store.hasPanelDerivedUnread(forTabId: workspace.id), line: line) - XCTAssertFalse(workspace.bonsplitController.tab(manualTabId)?.showsNotificationBadge ?? true, line: line) - XCTAssertFalse(workspace.bonsplitController.tab(notificationTabId)?.showsNotificationBadge ?? true, line: line) - } - - func testClearUnreadAfterJumpClearsWorkspaceLevelRepresentativeFallback() throws { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let manager = TabManager() - let store = TerminalNotificationStore.shared - let originalTabManager = appDelegate.tabManager - let originalNotificationStore = appDelegate.notificationStore - - store.replaceNotificationsForTesting([]) - appDelegate.tabManager = manager - appDelegate.notificationStore = store - - defer { - store.replaceNotificationsForTesting([]) - appDelegate.tabManager = originalTabManager - appDelegate.notificationStore = originalNotificationStore - } - - let workspace = try XCTUnwrap(manager.selectedWorkspace) - let panelId = try XCTUnwrap(workspace.focusedPanelId) - - store.markUnread(forTabId: workspace.id) - - XCTAssertEqual(workspace.preferredUnreadPanelIdForJump(), panelId) - XCTAssertTrue(store.hasManualUnread(forTabId: workspace.id)) - - workspace.clearUnreadAfterJump(panelId: panelId) - - XCTAssertFalse(store.hasManualUnread(forTabId: workspace.id)) - XCTAssertFalse(store.workspaceIsUnread(forTabId: workspace.id)) - } - - func testClearUnreadAfterJumpOnlyClearsTargetPanelWhenPanelIsUnread() throws { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let store = TerminalNotificationStore.shared - let originalNotificationStore = appDelegate.notificationStore - - store.replaceNotificationsForTesting([]) - appDelegate.notificationStore = store - - defer { - store.replaceNotificationsForTesting([]) - appDelegate.notificationStore = originalNotificationStore - } - - let workspace = Workspace() - let firstPanelId = try XCTUnwrap(workspace.focusedPanelId) - let secondPanel = try XCTUnwrap(workspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal, focus: false)) - - workspace.markPanelUnread(firstPanelId) - workspace.markPanelUnread(secondPanel.id) - - workspace.clearUnreadAfterJump(panelId: firstPanelId) - - XCTAssertFalse(workspace.manualUnreadPanelIds.contains(firstPanelId)) - XCTAssertTrue(workspace.manualUnreadPanelIds.contains(secondPanel.id)) - XCTAssertTrue(store.hasPanelDerivedUnread(forTabId: workspace.id)) - XCTAssertTrue(store.workspaceIsUnread(forTabId: workspace.id)) - } - - func testMarkingOneUnreadPanelReadKeepsWorkspaceUnreadWhileAnotherPanelIsUnread() throws { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let store = TerminalNotificationStore.shared - let originalNotificationStore = appDelegate.notificationStore - - store.replaceNotificationsForTesting([]) - appDelegate.notificationStore = store - - defer { - store.replaceNotificationsForTesting([]) - appDelegate.notificationStore = originalNotificationStore - } - - let workspace = Workspace() - let firstPanelId = try XCTUnwrap(workspace.focusedPanelId) - let secondPanel = try XCTUnwrap(workspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal, focus: false)) - - workspace.markPanelUnread(firstPanelId) - workspace.markPanelUnread(secondPanel.id) - - workspace.markPanelRead(firstPanelId) - - XCTAssertFalse(workspace.manualUnreadPanelIds.contains(firstPanelId)) - XCTAssertTrue(workspace.manualUnreadPanelIds.contains(secondPanel.id)) - XCTAssertTrue(store.hasPanelDerivedUnread(forTabId: workspace.id)) - XCTAssertTrue(store.workspaceIsUnread(forTabId: workspace.id)) - XCTAssertEqual(store.unreadCount(forTabId: workspace.id), 1) - } - func testManualPanelUnreadSurvivesNonTerminalDirectInteraction() { let appDelegate = AppDelegate.shared ?? AppDelegate() let manager = TabManager() @@ -1577,7 +1134,7 @@ final class CommandPaletteFuzzyMatcherTests: XCTestCase { XCTAssertGreaterThan(renameTabScore ?? 0, reopenTabScore ?? 0) } - func testRenameScoresHigherThanUnrelatedCommandWhenUnrelatedStillMatches() { + func testRenameScoresHigherThanUnrelatedCommand() { let renameScore = CommandPaletteFuzzyMatcher.score( query: "rename", candidates: ["Rename Tab…", "Tab • Terminal 1", "rename", "tab", "title"] @@ -1597,9 +1154,8 @@ final class CommandPaletteFuzzyMatcherTests: XCTestCase { ) XCTAssertNotNil(renameScore) - if let unrelatedScore { - XCTAssertGreaterThan(renameScore ?? 0, unrelatedScore) - } + XCTAssertNotNil(unrelatedScore) + XCTAssertGreaterThan(renameScore ?? 0, unrelatedScore ?? 0) } func testTokenMatchingRequiresAllTokens() { @@ -1652,18 +1208,6 @@ final class CommandPaletteFuzzyMatcherTests: XCTestCase { XCTAssertTrue(indices.contains(8)) XCTAssertTrue(indices.contains(9)) } - - func testMatchCharacterIndicesPreferStitchedWordsOverSingleEditPrefix() { - let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices( - query: "wunr", - candidate: "Mark Workspace as Unread" - ) - - XCTAssertTrue(indices.contains(5)) - XCTAssertTrue(indices.contains(18)) - XCTAssertTrue(indices.contains(19)) - XCTAssertTrue(indices.contains(20)) - } } final class CommandPaletteSwitcherSearchIndexerTests: XCTestCase { diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index af2a1a8a9fda..ad677fe742da 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -2186,84 +2186,6 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } - func testCodexPromptSubmitRetiresPreviousMonitorLeaseForSameSession() throws { - let cliPath = try bundledCLIPath() - let socketPath = makeSocketPath("codex") - let listenerFD = try bindUnixSocket(at: socketPath) - let state = MockSocketServerState() - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-codex-monitor-leases-\(UUID().uuidString)", isDirectory: true) - let workspaceId = "11111111-1111-1111-1111-111111111111" - let surfaceId = "22222222-2222-2222-2222-222222222222" - let sessionId = "codex-session-lease-dedupe" - - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: root) - Darwin.close(listenerFD) - unlink(socketPath) - } - - startMockServerAccepting(listenerFD: listenerFD, state: state, connectionLimit: 6) { line in - guard let data = line.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let id = payload["id"] as? String else { - return "OK" - } - return self.v2Response( - id: id, - ok: true, - result: ["surfaces": [["id": surfaceId, "ref": surfaceId, "focused": true]]] - ) - } - - var environment = ProcessInfo.processInfo.environment - environment["CMUX_SOCKET_PATH"] = socketPath - environment["CMUX_WORKSPACE_ID"] = workspaceId - environment["CMUX_SURFACE_ID"] = surfaceId - environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path - environment["CMUX_CLI_SENTRY_DISABLED"] = "1" - environment["CODEX_HOME"] = root.appendingPathComponent("codex-home", isDirectory: true).path - - let firstInput = """ - {"session_id":"\(sessionId)","turn_id":"turn-one","cwd":"\(root.path)","hook_event_name":"UserPromptSubmit","prompt":"first"} - """ - let firstResult = runProcess( - executablePath: cliPath, - arguments: ["hooks", "codex", "prompt-submit"], - environment: environment, - standardInput: firstInput, - timeout: 5 - ) - - XCTAssertFalse(firstResult.timedOut, firstResult.stderr) - XCTAssertEqual(firstResult.status, 0, firstResult.stderr) - XCTAssertEqual(firstResult.stdout, "{}\n") - XCTAssertTrue( - waitForCodexMonitorActiveLeaseTurns(in: root, expected: ["turn-one"], timeout: 3), - "Expected first prompt to leave one active monitor lease, saw \(codexMonitorActiveLeaseTurns(in: root))" - ) - - let secondInput = """ - {"session_id":"\(sessionId)","turn_id":"turn-two","cwd":"\(root.path)","hook_event_name":"UserPromptSubmit","prompt":"second"} - """ - let secondResult = runProcess( - executablePath: cliPath, - arguments: ["hooks", "codex", "prompt-submit"], - environment: environment, - standardInput: secondInput, - timeout: 5 - ) - - XCTAssertFalse(secondResult.timedOut, secondResult.stderr) - XCTAssertEqual(secondResult.status, 0, secondResult.stderr) - XCTAssertEqual(secondResult.stdout, "{}\n") - XCTAssertTrue( - waitForCodexMonitorActiveLeaseTurns(in: root, expected: ["turn-two"], timeout: 3), - "Expected a new turn to retire the prior Codex monitor lease, saw \(codexMonitorActiveLeaseTurns(in: root))" - ) - } - func testCodexHookStopTreatsCodexErrorInfoPayloadAsFailure() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -3603,55 +3525,6 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { return handled } - private func startMockServerAccepting( - listenerFD: Int32, - state: MockSocketServerState, - connectionLimit: Int, - handler: @escaping @Sendable (String) -> String - ) { - DispatchQueue.global(qos: .userInitiated).async { - var accepted = 0 - while accepted < connectionLimit { - var clientAddr = sockaddr_un() - var clientAddrLen = socklen_t(MemoryLayout.size) - let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) - } - } - if clientFD < 0 { - if errno == EINTR { continue } - return - } - accepted += 1 - - DispatchQueue.global(qos: .userInitiated).async { - defer { Darwin.close(clientFD) } - var pending = Data() - var buffer = [UInt8](repeating: 0, count: 4096) - - while true { - let count = Darwin.read(clientFD, &buffer, buffer.count) - if count < 0 { - if errno == EINTR { continue } - return - } - if count == 0 { return } - pending.append(buffer, count: count) - - while let newlineRange = pending.firstRange(of: Data([0x0A])) { - let lineData = pending.subdata(in: 0.. Bool { - let bytes = Array(string.utf8) - var offset = 0 - while offset < bytes.count { - let written = bytes.withUnsafeBytes { buffer in - Darwin.write(fd, buffer.baseAddress!.advanced(by: offset), bytes.count - offset) - } - if written > 0 { - offset += written - continue - } - if written == 0 { - return false - } - if errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK { - continue - } - return false - } - return true - } - private func v2Response( id: String, ok: Bool, @@ -3737,41 +3591,6 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { return String(data: data ?? Data("{}".utf8), encoding: .utf8) ?? "{}" } - private func codexMonitorActiveLeaseTurns(in root: URL) -> [String] { - let directory = root.appendingPathComponent("codex-monitor-leases", isDirectory: true) - let urls = (try? FileManager.default.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - )) ?? [] - - return urls.compactMap { url -> String? in - guard let data = try? Data(contentsOf: url), - let lease = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { - return nil - } - if let retiredAt = lease["retiredAt"], !(retiredAt is NSNull) { - return nil - } - return lease["turnId"] as? String - }.sorted() - } - - private func waitForCodexMonitorActiveLeaseTurns( - in root: URL, - expected: [String], - timeout: TimeInterval - ) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if codexMonitorActiveLeaseTurns(in: root) == expected.sorted() { - return true - } - _ = DispatchSemaphore(value: 0).wait(timeout: .now() + 0.05) - } - return codexMonitorActiveLeaseTurns(in: root) == expected.sorted() - } - @MainActor func testNotifyWithWorkspaceHandleKeepsCallerSurfaceFallback() throws { let cliPath = try bundledCLIPath() diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index f4b8fc84fe6c..e5619a064c77 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -363,6 +363,22 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { XCTAssertFalse(shortcut.control) } + func testToggleTerminalSidekickShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.toggleTerminalSidekick.label, "Toggle Terminal Sidekick") + XCTAssertEqual( + KeyboardShortcutSettings.Action.toggleTerminalSidekick.defaultsKey, + "shortcut.toggleTerminalSidekick" + ) + + let shortcut = KeyboardShortcutSettings.Action.toggleTerminalSidekick.defaultShortcut + XCTAssertEqual(shortcut.key, "b") + XCTAssertTrue(shortcut.command) + XCTAssertFalse(shortcut.shift) + XCTAssertTrue(shortcut.option) + XCTAssertFalse(shortcut.control) + XCTAssertTrue(KeyboardShortcutSettings.settingsVisibleActions.contains(.toggleTerminalSidekick)) + } + func testRightSidebarAndFindShortcutDefaultsMatchSettingsSurface() { XCTAssertEqual( KeyboardShortcutSettings.Action.focusRightSidebar.label, @@ -422,23 +438,10 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { XCTAssertTrue(visibleActions.contains(.toggleRightSidebar)) XCTAssertTrue(visibleActions.contains(.focusRightSidebar)) XCTAssertTrue(visibleActions.contains(.findInDirectory)) - XCTAssertTrue(visibleActions.contains(.toggleUnread)) XCTAssertTrue(visibleActions.contains(.markOldestUnreadAndJumpNext)) XCTAssertFalse(visibleActions.contains(.showHideAllWindows)) } - func testToggleUnreadUsesConfigurableCommandOptionUDefault() { - let shortcut = KeyboardShortcutSettings.Action.toggleUnread.defaultShortcut - - XCTAssertEqual(shortcut.key, "u") - XCTAssertTrue(shortcut.command) - XCTAssertFalse(shortcut.shift) - XCTAssertTrue(shortcut.option) - XCTAssertFalse(shortcut.control) - XCTAssertTrue(KeyboardShortcutSettings.publicShortcutActions.contains(.toggleUnread)) - XCTAssertTrue(KeyboardShortcutSettings.settingsVisibleActions.contains(.toggleUnread)) - } - func testMarkOldestUnreadAndJumpNextUsesConfigurableCommandControlUDefault() { let shortcut = KeyboardShortcutSettings.Action.markOldestUnreadAndJumpNext.defaultShortcut @@ -2533,24 +2536,6 @@ final class WorkspaceCreationWorkingDirectoryInheritanceTests: XCTestCase { } } - func testExplicitNoInheritanceLeavesNewWorkspaceCwdUnsetWhenGlobalInheritanceEnabled() throws { - try withWorkspaceWorkingDirectoryInheritanceSetting(nil) { - let sourceCwd = "/tmp/cmux-source-\(UUID().uuidString)" - let manager = TabManager( - initialWorkingDirectory: sourceCwd, - autoWelcomeIfNeeded: false - ) - - let inserted = manager.addWorkspace( - inheritWorkingDirectory: false, - autoWelcomeIfNeeded: false - ) - - XCTAssertNil(inserted.focusedTerminalPanel?.requestedWorkingDirectory) - XCTAssertNotEqual(inserted.currentDirectory, sourceCwd) - } - } - func testExplicitWorkspaceWorkingDirectoryWinsWhenInheritanceIsDisabled() throws { try withWorkspaceWorkingDirectoryInheritanceSetting(false) { let sourceCwd = "/tmp/cmux-source-\(UUID().uuidString)" @@ -4059,6 +4044,7 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { XCTFail("Expected split terminal panels") return } + let window = makeWindow() defer { window.orderOut(nil) } guard let contentView = window.contentView else { @@ -4098,10 +4084,7 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { leftPanel.hostedView.setActive(true) rightPanel.surface.setFocus(false) rightPanel.hostedView.setActive(false) - rightPanel.hostedView.suppressReparentFocus() -#if DEBUG - XCTAssertTrue(rightPanel.hostedView.debugIsSuppressingReparentFocusForTesting()) -#endif + rightPanel.hostedView.beginSuppressingReparentFocus() guard let rightSurfaceView = surfaceView(in: rightPanel.hostedView) else { XCTFail("Expected right terminal surface view") @@ -4112,12 +4095,6 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window) rightSurfaceView.mouseDown(with: event) RunLoop.current.run(until: Date().addingTimeInterval(0.05)) -#if DEBUG - XCTAssertFalse( - rightPanel.hostedView.debugIsSuppressingReparentFocusForTesting(), - "Explicit pointer focus should clear reparent-only focus suppression" - ) -#endif XCTAssertFalse( leftPanel.hostedView.debugRenderStats().isActive, @@ -4142,8 +4119,6 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { XCTFail("Expected split terminal panels") return } - workspace.focusPanel(leftPanel.id, trigger: .terminalFirstResponder) - XCTAssertEqual(workspace.focusedPanelId, leftPanel.id) let window = makeWindow() defer { window.orderOut(nil) } @@ -4169,51 +4144,26 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { return } - window.makeFirstResponder(nil) leftPanel.surface.setFocus(false) rightPanel.surface.setFocus(true) - leftPanel.hostedView.suppressReparentFocus() + leftPanel.hostedView.beginSuppressingReparentFocus() - window.orderFrontRegardless() - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() XCTAssertTrue(window.makeFirstResponder(leftSurfaceView)) - XCTAssertTrue(leftPanel.hostedView.isSurfaceViewFirstResponder()) - XCTAssertTrue(leftPanel.hostedView.debugRenderStats().desiredFocus) - XCTAssertTrue(leftPanel.hostedView.debugPortalVisibleInUI) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) XCTAssertFalse( leftPanel.surface.debugDesiredFocusState(), "Suppressed reparent focus should not immediately flip the Ghostty focus bit" ) - leftPanel.hostedView.clearSuppressReparentFocus() - XCTAssertTrue(leftPanel.surface.debugDesiredFocusState()) -#else - throw XCTSkip("Debug-only regression test") -#endif - } - - func testLayoutFollowUpClearsPendingReparentSuppressionWithoutResponderEvent() throws { -#if DEBUG - let workspace = Workspace() - guard let panelId = workspace.focusedPanelId, - let panel = workspace.terminalPanel(for: panelId) else { - XCTFail("Expected initial terminal panel") - return - } - - workspace.debugBeginReparentFocusSuppressionForTesting( - panel.hostedView, - reason: "workspace.testReparentSuppression" + leftPanel.hostedView.endSuppressingReparentFocus() + let focusRecovered = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + leftPanel.surface.debugDesiredFocusState() + }, + object: NSObject() ) - XCTAssertTrue(workspace.debugHasPendingReparentFocusSuppressionsForTesting()) - XCTAssertTrue(panel.hostedView.debugIsSuppressingReparentFocusForTesting()) - - workspace.debugAttemptEventDrivenLayoutFollowUpForTesting() - - XCTAssertFalse(workspace.debugHasPendingReparentFocusSuppressionsForTesting()) - XCTAssertFalse(panel.hostedView.debugIsSuppressingReparentFocusForTesting()) + wait(for: [focusRecovered], timeout: 1.0) #else throw XCTSkip("Debug-only regression test") #endif @@ -4568,26 +4518,6 @@ final class WorkspacePanelGitBranchTests: XCTestCase { wait(for: [expectation], timeout: 1.0) } - private func rootSplit(in workspace: Workspace) throws -> ExternalSplitNode { - switch workspace.bonsplitController.treeSnapshot() { - case .split(let split): - return split - case .pane: - let split: ExternalSplitNode? = nil - return try XCTUnwrap(split, "Expected workspace root to be a split") - } - } - - private func paneId(in node: ExternalTreeNode) throws -> String { - switch node { - case .pane(let pane): - return pane.id - case .split: - let paneId: String? = nil - return try XCTUnwrap(paneId, "Expected split child to be a pane") - } - } - func testBrowserSplitWithFocusFalsePreservesOriginalFocusedPanel() { let workspace = Workspace() guard let originalFocusedPanelId = workspace.focusedPanelId else { @@ -5043,634 +4973,6 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual(workspace.gitBranch?.isDirty, false) } - func testForkAgentConversationToRightCreatesRightSplitWithForkStartupInput() throws { - let workspace = Workspace() - let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) - let sourcePanel = try XCTUnwrap(workspace.terminalPanel(for: sourcePanelId)) - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: "/tmp/fork repo", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: [ - "/Users/example/.bun/bin/codex", - "--model", - "gpt-5.4", - "--search", - "initial prompt should not replay" - ], - workingDirectory: "/tmp/fork repo", - environment: ["CODEX_HOME": "/tmp/codex"], - capturedAt: 123, - source: "process" - ) - ) - - let forkPanel = try XCTUnwrap( - workspace.forkAgentConversation( - fromPanelId: sourcePanelId, - snapshot: snapshot, - direction: .right - ) - ) - - XCTAssertNotEqual(forkPanel.id, sourcePanelId) - XCTAssertEqual(workspace.terminalPanel(for: sourcePanelId)?.id, sourcePanel.id) - XCTAssertEqual(workspace.bonsplitController.allPaneIds.count, 2) - XCTAssertEqual(workspace.focusedPanelId, forkPanel.id) - XCTAssertEqual(forkPanel.requestedWorkingDirectory, "/tmp/fork repo") - XCTAssertEqual(forkPanel.surface.initialInput, snapshot.forkCommand.map { $0 + "\n" }) - let split = try rootSplit(in: workspace) - let sourcePaneId = try XCTUnwrap(workspace.paneId(forPanelId: sourcePanelId)).id.uuidString - let forkPaneId = try XCTUnwrap(workspace.paneId(forPanelId: forkPanel.id)).id.uuidString - XCTAssertEqual(split.orientation, "horizontal") - XCTAssertEqual(try paneId(in: split.first), sourcePaneId) - XCTAssertEqual(try paneId(in: split.second), forkPaneId) - } - - func testForkAgentConversationSupportsAllSplitDirections() throws { - for direction in [SplitDirection.left, .right, .up, .down] { - let workspace = Workspace() - let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: "/tmp/fork repo", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: ["/Users/example/.bun/bin/codex", "--search"], - workingDirectory: "/tmp/fork repo", - environment: nil, - capturedAt: 123, - source: "process" - ) - ) - - let forkPanel = try XCTUnwrap( - workspace.forkAgentConversation( - fromPanelId: sourcePanelId, - snapshot: snapshot, - direction: direction - ) - ) - - XCTAssertNotEqual(forkPanel.id, sourcePanelId) - XCTAssertEqual(workspace.bonsplitController.allPaneIds.count, 2) - XCTAssertEqual(workspace.focusedPanelId, forkPanel.id) - XCTAssertEqual(forkPanel.requestedWorkingDirectory, "/tmp/fork repo") - XCTAssertEqual(forkPanel.surface.initialInput, snapshot.forkCommand.map { $0 + "\n" }) - let split = try rootSplit(in: workspace) - let sourcePaneId = try XCTUnwrap(workspace.paneId(forPanelId: sourcePanelId)).id.uuidString - let forkPaneId = try XCTUnwrap(workspace.paneId(forPanelId: forkPanel.id)).id.uuidString - XCTAssertEqual(split.orientation, direction.isHorizontal ? "horizontal" : "vertical") - XCTAssertEqual( - try paneId(in: split.first), - direction.insertFirst ? forkPaneId : sourcePaneId - ) - XCTAssertEqual( - try paneId(in: split.second), - direction.insertFirst ? sourcePaneId : forkPaneId - ) - } - } - - func testForkAgentConversationUsesWorkspaceDirectoryFallback() throws { - let workspace = Workspace() - workspace.currentDirectory = "/tmp/workspace fork repo" - let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: ["/Users/example/.bun/bin/codex"], - workingDirectory: nil, - environment: nil, - capturedAt: 123, - source: "process" - ) - ) - - let forkPanel = try XCTUnwrap( - workspace.forkAgentConversation( - fromPanelId: sourcePanelId, - snapshot: snapshot, - direction: .right - ) - ) - - XCTAssertEqual(forkPanel.requestedWorkingDirectory, "/tmp/workspace fork repo") - XCTAssertEqual( - forkPanel.surface.initialInput, - "cd '/tmp/workspace fork repo' && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" - ) - } - - func testForkAgentConversationInRemoteWorkspaceUsesRemoteStartupCommand() throws { - let workspace = Workspace() - workspace.configureRemoteConnection( - WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64000, - relayID: "relay-fork", - relayToken: String(repeating: "a", count: 64), - localSocketPath: "/tmp/cmux-fork-remote.sock", - terminalStartupCommand: "ssh cmux-macmini" - ), - autoConnect: false - ) - let initialRemoteSessionCount = workspace.activeRemoteTerminalSessionCount - XCTAssertEqual(initialRemoteSessionCount, 1) - let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: "/Users/cmux/project", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: ["/Users/example/.bun/bin/codex"], - workingDirectory: "/Users/cmux/project", - environment: nil, - capturedAt: 123, - source: "process" - ) - ) - - let forkPanel = try XCTUnwrap( - workspace.forkAgentConversation( - fromPanelId: sourcePanelId, - snapshot: snapshot, - direction: .right - ) - ) - - XCTAssertEqual(forkPanel.surface.debugInitialCommand(), "ssh cmux-macmini") - XCTAssertNil(forkPanel.requestedWorkingDirectory) - XCTAssertEqual(workspace.panelDirectories[forkPanel.id], "/Users/cmux/project") - XCTAssertEqual(forkPanel.surface.initialInput, snapshot.forkCommand.map { $0 + "\n" }) - XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, initialRemoteSessionCount + 1) - } - - func testForkAgentConversationInRemoteWorkspaceUsesFallbackDirectoryInForkCommand() throws { - let workspace = Workspace() - workspace.configureRemoteConnection( - WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64000, - relayID: "relay-fork-fallback", - relayToken: String(repeating: "a", count: 64), - localSocketPath: "/tmp/cmux-fork-fallback-remote.sock", - terminalStartupCommand: "ssh cmux-macmini" - ), - autoConnect: false - ) - workspace.currentDirectory = "/Users/cmux/fallback repo" - let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: ["/Users/example/.bun/bin/codex"], - workingDirectory: nil, - environment: nil, - capturedAt: 123, - source: "process" - ) - ) - - let forkPanel = try XCTUnwrap( - workspace.forkAgentConversation( - fromPanelId: sourcePanelId, - snapshot: snapshot, - direction: .right - ) - ) - - XCTAssertEqual(forkPanel.surface.debugInitialCommand(), "ssh cmux-macmini") - XCTAssertNil(forkPanel.requestedWorkingDirectory) - XCTAssertEqual(workspace.panelDirectories[forkPanel.id], "/Users/cmux/fallback repo") - XCTAssertEqual( - forkPanel.surface.initialInput, - "cd '/Users/cmux/fallback repo' && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" - ) - } - - func testSessionIndexRemoteSplitDoesNotInjectRemoteStartupCommand() throws { - let fileManager = FileManager.default - let hookStateRoot = fileManager.temporaryDirectory - .appendingPathComponent("cmux-session-drop-hook-state-\(UUID().uuidString)", isDirectory: true) - try fileManager.createDirectory(at: hookStateRoot, withIntermediateDirectories: true) - let previousHookStateDir = getenv("CMUX_AGENT_HOOK_STATE_DIR").map { String(cString: $0) } - setenv("CMUX_AGENT_HOOK_STATE_DIR", hookStateRoot.path, 1) - defer { - if let previousHookStateDir { - setenv("CMUX_AGENT_HOOK_STATE_DIR", previousHookStateDir, 1) - } else { - unsetenv("CMUX_AGENT_HOOK_STATE_DIR") - } - try? fileManager.removeItem(at: hookStateRoot) - } - - let workspace = Workspace() - workspace.configureRemoteConnection( - WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64000, - relayID: "relay-session-drop", - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/cmux-session-drop-remote.sock", - terminalStartupCommand: "ssh cmux-macmini" - ), - autoConnect: false - ) - let initialRemoteSessionCount = workspace.activeRemoteTerminalSessionCount - XCTAssertEqual(initialRemoteSessionCount, 1) - let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) - let initialInput = "codex resume session-drop\n" - - let splitPanel = try XCTUnwrap( - workspace.splitPaneWithNewTerminal( - targetPane: paneId, - orientation: .horizontal, - insertFirst: false, - workingDirectory: "/Users/cmux/project", - initialInput: initialInput - ) - ) - - XCTAssertNil(splitPanel.surface.debugInitialCommand()) - XCTAssertEqual(splitPanel.surface.initialInput, initialInput) - XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, initialRemoteSessionCount) - } - - func testForkAgentWorkspaceLaunchInRemoteWorkspacePreservesRemoteContext() throws { - let workspace = Workspace() - workspace.configureRemoteConnection( - WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: 2222, - identityFile: "/Users/example/.ssh/cmux", - sshOptions: ["ServerAliveInterval=30"], - localProxyPort: nil, - relayPort: 64000, - relayID: "relay-fork", - relayToken: String(repeating: "a", count: 64), - localSocketPath: "/tmp/cmux-fork-remote.sock", - terminalStartupCommand: "ssh -p 2222 -i /Users/example/.ssh/cmux -o ServerAliveInterval=30 -tt cmux-macmini" - ), - autoConnect: false - ) - let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: "/Users/cmux/project", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: ["/Users/example/.bun/bin/codex"], - workingDirectory: "/Users/cmux/project", - environment: nil, - capturedAt: 123, - source: "process" - ) - ) - - let launch = try XCTUnwrap( - workspace.forkAgentWorkspaceLaunch( - fromPanelId: sourcePanelId, - snapshot: snapshot - ) - ) - - XCTAssertEqual(launch.workingDirectory, "/Users/cmux/project") - XCTAssertNil(launch.terminalWorkingDirectory) - XCTAssertEqual( - launch.initialTerminalCommand, - "ssh -p 2222 -i /Users/example/.ssh/cmux -o ServerAliveInterval=30 -tt cmux-macmini" - ) - XCTAssertEqual(launch.initialTerminalInput, snapshot.forkCommand.map { $0 + "\n" }) - XCTAssertTrue(launch.autoConnectRemoteConfiguration) - XCTAssertEqual(launch.remoteConfiguration?.destination, "cmux-macmini") - XCTAssertEqual(launch.remoteConfiguration?.port, 2222) - XCTAssertEqual(launch.remoteConfiguration?.identityFile, "/Users/example/.ssh/cmux") - XCTAssertEqual(launch.remoteConfiguration?.sshOptions, ["ServerAliveInterval=30"]) - XCTAssertNil(launch.remoteConfiguration?.relayPort) - XCTAssertNil(launch.remoteConfiguration?.localSocketPath) - } - - func testForkAgentWorkspaceLaunchInRemoteWorkspaceUsesFallbackDirectoryInForkCommand() throws { - let workspace = Workspace() - workspace.configureRemoteConnection( - WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64000, - relayID: "relay-workspace-fallback", - relayToken: String(repeating: "a", count: 64), - localSocketPath: "/tmp/cmux-workspace-fallback-remote.sock", - terminalStartupCommand: "ssh cmux-macmini" - ), - autoConnect: false - ) - workspace.currentDirectory = "/Users/cmux/fallback repo" - let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: ["/Users/example/.bun/bin/codex"], - workingDirectory: nil, - environment: nil, - capturedAt: 123, - source: "process" - ) - ) - - let launch = try XCTUnwrap( - workspace.forkAgentWorkspaceLaunch( - fromPanelId: sourcePanelId, - snapshot: snapshot - ) - ) - - XCTAssertEqual(launch.workingDirectory, "/Users/cmux/fallback repo") - XCTAssertNil(launch.terminalWorkingDirectory) - XCTAssertEqual(launch.initialTerminalCommand, "ssh -tt cmux-macmini") - XCTAssertEqual( - launch.initialTerminalInput, - "cd '/Users/cmux/fallback repo' && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" - ) - } - - func testForkAgentWorkspaceLaunchInLocalWorkspaceUsesLocalTerminalWorkingDirectory() throws { - let workspace = Workspace() - workspace.currentDirectory = "/tmp/local fork repo" - let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: ["/Users/example/.bun/bin/codex"], - workingDirectory: nil, - environment: nil, - capturedAt: 123, - source: "process" - ) - ) - - let launch = try XCTUnwrap( - workspace.forkAgentWorkspaceLaunch( - fromPanelId: sourcePanelId, - snapshot: snapshot - ) - ) - - XCTAssertEqual(launch.workingDirectory, "/tmp/local fork repo") - XCTAssertEqual(launch.terminalWorkingDirectory, "/tmp/local fork repo") - XCTAssertNil(launch.initialTerminalCommand) - XCTAssertFalse(launch.autoConnectRemoteConfiguration) - XCTAssertNil(launch.remoteConfiguration) - XCTAssertEqual( - launch.initialTerminalInput, - "cd '/tmp/local fork repo' && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" - ) - } - - func testForkAgentConversationInRemoteConfiguredLocalWorkspaceAllowsLauncherScript() throws { - let workspace = Workspace() - workspace.configureRemoteConnection( - WorkspaceRemoteConfiguration( - transport: .websocket, - destination: "cloud-vm", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: 54321, - relayPort: nil, - relayID: nil, - relayToken: nil, - localSocketPath: nil, - terminalStartupCommand: nil - ), - autoConnect: false - ) - let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) - let longPath = "/Users/cmux/" + String(repeating: "nested-project-", count: 120) - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: "/Users/cmux/project", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: [ - "/Users/example/.bun/bin/codex", - "--model", - "gpt-5.4", - "--add-dir", - longPath - ], - workingDirectory: "/Users/cmux/project", - environment: nil, - capturedAt: 123, - source: "process" - ) - ) - - XCTAssertGreaterThan( - (snapshot.forkCommand.map { $0 + "\n" } ?? "").utf8.count, - SessionRestorableAgentSnapshot.maxInlineStartupInputBytes - ) - let forkPanel = try XCTUnwrap( - workspace.forkAgentConversation( - fromPanelId: sourcePanelId, - snapshot: snapshot, - direction: .right - ) - ) - XCTAssertNil(forkPanel.surface.debugInitialCommand()) - XCTAssertEqual(forkPanel.requestedWorkingDirectory, "/Users/cmux/project") - XCTAssertTrue(forkPanel.surface.initialInput?.hasPrefix("/bin/zsh ") == true) - - let launch = try XCTUnwrap( - workspace.forkAgentWorkspaceLaunch( - fromPanelId: sourcePanelId, - snapshot: snapshot - ) - ) - XCTAssertEqual(launch.terminalWorkingDirectory, "/Users/cmux/project") - XCTAssertNil(launch.initialTerminalCommand) - XCTAssertFalse(launch.autoConnectRemoteConfiguration) - XCTAssertNil(launch.remoteConfiguration) - XCTAssertTrue(launch.initialTerminalInput.hasPrefix("/bin/zsh ")) - } - - func testForkAgentConversationFromLocalTerminalInRemoteWorkspaceStaysLocal() throws { - let workspace = Workspace() - workspace.configureRemoteConnection( - WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64000, - relayID: "relay-fork-local", - relayToken: String(repeating: "a", count: 64), - localSocketPath: "/tmp/cmux-fork-local-remote.sock", - terminalStartupCommand: "ssh cmux-macmini" - ), - autoConnect: false - ) - let initialRemoteSessionCount = workspace.activeRemoteTerminalSessionCount - let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) - let localPanel = try XCTUnwrap( - workspace.splitPaneWithNewTerminal( - targetPane: paneId, - orientation: .horizontal, - insertFirst: false, - workingDirectory: "/tmp/local project", - initialInput: nil - ) - ) - let longPath = "/tmp/local/" + String(repeating: "nested-project-", count: 120) - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: "/tmp/local project", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: [ - "/Users/example/.bun/bin/codex", - "--model", - "gpt-5.4", - "--add-dir", - longPath - ], - workingDirectory: "/tmp/local project", - environment: nil, - capturedAt: 123, - source: "process" - ) - ) - - let forkPanel = try XCTUnwrap( - workspace.forkAgentConversation( - fromPanelId: localPanel.id, - snapshot: snapshot, - direction: .right - ) - ) - XCTAssertNil(forkPanel.surface.debugInitialCommand()) - XCTAssertEqual(forkPanel.requestedWorkingDirectory, "/tmp/local project") - XCTAssertTrue(forkPanel.surface.initialInput?.hasPrefix("/bin/zsh ") == true) - XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, initialRemoteSessionCount) - - let launch = try XCTUnwrap( - workspace.forkAgentWorkspaceLaunch( - fromPanelId: localPanel.id, - snapshot: snapshot - ) - ) - XCTAssertEqual(launch.terminalWorkingDirectory, "/tmp/local project") - XCTAssertNil(launch.initialTerminalCommand) - XCTAssertFalse(launch.autoConnectRemoteConfiguration) - XCTAssertNil(launch.remoteConfiguration) - XCTAssertTrue(launch.initialTerminalInput.hasPrefix("/bin/zsh ")) - } - - func testForkAgentConversationInRemoteWorkspaceRejectsLocalLauncherScriptFallback() throws { - let workspace = Workspace() - workspace.configureRemoteConnection( - WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64000, - relayID: "relay-fork", - relayToken: String(repeating: "a", count: 64), - localSocketPath: "/tmp/cmux-fork-remote.sock", - terminalStartupCommand: "ssh cmux-macmini" - ), - autoConnect: false - ) - let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) - let longPath = "/Users/cmux/" + String(repeating: "nested-project-", count: 120) - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: "/Users/cmux/project", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: [ - "/Users/example/.bun/bin/codex", - "--model", - "gpt-5.4", - "--add-dir", - longPath - ], - workingDirectory: "/Users/cmux/project", - environment: nil, - capturedAt: 123, - source: "process" - ) - ) - - XCTAssertGreaterThan( - (snapshot.forkCommand.map { $0 + "\n" } ?? "").utf8.count, - SessionRestorableAgentSnapshot.maxInlineStartupInputBytes - ) - XCTAssertNil(snapshot.forkStartupInput(allowLauncherScript: false)) - XCTAssertNil( - workspace.forkAgentConversation( - fromPanelId: sourcePanelId, - snapshot: snapshot, - direction: .right - ) - ) - XCTAssertNil( - workspace.forkAgentWorkspaceLaunch( - fromPanelId: sourcePanelId, - snapshot: snapshot - ) - ) - } - func testSidebarGitBranchesFollowLeftToRightSplitOrder() { let workspace = Workspace() guard let leftPanelId = workspace.focusedPanelId else { diff --git a/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift b/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift index c8e6e34f1adf..e6407a255877 100644 --- a/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift +++ b/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift @@ -19,7 +19,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { RunLoop.current.run(until: Date().addingTimeInterval(0.5)) } - func testOmnibarSuggestionsAlignToPillAndCtrlNP() { + func testOmnibarSuggestionsAlignToPillAndCmdNP() { seedBrowserHistoryForTest(seedEntries: [ SeedEntry(url: "https://example.com/", title: "Example Domain", visitCount: 12, typedCount: 4), SeedEntry(url: "https://example.org/", title: "Example Organization", visitCount: 9, typedCount: 3), @@ -78,20 +78,20 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { ) // Row 0 should be the autocompletable example.com history entry. - // Verify Ctrl+N moves to row 1, Ctrl+P returns to row 0, then Enter navigates. + // Verify Cmd+N moves to row 1, Cmd+P returns to row 0, then Enter navigates. let row1 = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.1").firstMatch XCTAssertTrue(row1.waitForExistence(timeout: 6.0)) - app.typeKey("n", modifierFlags: [.control]) + app.typeKey("n", modifierFlags: [.command]) XCTAssertTrue( waitForSuggestionRowToBeSelected(row1, timeout: 3.0), - "Expected Ctrl+N to move selection to row 1. row1Value=\(String(describing: row1.value))" + "Expected Cmd+N to move selection to row 1. row1Value=\(String(describing: row1.value))" ) - app.typeKey("p", modifierFlags: [.control]) + app.typeKey("p", modifierFlags: [.command]) XCTAssertTrue( waitForSuggestionRowToBeSelected(row0, timeout: 3.0), - "Expected Ctrl+P to move selection back to row 0. row0Value=\(String(describing: row0.value))" + "Expected Cmd+P to move selection back to row 0. row0Value=\(String(describing: row0.value))" ) app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) @@ -187,7 +187,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { XCTAssertEqual(afterOutsideTyping, beforeOutsideTyping, "Expected typing after click-outside to not modify omnibar (blurred)") } - func testOmnibarSuggestionsCtrlNPWhenAddressBarFocused() { + func testOmnibarSuggestionsCmdNPWhenAddressBarFocused() { seedBrowserHistoryForTest() let app = XCUIApplication() @@ -213,22 +213,22 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { XCTAssertTrue(row1.waitForExistence(timeout: 6.0)) XCTAssertTrue(row2.waitForExistence(timeout: 6.0)) - app.typeKey("n", modifierFlags: [.control]) + app.typeKey("n", modifierFlags: [.command]) XCTAssertTrue( waitForSuggestionRowToBeSelected(row1, timeout: 3.0), - "Expected Ctrl+N to move selection to row 1. row1Value=\(String(describing: row1.value))" + "Expected Cmd+N to move selection to row 1. row1Value=\(String(describing: row1.value))" ) - app.typeKey("n", modifierFlags: [.control]) + app.typeKey("n", modifierFlags: [.command]) XCTAssertTrue( waitForSuggestionRowToBeSelected(row2, timeout: 3.0), - "Expected repeated Ctrl+N to move selection to row 2. row2Value=\(String(describing: row2.value))" + "Expected repeated Cmd+N to move selection to row 2. row2Value=\(String(describing: row2.value))" ) - app.typeKey("p", modifierFlags: [.control]) + app.typeKey("p", modifierFlags: [.command]) XCTAssertTrue( waitForSuggestionRowToBeSelected(row1, timeout: 3.0), - "Expected Ctrl+P to move selection back to row 1. row1Value=\(String(describing: row1.value))" + "Expected Cmd+P to move selection back to row 1. row1Value=\(String(describing: row1.value))" ) } @@ -403,11 +403,11 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { if gmailRowIndex > 0 { let gmailRow = rows[gmailRowIndex] for _ in 0.. - `cmux --help` -> `cmux - control cmux via Unix socket` -- `cmux --help` -> `open ...` - `cmux help` -> `cmux - control cmux via Unix socket` - `cmux ping --help` -> `Usage: cmux ping` - `cmux capabilities --help` -> `Usage: cmux capabilities` @@ -395,7 +390,6 @@ the expected text without connecting to a cmux socket. - `cmux enable-browser --help` -> `Usage: cmux enable-browser [--json]` - `cmux browser-status --help` -> `Usage: cmux browser-status [--json]` - `cmux restore-session --help` -> `Usage: cmux restore-session` -- `cmux open --help` -> `Usage: cmux open ...` - `cmux feedback --help` -> `Usage: cmux feedback` - `cmux feed --help` -> `Usage: cmux feed tui [--opentui|--legacy]` - `cmux hooks --help` -> `Usage: cmux hooks setup [agent] [--agent ] [--yes|-y]` @@ -417,7 +411,6 @@ the expected text without connecting to a cmux socket. - `cmux reorder-workspace --help` -> `Usage: cmux reorder-workspace` - `cmux reorder-workspaces --help` -> `Usage: cmux reorder-workspaces` - `cmux workspace-action --help` -> `Usage: cmux workspace-action --action ` -- `cmux move-tab-to-new-workspace --help` -> `Usage: cmux move-tab-to-new-workspace` - `cmux tab-action --help` -> `Usage: cmux tab-action --action ` - `cmux rename-tab --help` -> `Usage: cmux rename-tab` - `cmux new-workspace --help` -> `Usage: cmux new-workspace` @@ -427,7 +420,6 @@ the expected text without connecting to a cmux socket. - `cmux list-panes --help` -> `Usage: cmux list-panes` - `cmux list-pane-surfaces --help` -> `Usage: cmux list-pane-surfaces` - `cmux tree --help` -> `Usage: cmux tree` -- `cmux top --help` -> `Usage: cmux top` - `cmux focus-pane --help` -> `Usage: cmux focus-pane` - `cmux new-pane --help` -> `Usage: cmux new-pane` - `cmux new-surface --help` -> `Usage: cmux new-surface` @@ -523,8 +515,6 @@ changes them: is handled before subcommand help dispatch. - `cmux claude-teams --help` is handled by the command launcher, not by the pre-socket help dispatcher. -- `cmux codex-teams --help` is handled by the command launcher, not by the - pre-socket help dispatcher. - `cmux remote-daemon-status --help` currently prints status because the command runs before subcommand help dispatch. diff --git a/scripts/build-command-palette-nucleo-ffi.sh b/scripts/build-command-palette-nucleo-ffi.sh deleted file mode 100755 index e40bcaf02e54..000000000000 --- a/scripts/build-command-palette-nucleo-ffi.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CRATE_DIR="${ROOT}/Native/CommandPaletteNucleoFFI" -LIB_NAME="libcmux_command_palette_nucleo_ffi.dylib" -BUILD_OUTPUT_DIR="${TARGET_BUILD_DIR:-${CRATE_DIR}/target}/cmux-nucleo-ffi" - -if ! command -v cargo >/dev/null 2>&1; then - case "${CMUX_NUCLEO_FFI_REQUIRE_CARGO:-${CI:-0}}" in - 1|true|TRUE|yes|YES) - echo "error: cargo is required to build ${LIB_NAME}" >&2 - exit 1 - ;; - esac - echo "warning: cargo not found; skipping optional ${LIB_NAME} build" >&2 - exit 0 -fi - -rust_target_for_arch() { - case "$1" in - arm64|arm64e) echo "aarch64-apple-darwin" ;; - x86_64) echo "x86_64-apple-darwin" ;; - *) - echo "error: unsupported Rust macOS arch $1" >&2 - return 1 - ;; - esac -} - -rust_target_for_triple() { - case "$1" in - aarch64-apple-darwin|x86_64-apple-darwin) echo "$1" ;; - arm64-*|arm64e-*|aarch64-*) echo "aarch64-apple-darwin" ;; - x86_64-*) echo "x86_64-apple-darwin" ;; - *) - echo "error: unsupported Rust macOS target triple $1" >&2 - return 1 - ;; - esac -} - -ensure_rust_target() { - local target="$1" - if command -v rustup >/dev/null 2>&1; then - if rustup target list --installed | grep -qx "$target"; then - return - fi - rustup target add "$target" - fi -} - -requested_targets="${CMUX_NUCLEO_FFI_TARGETS:-}" -if [ -z "${requested_targets}" ] && [ -n "${TARGET_TRIPLE:-}" ]; then - requested_targets="$(rust_target_for_triple "${TARGET_TRIPLE}")" -fi - -if [ -z "${requested_targets}" ]; then - requested_archs="${CMUX_NUCLEO_FFI_ARCHS:-${ARCHS:-}}" - if [ -z "${requested_archs}" ]; then - case "$(uname -m)" in - arm64|aarch64) requested_archs="arm64" ;; - x86_64) requested_archs="x86_64" ;; - *) - echo "error: cannot infer Rust macOS target for host arch $(uname -m)" >&2 - exit 1 - ;; - esac - fi - - for arch in ${requested_archs}; do - requested_targets="${requested_targets} $(rust_target_for_arch "$arch")" - done -fi - -mkdir -p "${BUILD_OUTPUT_DIR}" -libs=() -seen_targets="" -for target in ${requested_targets}; do - case " ${seen_targets} " in - *" ${target} "*) continue ;; - esac - seen_targets="${seen_targets} ${target}" - ensure_rust_target "$target" - cargo build --manifest-path "${CRATE_DIR}/Cargo.toml" --release --target "$target" - source_lib="${CRATE_DIR}/target/${target}/release/${LIB_NAME}" - if [ ! -f "${source_lib}" ]; then - echo "error: expected nucleo FFI library at ${source_lib}" >&2 - exit 1 - fi - libs+=("${source_lib}") -done - -if [ "${#libs[@]}" -eq 0 ]; then - echo "error: no Rust macOS architectures requested" >&2 - exit 1 -fi - -SOURCE_LIB="${BUILD_OUTPUT_DIR}/${LIB_NAME}" -if [ "${#libs[@]}" -eq 1 ]; then - rsync -a "${libs[0]}" "${SOURCE_LIB}" -else - lipo -create -output "${SOURCE_LIB}" "${libs[@]}" -fi - -/usr/bin/install_name_tool -id "@rpath/${LIB_NAME}" "${SOURCE_LIB}" - -if [ -z "${TARGET_BUILD_DIR:-}" ]; then - echo "built ${SOURCE_LIB}" - exit 0 -fi - -DEST_DIR="${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH:-${CONTENTS_FOLDER_PATH:-Contents}/Frameworks}" -DEST_LIB="${DEST_DIR}/${LIB_NAME}" -mkdir -p "${DEST_DIR}" -rsync -a "${SOURCE_LIB}" "${DEST_LIB}" - -if [ "${CODE_SIGNING_ALLOWED:-YES}" != "NO" ] && [ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" ]; then - codesign --force --sign "${EXPANDED_CODE_SIGN_IDENTITY}" "${DEST_LIB}" >/dev/null -fi diff --git a/scripts/install-rust-ci.sh b/scripts/install-rust-ci.sh deleted file mode 100755 index dbe784a831fb..000000000000 --- a/scripts/install-rust-ci.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export PATH="$HOME/.cargo/bin:$PATH" - -if ! command -v rustup >/dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --profile minimal --default-toolchain stable - export PATH="$HOME/.cargo/bin:$PATH" -fi - -if [ -n "${BASH_ENV:-}" ]; then - echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> "$BASH_ENV" -fi - -if [ -n "${GITHUB_PATH:-}" ]; then - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" -fi - -if ! rustup show active-toolchain >/dev/null 2>&1; then - rustup default stable -fi - -rustup target add aarch64-apple-darwin x86_64-apple-darwin -cargo --version -rustc --version diff --git a/scripts/sign-cmux-bundle.sh b/scripts/sign-cmux-bundle.sh index 4372622aad06..70c2c9aec2da 100755 --- a/scripts/sign-cmux-bundle.sh +++ b/scripts/sign-cmux-bundle.sh @@ -88,7 +88,6 @@ echo "==> signing main bundle" echo "==> verifying" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" -"$SCRIPT_DIR/verify-command-palette-nucleo-ffi-artifact.sh" "$APP_PATH" APP_ID="$(/usr/libexec/PlistBuddy -c "Print :com.apple.application-identifier" \ /dev/stdin <<<"$(plutil -convert xml1 -o - "$APP_ENTITLEMENTS")" 2>/dev/null || true)" diff --git a/scripts/test-command-palette-nucleo-ffi.sh b/scripts/test-command-palette-nucleo-ffi.sh deleted file mode 100755 index 277aaf8e5663..000000000000 --- a/scripts/test-command-palette-nucleo-ffi.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CRATE_DIR="${ROOT}/Native/CommandPaletteNucleoFFI" -DERIVED_DATA="${CMUX_NUCLEO_FFI_DERIVED_DATA:-/tmp/cmux-nucleo-ffi-unit}" -LOG_PATH="${CMUX_NUCLEO_FFI_LOG:-/tmp/cmux-nucleo-ffi-tests.log}" - -cargo build --manifest-path "${CRATE_DIR}/Cargo.toml" --release - -LIB_PATH="${CRATE_DIR}/target/release/libcmux_command_palette_nucleo_ffi.dylib" -if [ ! -f "${LIB_PATH}" ]; then - echo "error: expected nucleo FFI library at ${LIB_PATH}" >&2 - exit 1 -fi - -if [ "${CMUX_NUCLEO_FFI_CLEAN:-0}" = "1" ]; then - rm -rf "${DERIVED_DATA}" -fi -NSUnbufferedIO=YES CMUX_NUCLEO_FFI_LIB="${LIB_PATH}" \ - xcodebuild \ - -project "${ROOT}/cmux.xcodeproj" \ - -scheme cmux-unit \ - -configuration Debug \ - -destination 'platform=macOS' \ - -derivedDataPath "${DERIVED_DATA}" \ - -only-testing:cmuxTests/CommandPaletteNucleoFFITests \ - test | tee "${LOG_PATH}" - -if ! grep 'BENCH cmd+p nucleo-ffi' "${LOG_PATH}"; then - echo "error: CommandPaletteNucleoFFITests did not emit benchmark output" >&2 - exit 1 -fi diff --git a/scripts/verify-command-palette-nucleo-ffi-artifact.sh b/scripts/verify-command-palette-nucleo-ffi-artifact.sh deleted file mode 100755 index 94af4fc7bcc0..000000000000 --- a/scripts/verify-command-palette-nucleo-ffi-artifact.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ $# -ne 1 ]]; then - echo "usage: $0 " >&2 - exit 2 -fi - -TARGET="$1" -LIB_NAME="libcmux_command_palette_nucleo_ffi.dylib" - -if [[ -d "$TARGET/Contents" ]]; then - DYLIB="$TARGET/Contents/Frameworks/$LIB_NAME" -else - DYLIB="$TARGET" -fi - -if [[ ! -f "$DYLIB" ]]; then - echo "error: missing bundled Nucleo FFI library at $DYLIB" >&2 - exit 1 -fi - -install_names=() -while IFS= read -r install_name; do - install_names+=("$install_name") -done < <(/usr/bin/otool -D "$DYLIB" | awk 'NF && $0 !~ /:$/ { print $1 }') - -if [[ "${#install_names[@]}" -eq 0 ]]; then - echo "error: could not read install name from $DYLIB" >&2 - exit 1 -fi - -for install_name in "${install_names[@]}"; do - if [[ "$install_name" != "@rpath/$LIB_NAME" ]]; then - echo "error: $LIB_NAME has invalid install name: $install_name" >&2 - echo "expected: @rpath/$LIB_NAME" >&2 - exit 1 - fi -done - -if /usr/bin/otool -L "$DYLIB" | awk 'NR > 1 && NF { print $1 }' | grep -E '/Users/runner/work|/Native/CommandPaletteNucleoFFI/target|/target/(aarch64|x86_64)-apple-darwin/' >/dev/null; then - echo "error: $LIB_NAME contains CI/source-tree absolute load paths" >&2 - /usr/bin/otool -L "$DYLIB" >&2 - exit 1 -fi - -echo "Nucleo FFI artifact OK: $DYLIB" diff --git a/skills/cmux-customization/SKILL.md b/skills/cmux-customization/SKILL.md deleted file mode 100644 index 371499437cff..000000000000 --- a/skills/cmux-customization/SKILL.md +++ /dev/null @@ -1,234 +0,0 @@ ---- -name: cmux-customization -description: "Customize cmux for an end user. Use when changing cmux.json actions, custom commands, workspace layouts, plus-button behavior, surface tab bar buttons, Command Palette entries, Dock controls, sidebar and app settings, shortcuts, notifications, browser routing, examples-library presets, or Ghostty-backed terminal preferences." ---- - -# cmux Customization - -Use this skill for user-facing cmux customization. Keep the user's config intact, prefer schema-backed edits, and validate before reporting completion. - -## What Can Be Customized - -- Custom actions: define reusable `actions` in `cmux.json`. Actions can appear in Cmd+Shift+P, surface tab bars, shortcuts, and the plus-button right-click menu. -- New workspace button: set `ui.newWorkspace.action` to replace the normal plus-button click, and `ui.newWorkspace.contextMenu` to control right-click actions. `ui.newWorkspace.rightClick` is accepted as an alias, but new examples should use `contextMenu`. -- Surface tab bar buttons: set `ui.surfaceTabBar.buttons` to replace the default tab bar buttons. Include built-in IDs such as `cmux.newTerminal`, `cmux.newBrowser`, `cmux.splitRight`, and `cmux.splitDown` only when they should stay visible. -- Workflows and layouts: use `commands` with workspace definitions to open a worktree, multiple checkouts, local services, browser previews, or SSH sessions in a deliberate split layout. -- Dock controls: create `.cmux/dock.json` or `~/.config/cmux/dock.json` for right-sidebar terminal controls such as logs, test watchers, git TUIs, dev servers, queues, or `cmux feed tui --opentui`. -- Sidebar and app behavior: use `cmux-settings` for supported settings such as appearance, sidebar display, notification behavior, browser routing, automation, shortcuts, and new-workspace placement. -- Workspace metadata: use the cmux CLI or `cmux-workspace` for workspace names, descriptions, colors, read state, and sidebar metadata updates. -- Feed and notifications: use `cmux hooks setup` for Feed event sources, notification settings for delivery behavior, and notification hooks in `cmux.json` for filtering or post-processing banners. -- Team presets and examples: use project-local `.cmux/cmux.json` and `.cmux/dock.json` to share worktree, SSH, review, dev, CI, and docs workspace patterns with a repo. -- Import, export, and reset: back up the current config, apply the smallest diff, validate it, and keep a rollback path for user-owned customizations. -- Terminal behavior: use Ghostty config for fonts, themes, cursor style, copy-on-select, shell integration, terminal keybindings, and terminal rendering. - -## Choose the Right Surface - -- cmux app preferences: use `cmux-settings` for global `~/.config/cmux/cmux.json` settings such as appearance, sidebar, notifications, browser behavior, automation, and shortcuts. -- Custom actions, workspace layouts, tab bar buttons, plus-button behavior, and Command Palette entries: edit `~/.config/cmux/cmux.json` globally or `.cmux/cmux.json` in the project. Project-local actions and commands override global entries with the same ID or name. -- Dock controls: edit `.cmux/dock.json` in the project or `~/.config/cmux/dock.json` globally. Run `cmux docs dock` when available. -- Terminal rendering and terminal keybindings: use Ghostty config, usually `~/.config/ghostty/config`. This includes fonts, cursor style, copy-on-select, shell integration, themes, and terminal keybindings. -- Project-specific behavior: prefer `.cmux/cmux.json` in the project so actions, commands, UI action wiring, and notification hooks travel with the repo. Do not put global app preferences there. - -If a request can be handled by Ghostty config, say that and use Ghostty config instead of inventing cmux UI settings. - -## Examples Library - -For reusable patterns such as worktree agents, full-stack dev layouts, SSH -devboxes, PR review workspaces, docs workspaces, quick agent tab buttons, and -CI watches, read `references/examples.md`. Load it when the user asks for examples, presets, -templates, starter configs, or a known workflow shape. - -## Workflow - -1. Inspect existing config before editing. - - ```bash - test -f ~/.config/cmux/cmux.json && sed -n '1,220p' ~/.config/cmux/cmux.json - test -f .cmux/cmux.json && sed -n '1,220p' .cmux/cmux.json - ``` - -2. Pick global or project-local scope. Ask only when the choice changes behavior meaningfully. Default to project-local for repo-specific commands and global for app preferences. -3. Before editing, back up the target file when it already exists: - - ```bash - stamp="$(date +%Y%m%d-%H%M%S)" - test -f ~/.config/cmux/cmux.json && cp -p ~/.config/cmux/cmux.json ~/.config/cmux/cmux.json."$stamp".bak - test -f .cmux/cmux.json && cp -p .cmux/cmux.json .cmux/cmux.json."$stamp".bak - ``` - - Use the applicable path only. Do not create a backup for a missing file. -4. For app settings and cmux-owned shortcuts, use the settings helper from the installed skill or checkout: - - ```bash - ~/.agents/skills/cmux-settings/scripts/cmux-settings list-supported - ~/.agents/skills/cmux-settings/scripts/cmux-settings set browser.openTerminalLinksInCmuxBrowser true - ~/.agents/skills/cmux-settings/scripts/cmux-settings validate - ``` - - If the user installed with `skills.sh`, use `~/.codex/skills/cmux-settings/scripts/cmux-settings` instead. -5. For actions, UI wiring, workspace layouts, notification hooks, and Dock controls, edit JSONC or JSON carefully. Preserve unrelated sections such as `vault`, `rightSidebar`, `commands`, `actions`, `ui`, and `notifications`. -6. Reload config after successful edits: - - ```bash - cmux reload-config - ``` - -7. Verify the configured entrypoint exists. For shortcuts, read back the binding. For custom actions, confirm the action ID and where it should appear. - -## Common Patterns - -Add a Command Palette action that opens Codex in a new tab. It will appear in Cmd+Shift+P unless `palette` is false: - -```json -{ - "actions": { - "codex-new-tab": { - "type": "agent", - "agent": "codex", - "title": "Codex", - "subtitle": "Start Codex in this workspace", - "target": "newTabInCurrentPane", - "palette": true - } - } -} -``` - -Replace the plus-button click and define the plus-button right-click menu. -This is the pattern for "bring your own worktree, multiple checkouts, or SSH -setup". The `workspaceCommand` action ID is `worktree-agents`, and its -`commandName` must match a command named `Worktree Agents` in the same config: - -```json -{ - "actions": { - "worktree-agents": { - "type": "workspaceCommand", - "title": "Worktree Agents", - "commandName": "Worktree Agents", - "icon": { "type": "symbol", "name": "folder.badge.plus" } - } - }, - "ui": { - "newWorkspace": { - "action": "worktree-agents", - "contextMenu": [ - { "action": "worktree-agents", "title": "Worktree Agents" }, - { "type": "separator" }, - { "action": "cmux.newTerminal", "title": "New Terminal" }, - { "action": "cmux.newBrowser", "title": "New Browser" } - ] - } - }, - "commands": [ - { - "name": "Worktree Agents", - "description": "Create a worktree and open agents inside it", - "workspace": { - "name": "Worktree Agents", - "cwd": "../worktrees/my-feature", - "layout": { - "direction": "horizontal", - "children": [ - { - "pane": { - "surfaces": [ - { "type": "terminal", "name": "Codex", "command": "codex" } - ] - } - }, - { - "pane": { - "surfaces": [ - { "type": "terminal", "name": "SSH", "command": "ssh devbox" } - ] - } - } - ] - } - } - } - ] -} -``` - -Add a project workspace layout: - -```json -{ - "commands": [ - { - "name": "dev", - "workspace": { - "name": "Dev", - "cwd": ".", - "layout": { - "direction": "horizontal", - "children": [ - { "pane": { "surfaces": [{ "type": "terminal", "command": "bun dev" }] } }, - { "pane": { "surfaces": [{ "type": "browser", "url": "http://localhost:3000" }] } } - ] - } - } - } - ] -} -``` - -Replace surface tab bar buttons: - -```json -{ - "ui": { - "surfaceTabBar": { - "buttons": [ - "cmux.newTerminal", - "cmux.newBrowser", - { - "action": "codex-new-tab", - "title": "Codex", - "icon": { "type": "symbol", "name": "terminal" } - } - ] - } - } -} -``` - -Add project Dock controls: - -```json -{ - "controls": [ - { - "id": "git", - "title": "Git", - "command": "lazygit", - "cwd": ".", - "height": 300 - }, - { - "id": "feed", - "title": "Feed", - "command": "cmux feed tui --opentui", - "height": 260 - } - ] -} -``` - -## Validation - -- App settings: run `cmux-settings validate`. -- JSONC shape: keep valid JSONC and avoid duplicate keys. -- Dock JSON: parse `.cmux/dock.json` or `~/.config/cmux/dock.json` with a JSON parser before reporting completion. -- Runtime reload: run `cmux reload-config` when the CLI is available. -- User-facing action: confirm the action title, shortcut, plus-button behavior, context-menu entry, or tab bar placement the user asked for. - -## Rules - -- Do not overwrite whole top-level config sections unless you own the full section. -- Do not store secrets directly in actions, commands, or prompts. Use environment variables or the user's secret manager. -- Do not use app/runtime sleeps or timing workarounds in generated commands. -- Do not add a cmux setting for behavior Ghostty already owns. -- Keep labels short enough for menus, buttons, and the Command Palette. diff --git a/skills/cmux-customization/agents/openai.yaml b/skills/cmux-customization/agents/openai.yaml deleted file mode 100644 index 9098b80c85ed..000000000000 --- a/skills/cmux-customization/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "cmux Customization" - short_description: "Customize cmux actions, UI, layouts, and presets." - default_prompt: "Use $cmux-customization to pick an examples-library preset, wire it into cmux.json, and validate the cmux config." diff --git a/skills/cmux-customization/references/examples.md b/skills/cmux-customization/references/examples.md deleted file mode 100644 index 3f3b00a41dce..000000000000 --- a/skills/cmux-customization/references/examples.md +++ /dev/null @@ -1,338 +0,0 @@ -# cmux Customization Examples - -Use these examples as starting points. Merge only the relevant top-level keys -into the target config file named above each code block. Unlabeled JSON examples -target `cmux.json`. Preserve unrelated sections, then run `cmux reload-config` -when available. - -Prefer project-local `.cmux/cmux.json` for team workflows and global -`~/.config/cmux/cmux.json` for personal app preferences. - -## Worktree Agents - -Use this when the user wants the plus button to open a worktree or checkout -layout, and right-click to offer alternate starters. - -```json -{ - "actions": { - "worktree-agents": { - "type": "workspaceCommand", - "title": "Worktree Agents", - "commandName": "Worktree Agents", - "icon": { "type": "symbol", "name": "folder.badge.plus" } - } - }, - "ui": { - "newWorkspace": { - "action": "worktree-agents", - "contextMenu": [ - { "action": "worktree-agents", "title": "Worktree Agents" }, - { "type": "separator" }, - { "action": "cmux.newTerminal", "title": "New Terminal" }, - { "action": "cmux.newBrowser", "title": "New Browser" } - ] - } - }, - "commands": [ - { - "name": "Worktree Agents", - "workspace": { - "name": "Worktree Agents", - "cwd": "../worktrees/my-feature", - "layout": { - "direction": "horizontal", - "children": [ - { - "pane": { - "surfaces": [ - { "type": "terminal", "name": "Codex", "command": "codex" } - ] - } - }, - { - "pane": { - "surfaces": [ - { "type": "terminal", "name": "Claude", "command": "claude" } - ] - } - } - ] - } - } - } - ] -} -``` - -## Full-Stack Dev - -Use this when a repo needs terminals, browser preview, and persistent Dock -controls for repeated local development. - -`.cmux/cmux.json`: - -```json -{ - "commands": [ - { - "name": "Full-Stack Dev", - "workspace": { - "name": "Dev", - "cwd": ".", - "layout": { - "direction": "horizontal", - "split": 0.55, - "children": [ - { - "direction": "vertical", - "children": [ - { - "pane": { - "surfaces": [ - { "type": "terminal", "name": "Web", "command": "bun dev" } - ] - } - }, - { - "pane": { - "surfaces": [ - { "type": "terminal", "name": "Tests", "command": "bun test --watch" } - ] - } - } - ] - }, - { - "pane": { - "surfaces": [ - { "type": "browser", "name": "Preview", "url": "http://localhost:3000" } - ] - } - } - ] - } - } - } - ] -} -``` - -`.cmux/dock.json`: - -```json -{ - "controls": [ - { "id": "git", "title": "Git", "command": "lazygit", "cwd": ".", "height": 320 }, - { "id": "feed", "title": "Feed", "command": "cmux feed tui --opentui", "height": 260 } - ] -} -``` - -## SSH Devbox - -Use this when the user's normal environment is remote, or when local cmux should -open a known SSH session beside project notes or a browser preview. - -```json -{ - "commands": [ - { - "name": "SSH Devbox", - "keywords": ["ssh", "remote", "devbox"], - "workspace": { - "name": "Devbox", - "cwd": ".", - "layout": { - "direction": "horizontal", - "children": [ - { - "pane": { - "surfaces": [ - { "type": "terminal", "name": "SSH", "command": "ssh devbox" } - ] - } - }, - { - "pane": { - "surfaces": [ - { "type": "browser", "name": "Preview", "url": "http://localhost:3000" } - ] - } - } - ] - } - } - } - ] -} -``` - -## Review PR - -Use this when a project needs one command to review a pull request with a -terminal, browser, and notes panel. Adjust the URL and command for the user's -GitHub workflow. - -```json -{ - "commands": [ - { - "name": "Review PR", - "keywords": ["review", "pull request", "pr"], - "workspace": { - "name": "PR Review", - "cwd": ".", - "layout": { - "direction": "horizontal", - "children": [ - { - "pane": { - "surfaces": [ - { "type": "terminal", "name": "GitHub", "command": "gh pr status" } - ] - } - }, - { - "pane": { - "surfaces": [ - { "type": "browser", "name": "Pull Request", "url": "https://github.com/owner/repo/pulls" } - ] - } - } - ] - } - } - } - ] -} -``` - -## Docs Workspace - -Use this when a repo needs one command for docs authoring with a dev server, -browser preview, and markdown viewer. Adjust the command, URL, and markdown path -for the docs stack. - -```json -{ - "commands": [ - { - "name": "Docs Workspace", - "keywords": ["docs", "documentation", "preview"], - "workspace": { - "name": "Docs", - "cwd": ".", - "layout": { - "direction": "horizontal", - "split": 0.45, - "children": [ - { - "direction": "vertical", - "children": [ - { - "pane": { - "surfaces": [ - { "type": "terminal", "name": "Docs Server", "command": "bun run docs:dev" } - ] - } - }, - { - "pane": { - "surfaces": [ - { - "type": "terminal", - "name": "Markdown", - "command": "cmux markdown open docs/README.md --direction right --focus false; exec ${SHELL:-/bin/zsh} -l" - } - ] - } - } - ] - }, - { - "pane": { - "surfaces": [ - { "type": "browser", "name": "Docs Preview", "url": "http://localhost:3000/docs" } - ] - } - } - ] - } - } - } - ] -} -``` - -## Quick Agent Buttons - -Use this when the user wants tab bar buttons for common agents while keeping -the default new terminal and browser buttons. - -```json -{ - "actions": { - "codex-new-tab": { - "type": "agent", - "agent": "codex", - "title": "Codex", - "target": "newTabInCurrentPane", - "palette": true - }, - "claude-new-tab": { - "type": "agent", - "agent": "claude", - "title": "Claude", - "target": "newTabInCurrentPane", - "palette": true - } - }, - "ui": { - "surfaceTabBar": { - "buttons": [ - "cmux.newTerminal", - "cmux.newBrowser", - { "action": "codex-new-tab", "title": "Codex" }, - { "action": "claude-new-tab", "title": "Claude" } - ] - } - } -} -``` - -## CI Watch - -Use this when the user wants a repeatable place for GitHub Actions, CircleCI, -or release-monitoring commands. Prefer Dock controls for long-running monitors. - -`.cmux/dock.json`: - -```json -{ - "controls": [ - { - "id": "gh-runs", - "title": "GitHub Runs", - "command": "gh run list --limit 10 && exec ${SHELL:-/bin/zsh} -l", - "cwd": ".", - "height": 260 - }, - { - "id": "feed", - "title": "Feed", - "command": "cmux feed tui --opentui", - "height": 260 - } - ] -} -``` - -## Validation Checklist - -- Parse any changed JSON or JSONC before reporting success. -- Keep `cwd` inside the `workspace` object for workspace commands. -- Confirm `workspaceCommand.commandName` matches a `commands[].name`. -- Use `ui.newWorkspace.contextMenu` in new examples, not the alias. -- Keep built-in tab bar buttons only when the user wants them visible. -- Keep secrets out of config. Use shell profiles, env vars, or a secret store. diff --git a/skills/cmux-debug-windows/SKILL.md b/skills/cmux-debug-windows/SKILL.md new file mode 100644 index 000000000000..f486c5a36c32 --- /dev/null +++ b/skills/cmux-debug-windows/SKILL.md @@ -0,0 +1,51 @@ +--- +name: cmux-debug-windows +description: Manage cmux debug windows and related debug menu wiring for Sidebar Debug, Background Debug, and Menu Bar Extra Debug. Use this when the user asks to open/tune these debug controls, add or adjust Debug menu entries, or capture/copy a combined debug config snapshot. +--- + +# cmux Debug Windows + +Keep this workflow focused on existing debug windows and menu entries. Do not add a new utility/debug control window unless the user asks explicitly. + +## Workflow + +1. Verify debug menu wiring in `Sources/cmuxApp.swift` under `CommandMenu("Debug")`. + - Menu path in app: `Debug` → `Debug Windows` → window entry. + - The `Debug` menu only exists in DEBUG builds (`./scripts/reload.sh --tag ...`). + - Release builds (`reloadp.sh`, `reloads.sh`) do not show this menu. +2. Keep these actions available in `Menu("Debug Windows")`: +- `Sidebar Debug…` +- `Background Debug…` +- `Menu Bar Extra Debug…` +- `Open All Debug Windows` +3. Reuse existing per-window copy buttons (`Copy Config`) in each debug window before adding new UI. +4. For one combined payload, run: +```bash +skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh --copy +``` +5. After code edits, run build + tagged reload: +```bash +xcodebuild -project cmux.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build +./scripts/reload.sh --tag +``` + +## Key Files + +- `Sources/cmuxApp.swift`: Debug menu entries and debug window controllers/views. +- `Sources/AppDelegate.swift`: Menu bar extra debug settings payload and defaults keys. + +## Script + +- `scripts/debug_windows_snapshot.sh` + +Purpose: +- Reads current debug-related defaults values. +- Prints one combined snapshot for sidebar/background/menu bar extra. +- Optionally copies it to clipboard. + +Examples: +```bash +skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh +skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh --copy +skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh --domain --copy +``` diff --git a/skills/cmux-debug-windows/agents/openai.yaml b/skills/cmux-debug-windows/agents/openai.yaml new file mode 100644 index 000000000000..39e648dd09b7 --- /dev/null +++ b/skills/cmux-debug-windows/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "cmux Debug Windows" + short_description: "Tune cmux debug windows and copy snapshots." + default_prompt: "Use this skill to manage cmux sidebar/background/menu-bar debug windows, keep Debug menu wiring clean, and capture copyable debug snapshots." diff --git a/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh new file mode 100755 index 000000000000..7798e8e7ac1e --- /dev/null +++ b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: debug_windows_snapshot.sh [--domain ] [--copy] + +Collect Sidebar Debug, Background Debug, Menu Bar Extra, and Browser DevTools debug values +from macOS defaults and print a combined payload. Use --copy to also copy the payload. + +Examples: + debug_windows_snapshot.sh + debug_windows_snapshot.sh --copy + debug_windows_snapshot.sh --domain dev.manaflow.cmux --copy +USAGE +} + +domain="" +copy_flag=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --domain) + shift + [[ $# -gt 0 ]] || { echo "Missing value for --domain" >&2; exit 1; } + domain="$1" + ;; + --copy) + copy_flag=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +discover_domain() { + defaults domains 2>/dev/null \ + | tr ',' '\n' \ + | tr -d ' ' \ + | grep -E 'cmux' \ + | head -n1 || true +} + +read_value() { + local key="$1" + local fallback="$2" + local value + value=$(defaults read "$domain" "$key" 2>/dev/null || true) + if [[ -z "$value" ]]; then + printf '%s' "$fallback" + else + printf '%s' "$value" + fi +} + +format_number() { + local raw="$1" + local precision="$2" + if [[ "$raw" =~ ^-?[0-9]+([.][0-9]+)?$ ]]; then + printf "%.*f" "$precision" "$raw" + else + printf "%.*f" "$precision" 0 + fi +} + +if [[ -z "$domain" ]]; then + domain="$(discover_domain)" +fi + +if [[ -z "$domain" ]]; then + echo "Could not auto-detect a cmux defaults domain. Pass --domain ." >&2 + exit 1 +fi + +if ! defaults domains 2>/dev/null | tr ',' '\n' | tr -d ' ' | grep -Fxq "$domain"; then + echo "Defaults domain '$domain' was not found on this machine." >&2 + exit 1 +fi + +sidebarPreset="$(read_value sidebarPreset nativeSidebar)" +sidebarMaterial="$(read_value sidebarMaterial sidebar)" +sidebarBlendMode="$(read_value sidebarBlendMode behindWindow)" +sidebarState="$(read_value sidebarState followWindow)" +sidebarBlurOpacity="$(format_number "$(read_value sidebarBlurOpacity 0.79)" 2)" +sidebarTintHex="$(read_value sidebarTintHex '#101010')" +sidebarTintOpacity="$(format_number "$(read_value sidebarTintOpacity 0.54)" 2)" +sidebarCornerRadius="$(format_number "$(read_value sidebarCornerRadius 0.0)" 1)" +sidebarActiveTabIndicatorStyle="$(read_value sidebarActiveTabIndicatorStyle solidFill)" +shortcutHintSidebarXOffset="$(format_number "$(read_value shortcutHintSidebarXOffset 0.0)" 1)" +shortcutHintSidebarYOffset="$(format_number "$(read_value shortcutHintSidebarYOffset 0.0)" 1)" +shortcutHintTitlebarXOffset="$(format_number "$(read_value shortcutHintTitlebarXOffset 4.0)" 1)" +shortcutHintTitlebarYOffset="$(format_number "$(read_value shortcutHintTitlebarYOffset 0.0)" 1)" +shortcutHintPaneTabXOffset="$(format_number "$(read_value shortcutHintPaneTabXOffset 0.0)" 1)" +shortcutHintPaneTabYOffset="$(format_number "$(read_value shortcutHintPaneTabYOffset 0.0)" 1)" +shortcutHintAlwaysShow="$(read_value shortcutHintAlwaysShow 0)" + +bgGlassEnabled="$(read_value bgGlassEnabled 1)" +bgGlassMaterial="$(read_value bgGlassMaterial hudWindow)" +bgGlassTintHex="$(read_value bgGlassTintHex '#000000')" +bgGlassTintOpacity="$(format_number "$(read_value bgGlassTintOpacity 0.05)" 2)" + +menubarDebugPreviewEnabled="$(read_value menubarDebugPreviewEnabled 0)" +menubarDebugPreviewCount="$(read_value menubarDebugPreviewCount 1)" +menubarDebugBadgeRectX="$(format_number "$(read_value menubarDebugBadgeRectX 5.38)" 2)" +menubarDebugBadgeRectY="$(format_number "$(read_value menubarDebugBadgeRectY 6.43)" 2)" +menubarDebugBadgeRectWidth="$(format_number "$(read_value menubarDebugBadgeRectWidth 10.75)" 2)" +menubarDebugBadgeRectHeight="$(format_number "$(read_value menubarDebugBadgeRectHeight 11.58)" 2)" +menubarDebugSingleDigitFontSize="$(format_number "$(read_value menubarDebugSingleDigitFontSize 6.70)" 2)" +menubarDebugMultiDigitFontSize="$(format_number "$(read_value menubarDebugMultiDigitFontSize 6.70)" 2)" +menubarDebugSingleDigitYOffset="$(format_number "$(read_value menubarDebugSingleDigitYOffset 0.60)" 2)" +menubarDebugMultiDigitYOffset="$(format_number "$(read_value menubarDebugMultiDigitYOffset 0.60)" 2)" +legacySingleDigitX="$(read_value menubarDebugTextRectXAdjust '')" +if [[ -n "$legacySingleDigitX" ]]; then +menubarDebugSingleDigitXAdjust="$(format_number "$legacySingleDigitX" 2)" +else + menubarDebugSingleDigitXAdjust="$(format_number "$(read_value menubarDebugSingleDigitXAdjust -1.10)" 2)" +fi +menubarDebugMultiDigitXAdjust="$(format_number "$(read_value menubarDebugMultiDigitXAdjust 2.42)" 2)" +menubarDebugTextRectWidthAdjust="$(format_number "$(read_value menubarDebugTextRectWidthAdjust 1.80)" 2)" + +browserDevToolsIconName="$(read_value browserDevToolsIconName 'wrench.and.screwdriver')" +browserDevToolsIconColor="$(read_value browserDevToolsIconColor bonsplitInactive)" + +payload="$(cat </dev/null 2>&1; then + printf '%s' "$payload" | pbcopy + echo "Copied debug snapshot to clipboard." + else + echo "pbcopy not available; skipped clipboard copy." >&2 + fi +fi diff --git a/skills/cmux-diagnostics/SKILL.md b/skills/cmux-diagnostics/SKILL.md deleted file mode 100644 index 1d892ea86bb9..000000000000 --- a/skills/cmux-diagnostics/SKILL.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -name: cmux-diagnostics -description: "Run end-user cmux diagnostics. Use when cmux hooks, notifications, session restore, settings, browser automation, socket access, CLI control, or agent resume behavior is not working, or when the user asks for a cmux health check, doctor report, or support-safe debug summary." ---- - -# cmux Diagnostics - -Use this skill to collect and interpret support-safe cmux diagnostics for end users. Default to read-only checks. Do not dump hook config files, session stores, prompt logs, tokens, or environment secrets. - -## Quick Report - -Run the bundled read-only diagnostic script first: - -```bash -# From a cmux checkout -skills/cmux-diagnostics/scripts/cmux-diagnostics - -# From an installed skill -~/.agents/skills/cmux-diagnostics/scripts/cmux-diagnostics - -# From a Codex-only skills.sh install -~/.codex/skills/cmux-diagnostics/scripts/cmux-diagnostics -``` - -Use `--include-context` only when workspace names, cwd paths, and current cmux identifiers are relevant to the user-reported issue: - -```bash -skills/cmux-diagnostics/scripts/cmux-diagnostics --include-context -``` - -## What to Check - -1. CLI and socket health: - - ```bash - command -v cmux - cmux ping - cmux capabilities --json - ``` - - If socket commands fail, check whether the agent is running inside a cmux terminal and whether socket automation is enabled. - -2. Settings health: - - ```bash - ~/.agents/skills/cmux-settings/scripts/cmux-settings validate - ~/.agents/skills/cmux-settings/scripts/cmux-settings get terminal.autoResumeAgentSessions - ``` - - If the user installed with `skills.sh`, use `~/.codex/skills/cmux-settings/scripts/cmux-settings` instead. - If `terminal.autoResumeAgentSessions` is false, cmux restores panes but will not automatically resume saved agent sessions. - -3. Hook installation: - - ```bash - cmux hooks setup --agent codex - cmux hooks setup --agent opencode - cmux hooks setup - ``` - - Only run install or uninstall commands after the user agrees. `cmux hooks setup` installs supported agents found on PATH and skips missing agents. - -4. Session restore evidence: - - ```bash - ls -lh ~/.cmuxterm/*-hook-sessions.json 2>/dev/null - ``` - - Missing session stores usually means the agent has not run inside cmux since hooks were installed, hooks are disabled, or the agent integration does not support resume capture. - -5. Notification path: - - ```bash - cmux notify "cmux diagnostic test" - ``` - - Use this only when the user is ready for a visible test notification. - -## Interpretation - -- `cmux` not found: the CLI is not installed or not on PATH for this shell. -- `cmux ping` fails: app is not reachable through the current socket path, the app is closed, or automation access is disabled. -- No `CMUX_WORKSPACE_ID` or `CMUX_SURFACE_ID`: the command is probably running outside a cmux terminal. Some hooks intentionally no-op outside cmux. -- Hook config exists but no session store: run one supported agent inside cmux after installing hooks, then re-check. -- Session store exists but restore does not launch agents: check `terminal.autoResumeAgentSessions` and whether the saved executable still exists on PATH. -- Settings validation fails: fix the config first. Invalid config can make later symptoms misleading. - -## Rules - -- Stay read-only until the user asks to fix something. -- Never print raw hook files, session JSON, prompt logs, shell history, tokens, or API keys. -- Summarize file presence, size, modified time, and marker presence instead of contents. -- Prefer narrow fixes such as `cmux hooks setup --agent codex` over reinstalling every integration. -- After a fix, rerun the diagnostic script and report the changed lines. diff --git a/skills/cmux-diagnostics/agents/openai.yaml b/skills/cmux-diagnostics/agents/openai.yaml deleted file mode 100644 index 364b6e193551..000000000000 --- a/skills/cmux-diagnostics/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "cmux Diagnostics" - short_description: "Check cmux hooks, restore, CLI, and settings." - default_prompt: "Use $cmux-diagnostics to collect a read-only cmux health report and explain any hook or session restore problems." diff --git a/skills/cmux-diagnostics/scripts/cmux-diagnostics b/skills/cmux-diagnostics/scripts/cmux-diagnostics deleted file mode 100755 index 623374247b0c..000000000000 --- a/skills/cmux-diagnostics/scripts/cmux-diagnostics +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -include_context=0 -while [[ $# -gt 0 ]]; do - case "$1" in - --include-context) - include_context=1 - shift - ;; - -h|--help) - cat <<'EOF' -Usage: cmux-diagnostics [--include-context] - -Print a read-only, support-safe cmux diagnostics report. By default this avoids -workspace names, cwd paths, hook contents, session JSON, and secrets. -EOF - exit 0 - ;; - *) - printf 'cmux-diagnostics: unknown option: %s\n' "$1" >&2 - exit 1 - ;; - esac -done - -section() { - printf '\n== %s ==\n' "$1" -} - -safe_path() { - local path="$1" - if [[ "$path" == "$HOME" ]]; then - printf '~' - elif [[ "$path" == "$HOME"/* ]]; then - printf '~/%s' "${path#"$HOME"/}" - else - printf '%s' "$path" - fi -} - -run_status() { - local label="$1" - shift - printf '%s: ' "$label" - if "$@" >/dev/null 2>&1; then - printf 'ok\n' - else - printf 'failed\n' - fi -} - -file_summary() { - local path="$1" - if [[ -f "$path" ]]; then - local bytes modified - bytes="$(wc -c <"$path" 2>/dev/null | tr -d ' ' || true)" - modified="$(stat -f '%Sm' -t '%Y-%m-%d %H:%M:%S %z' "$path" 2>/dev/null || stat -c '%y' "$path" 2>/dev/null || printf 'unknown')" - printf 'present bytes=%s modified=%s\n' "${bytes:-unknown}" "$modified" - elif [[ -e "$path" ]]; then - printf 'non-regular\n' - else - printf 'missing\n' - fi -} - -marker_summary() { - local path="$1" - local marker="$2" - if [[ -f "$path" ]]; then - if grep -Fq "$marker" "$path" 2>/dev/null; then - printf 'marker-present\n' - else - printf 'marker-missing\n' - fi - elif [[ -e "$path" ]]; then - printf 'non-regular\n' - else - printf 'missing\n' - fi -} - -script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" -skill_dir="$(cd -- "$script_dir/.." >/dev/null 2>&1 && pwd)" -skills_root="$(cd -- "$skill_dir/.." >/dev/null 2>&1 && pwd)" -settings_helper="$skills_root/cmux-settings/scripts/cmux-settings" -if [[ ! -x "$settings_helper" && -x "$HOME/.agents/skills/cmux-settings/scripts/cmux-settings" ]]; then - settings_helper="$HOME/.agents/skills/cmux-settings/scripts/cmux-settings" -elif [[ ! -x "$settings_helper" && -x "$HOME/.codex/skills/cmux-settings/scripts/cmux-settings" ]]; then - settings_helper="$HOME/.codex/skills/cmux-settings/scripts/cmux-settings" -fi - -section "cmux diagnostics" -printf 'date: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" -printf 'shell: %s\n' "${SHELL:-unknown}" -printf 'inside_cmux: ' -if [[ -n "${CMUX_WORKSPACE_ID:-}" || -n "${CMUX_SURFACE_ID:-}" ]]; then - printf 'yes\n' -else - printf 'no\n' -fi -printf 'cmux_workspace_id_set: %s\n' "$([[ -n "${CMUX_WORKSPACE_ID:-}" ]] && printf yes || printf no)" -printf 'cmux_surface_id_set: %s\n' "$([[ -n "${CMUX_SURFACE_ID:-}" ]] && printf yes || printf no)" -if [[ "${CMUX_SOCKET_PATH+x}" == "x" ]]; then - printf 'cmux_socket_path: %s (from CMUX_SOCKET_PATH)\n' "$(safe_path "$CMUX_SOCKET_PATH")" -elif [[ "${CMUX_SOCKET+x}" == "x" ]]; then - printf 'cmux_socket_path: %s (from CMUX_SOCKET)\n' "$(safe_path "$CMUX_SOCKET")" -else - printf 'cmux_socket_path: unset\n' -fi -printf 'cmux_socket_password_set: %s\n' "$([[ -n "${CMUX_SOCKET_PASSWORD:-}" ]] && printf yes || printf no)" - -section "cli" -if command -v cmux >/dev/null 2>&1; then - printf 'cmux_path: %s\n' "$(safe_path "$(command -v cmux)")" - if cmux --version >/dev/null 2>&1; then - printf 'cmux_version: %s\n' "$(cmux --version 2>/dev/null | head -n 1)" - else - printf 'cmux_version: unavailable\n' - fi - run_status "cmux_ping" cmux ping - run_status "cmux_capabilities" cmux capabilities --json - if [[ "$include_context" -eq 1 ]]; then - printf 'identify_json:\n' - cmux identify --json 2>/dev/null || printf ' unavailable\n' - fi -else - printf 'cmux_path: missing\n' -fi - -section "settings" -printf 'cmux_json: ' -file_summary "$HOME/.config/cmux/cmux.json" -printf 'legacy_settings_json: ' -file_summary "$HOME/.config/cmux/settings.json" -if [[ -x "$settings_helper" ]]; then - run_status "cmux_settings_validate" "$settings_helper" validate - printf 'auto_resume_agent_sessions: ' - "$settings_helper" get terminal.autoResumeAgentSessions 2>/dev/null || printf 'default-or-unset\n' -else - printf 'cmux_settings_helper: missing (%s)\n' "$(safe_path "$settings_helper")" -fi - -section "hook configs" -declare -a hook_checks=( - "codex:$HOME/.codex/hooks.json:cmux hooks codex" - "codex-config:$HOME/.codex/config.toml:codex_hooks" - "opencode-session:$HOME/.config/opencode/plugins/cmux-session.js:cmux hooks opencode" - "opencode-feed:$HOME/.config/opencode/plugins/cmux-feed.js:cmux hooks feed --source opencode" - "pi:$HOME/.pi/agent/extensions/cmux-session.ts:cmux hooks pi" - "amp:$HOME/.config/amp/plugins/cmux-session.ts:cmux hooks amp" - "cursor:$HOME/.cursor/hooks.json:cmux hooks cursor" - "gemini:$HOME/.gemini/settings.json:cmux hooks gemini" - "rovodev:$HOME/.rovodev/rovodev.yml:cmux hooks rovodev" - "hermes-agent:$HOME/.hermes/config.yaml:cmux hooks hermes-agent" - "copilot:$HOME/.copilot/settings.json:cmux hooks copilot" - "codebuddy:$HOME/.codebuddy/settings.json:cmux hooks codebuddy" - "factory:$HOME/.factory/settings.json:cmux hooks factory" - "qoder:$HOME/.qoder/settings.json:cmux hooks qoder" -) -for entry in "${hook_checks[@]}"; do - IFS=: read -r name path marker <<<"$entry" - printf '%s: %s ' "$name" "$(safe_path "$path")" - marker_summary "$path" "$marker" -done - -section "session stores" -shopt -s nullglob -stores=("$HOME"/.cmuxterm/*-hook-sessions.json) -if [[ "${#stores[@]}" -eq 0 ]]; then - printf 'session_store_files: none\n' -else - printf 'session_store_files: %s\n' "${#stores[@]}" - for path in "${stores[@]}"; do - printf '%s: ' "$(basename "$path")" - file_summary "$path" - done -fi - -section "agent binaries" -for bin in claude codex opencode pi amp cursor-agent gemini rovo rovodev hermes-agent gh copilot codebuddy droid qodercli; do - if command -v "$bin" >/dev/null 2>&1; then - printf '%s: present (%s)\n' "$bin" "$(safe_path "$(command -v "$bin")")" - else - printf '%s: missing\n' "$bin" - fi -done diff --git a/skills/cmux-settings/SKILL.md b/skills/cmux-settings/SKILL.md deleted file mode 100644 index 8a912d360da7..000000000000 --- a/skills/cmux-settings/SKILL.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -name: cmux-settings -description: "View and edit cmux settings in ~/.config/cmux/cmux.json. Use when the user wants to change cmux preferences (appearance, sidebar, notifications, automation, browser, shortcuts), set a value by JSON path, validate the file, open it in an editor, or look up which keys cmux recognizes. Triggers on '/cmux-settings', 'change cmux setting', 'set in cmux', 'cmux config', 'cmux.json', or 'rebind a cmux shortcut'." ---- - -# cmux-settings - -cmux reads user settings from `~/.config/cmux/cmux.json` (JSONC). The app installs a file watcher; saving the file applies changes immediately, no restart needed. Legacy `~/.config/cmux/settings.json` is read only as a fallback for keys not present in `cmux.json`. - -Schema: `https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux.schema.json`. The authoritative path list lives in `Sources/CmuxSettingsJSONPathSupport.swift` in the cmux checkout, and the installed skill includes a generated copy in `references/all-keys.md`. Top-level sections are `app`, `terminal`, `notifications`, `sidebar`, `sidebarAppearance`, `workspaceColors`, `automation`, `browser`, and `shortcuts`. Non-settings sections (`actions`, `ui`, `commands`, `vault`, `rightSidebar`) coexist in the same file. - -## Helper script - -Use the bundled helper for every read/write. It strips JSONC comments, writes atomically, and validates keys against the schema. - -```bash -# From a cmux checkout -skills/cmux-settings/scripts/cmux-settings - -# From an installed Codex skill -~/.codex/skills/cmux-settings/scripts/cmux-settings -``` - -For brevity in the rest of this doc, assume the script is on `$PATH` as `cmux-settings`. To make it so for a session from a checkout: `export PATH="$PWD/skills/cmux-settings/scripts:$PATH"`. - -Subcommands: - -| Command | What it does | -|---|---| -| `cmux-settings path` | Print the config path. | -| `cmux-settings dump` | Print the raw file (preserves comments). | -| `cmux-settings dump --no-comments` | Print the parsed JSON. | -| `cmux-settings get ` | Print value at dotted JSON path. | -| `cmux-settings set ` | Set value. `` is parsed as JSON (`true`, `42`, `"text"`, `[…]`, `{…}`); plain strings without quotes are stored as strings. | -| `cmux-settings unset ` | Delete key, reverting to the in-app default. | -| `cmux-settings list-supported` | List every settings JSON path the app recognizes. | -| `cmux-settings validate` | Parse the file and flag any unknown settings keys. | -| `cmux-settings open` | Open `cmux.json` in `$EDITOR`, VS Code, Cursor, or TextEdit. | - -`--file ` overrides the target file (useful for `--file ~/.config/cmux/settings.json` when the user keeps things in the legacy file). - -## Workflow - -1. Confirm the change. If the user named a setting in plain English (e.g. "make the sidebar tint match the terminal background"), look it up first. - ```bash - cmux-settings list-supported | rg -i 'sidebar.*terminal|terminal.*sidebar' - ``` -2. Set the value. JSON literals (`true`, `false`, numbers, arrays, objects) must be valid JSON. Plain words are stored as strings. - ```bash - cmux-settings set sidebarAppearance.matchTerminalBackground true - cmux-settings set app.appearance dark - cmux-settings set shortcuts.bindings.toggleSidebar cmd+b - cmux-settings set shortcuts.bindings.newTab '["ctrl+b","c"]' - cmux-settings set browser.hostsToOpenInEmbeddedBrowser '["localhost","*.internal.example"]' - ``` -3. Verify by reading back and validating. - ```bash - cmux-settings get sidebarAppearance.matchTerminalBackground - cmux-settings validate - ``` -4. Tell the user it auto-reloaded. No app restart. If they want to revert, run `cmux-settings unset `. - -## Quick reference - -- Appearance: `app.appearance` = `"system" | "light" | "dark"`, `app.appIcon`, `app.menuBarOnly`, `app.minimalMode`. -- Sidebar tint: `sidebarAppearance.matchTerminalBackground`, `sidebarAppearance.tintColor`, `sidebarAppearance.tintOpacity` (0..1). -- Sidebar details: `sidebar.hideAllDetails`, `sidebar.showBranchDirectory`, `sidebar.showPullRequests`, `sidebar.showPorts`, `sidebar.showLog`. -- Notifications: `notifications.dockBadge`, `notifications.sound` (enum incl. `"none"`, `"custom_file"`), `notifications.customSoundFilePath`, `notifications.hooks` (array). -- Browser: `browser.defaultSearchEngine`, `browser.theme`, `browser.openTerminalLinksInCmuxBrowser`, `browser.hostsToOpenInEmbeddedBrowser`. -- Automation: `automation.socketControlMode` (`off | cmuxOnly | automation | password | allowAll`), `automation.portBase`, `automation.portRange`. -- Shortcuts: `shortcuts.bindings.` = `"cmd+b"`, `["ctrl+b","c"]`, `null`, or `""` to unbind. See `references/shortcut-actions.md`. - -For the full list of settings, defaults, and descriptions, run `cmux-settings list-supported` or read [references/all-keys.md](references/all-keys.md). - -## Rules - -- Only edit `cmux.json`. Never edit `settings.json` unless the user explicitly asks; it is legacy and only read when the key is absent from `cmux.json`. -- Never tell the user to restart cmux to apply a change. The file watcher reloads on save. -- Always validate after a bulk edit: `cmux-settings validate`. Unknown keys mean the user pasted a key the app does not consume. -- Do not blindly overwrite top-level sections (`actions`, `ui`, `commands`, `vault`, `rightSidebar`). They live in the same file and contain non-settings config the user has hand-tuned. -- Shortcut action ids must match the schema enum. Look them up in [references/shortcut-actions.md](references/shortcut-actions.md) before binding. -- Color values must be `#RRGGBB`. Opacities are `0..1`. -- For settings the user expressed in app-level language (e.g. "Settings > Notifications > Dock badge"), translate to the matching JSON path first; the docs page at `web/app/[locale]/docs/configuration/page.tsx` mirrors the schema 1:1. diff --git a/skills/cmux-settings/agents/openai.yaml b/skills/cmux-settings/agents/openai.yaml deleted file mode 100644 index 68f98b042bb5..000000000000 --- a/skills/cmux-settings/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "cmux Settings" - short_description: "Inspect, edit, validate, and reload cmux.json settings." - default_prompt: "Use this skill to change cmux settings safely: map user-facing preferences to cmux.json paths, edit with the bundled helper, validate recognized keys, and rely on cmux's live config reload." diff --git a/skills/cmux-settings/references/all-keys.md b/skills/cmux-settings/references/all-keys.md deleted file mode 100644 index 88ff04584504..000000000000 --- a/skills/cmux-settings/references/all-keys.md +++ /dev/null @@ -1,140 +0,0 @@ -# All settings keys - -Auto-generated from `web/data/cmux.schema.json`. For the rendered docs, see `https://cmux.com/docs/configuration`. - -## app - -General app preferences from Settings > App. - -| Key | Type | Default | Description | -|---|---|---|---| -| `app.language` | `"system"` or `"en"` or `"ar"` or `"bs"` or `"zh-Hans"` or `"zh-Hant"` or `"da"` or `"de"` or `"es"` or `"fr"` or `"it"` or `"ja"` or `"ko"` or `"nb"` or `"pl"` or `"pt-BR"` or `"ru"` or `"th"` or `"tr"` | `"system"` | Preferred app language. | -| `app.appearance` | `"system"` or `"light"` or `"dark"` | `"system"` | App appearance mode. | -| `app.appIcon` | `"automatic"` or `"light"` or `"dark"` | `"automatic"` | Dock and app switcher icon style. | -| `app.menuBarOnly` | boolean | `false` | Hide the Dock icon and app switcher entry while keeping cmux available from the menu bar. | -| `app.newWorkspacePlacement` | `"top"` or `"afterCurrent"` or `"end"` | `"afterCurrent"` | Where new workspaces are inserted in the sidebar. | -| `app.workspaceInheritWorkingDirectory` | boolean | `true` | When true, new workspaces inherit the current workspace working directory. When false, new workspaces leave the working directory unset so Ghostty's working-directory setting can provide the default. | -| `app.minimalMode` | boolean | `false` | Hide the workspace title bar and move controls into the sidebar. | -| `app.keepWorkspaceOpenWhenClosingLastSurface` | boolean | `false` | When true, closing the last surface keeps the workspace open. | -| `app.focusPaneOnFirstClick` | boolean | `true` | When cmux is inactive, the first click can activate and focus the clicked pane. | -| `app.preferredEditor` | string | `""` | Custom editor command used when Cmd-click file previews are disabled or a file is unsupported. Leave empty to use the default. | -| `app.openSupportedFilesInCmux` | boolean | `true` | When enabled, Cmd-clicking readable local files opens supported previews in cmux, including text, code, PDFs, images, audio, video, and Quick Look files. Preview headers include an Open With menu based on the user's default and compatible macOS apps for that file. | -| `app.openMarkdownInCmuxViewer` | boolean | `false` | When enabled together with openSupportedFilesInCmux, Cmd-clicking .md/.markdown/.mkd/.mdx files opens the rendered cmux markdown viewer panel (with live reload) instead of the generic file preview. | -| `app.reorderOnNotification` | boolean | `true` | Move workspaces with new notifications toward the top. | -| `app.iMessageMode` | boolean | `false` | Move a workspace to the top and show the submitted message when sending an agent prompt. | -| `app.sendAnonymousTelemetry` | boolean | `true` | Allow anonymous telemetry. | -| `app.warnBeforeQuit` | boolean | `true` | Show a confirmation before quitting cmux. | -| `app.warnBeforeClosingTab` | boolean | `true` | Show a confirmation before closing a tab. | -| `app.renameSelectsExistingName` | boolean | `true` | Select the current name when opening rename flows. | -| `app.commandPaletteSearchesAllSurfaces` | boolean | `false` | Search every surface in the command palette switcher instead of only the active workspace. | - -## terminal - -Terminal presentation settings from Settings > Terminal. - -| Key | Type | Default | Description | -|---|---|---|---| -| `terminal.showScrollBar` | boolean | `true` | Show the right-edge terminal scroll bar when scrollback is available. cmux automatically suppresses it for alternate-screen style TUI surfaces. | -| `terminal.autoResumeAgentSessions` | boolean | `true` | Automatically run agent resume commands for restored terminal sessions when cmux reopens after quit. Set false to restore panes while keeping Claude Code, Codex, OpenCode, and other saved agent sessions idle until you resume them manually. | - -## notifications - -Notification behavior from Settings > Notifications. - -| Key | Type | Default | Description | -|---|---|---|---| -| `notifications.dockBadge` | boolean | `true` | Show the unread count in the Dock tile. | -| `notifications.showInMenuBar` | boolean | `true` | Show the menu bar extra. | -| `notifications.unreadPaneRing` | boolean | `true` | Highlight panes with unread notifications. | -| `notifications.paneFlash` | boolean | `true` | Flash the focused pane when requested. | -| `notifications.sound` | `"default"` or `"Basso"` or `"Blow"` or `"Bottle"` or `"Frog"` or `"Funk"` or `"Glass"` or `"Hero"` or `"Morse"` or `"Ping"` or `"Pop"` or `"Purr"` or `"Sosumi"` or `"Submarine"` or `"Tink"` or `"custom_file"` or `"none"` | `"default"` | Notification sound preset. | -| `notifications.customSoundFilePath` | string | `""` | Local path to the custom notification sound file. | -| `notifications.command` | string | `""` | Optional shell command to run alongside notification delivery. | -| `notifications.hooksMode` | `"append"` or `"replace"` | `"append"` | Controls whether project-local notification hooks append to inherited hooks or replace them. | -| `notifications.hooks` | array | `[]` | Composable shell hooks that receive notification policy JSON on stdin and return updated policy JSON on stdout. | - -## sidebar - -Sidebar content and metadata visibility from Settings > Sidebar. - -| Key | Type | Default | Description | -|---|---|---|---| -| `sidebar.hideAllDetails` | boolean | `false` | Hide all per-workspace detail rows. | -| `sidebar.showWorkspaceDescription` | boolean | `true` | Show custom workspace descriptions in the sidebar. | -| `sidebar.branchLayout` | `"vertical"` or `"inline"` | `"vertical"` | Show git branch details stacked vertically or inline. | -| `sidebar.showNotificationMessage` | boolean | `true` | Show the latest notification text in the sidebar. | -| `sidebar.showBranchDirectory` | boolean | `true` | Show the workspace working directory. | -| `sidebar.showPullRequests` | boolean | `true` | Show pull request metadata in the sidebar. | -| `sidebar.makePullRequestsClickable` | boolean | `true` | Allow sidebar pull request metadata to open links when clicked. | -| `sidebar.openPullRequestLinksInCmuxBrowser` | boolean | `true` | Open sidebar pull request links in the embedded cmux browser. | -| `sidebar.openPortLinksInCmuxBrowser` | boolean | `true` | Open sidebar port links in the embedded cmux browser. | -| `sidebar.showSSH` | boolean | `true` | Show SSH connection details. | -| `sidebar.showPorts` | boolean | `true` | Show listening ports. | -| `sidebar.showLog` | boolean | `true` | Show recent log snippets. | -| `sidebar.showProgress` | boolean | `true` | Show progress indicators. | -| `sidebar.showCustomMetadata` | boolean | `true` | Show custom metadata pills. | - -## workspaceColors - -Workspace tab and badge colors from Settings > Workspace Colors. - -| Key | Type | Default | Description | -|---|---|---|---| -| `workspaceColors.indicatorStyle` | `"leftRail"` or `"solidFill"` or `"rail"` or `"border"` or `"wash"` or `"lift"` or `"typography"` or `"washRail"` or `"blueWashColorRail"` | `"leftRail"` | Active workspace indicator style. Legacy aliases are accepted and normalized. | -| `workspaceColors.selectionColor` | colorHexOrNull | `null` | Override the selected workspace background color. | -| `workspaceColors.notificationBadgeColor` | colorHexOrNull | `null` | Override the unread notification badge color. | -| `workspaceColors.colors` | object | `{"Red": "#C0392B", "Crimson": "#922B21", "Orange": "#A04000", "Amber": "#7D6608", "Olive": "#4A5C18", "Green": "#196F3D", "Teal": "#006B6B", "Aqua": "#0E6B8C", "Blue": "#1565C0", "Navy": "#1A5276", "Indigo": "#283593", "Purple": "#6A1B9A", "Magenta": "#AD1457", "Rose": "#880E4F", "Brown": "#7B3F00", "Charcoal": "#3E4B5E"}` | Full named workspace color palette. Include built-in entries you want to keep, remove keys to remove colors, and add more named entries to extend the picker. | -| `workspaceColors.paletteOverrides` | object | `{}` | Legacy workspace color overrides for built-in palette names. Prefer workspaceColors.colors for new configs. | -| `workspaceColors.customColors` | array | `[]` | Legacy list of custom workspace colors. Prefer workspaceColors.colors for new configs. | - -## sidebarAppearance - -Sidebar tint settings from Settings > Sidebar Appearance. - -| Key | Type | Default | Description | -|---|---|---|---| -| `sidebarAppearance.matchTerminalBackground` | boolean | `false` | Use the terminal background instead of the sidebar tint. | -| `sidebarAppearance.tintColor` | colorHex | `"#000000"` | Base sidebar tint color used when light/dark overrides are not set. | -| `sidebarAppearance.lightModeTintColor` | colorHexOrNull | `null` | Sidebar tint override for light appearance. | -| `sidebarAppearance.darkModeTintColor` | colorHexOrNull | `null` | Sidebar tint override for dark appearance. | -| `sidebarAppearance.tintOpacity` | number | `0.03` | Sidebar tint opacity from 0 to 1. Note: this only controls the sidebar tint, not terminal/window transparency. For terminal background transparency or blur, set `background-opacity` and `background-blur` in `~/.config/ghostty/config` and run `cmux reload-config`. | - -## automation - -Socket control and automation settings from Settings > Automation. - -| Key | Type | Default | Description | -|---|---|---|---| -| `automation.socketControlMode` | `"off"` or `"cmuxOnly"` or `"automation"` or `"password"` or `"allowAll"` or `"openAccess"` or `"fullOpenAccess"` or `"notifications"` or `"full"` | `"cmuxOnly"` | Socket control mode. Legacy aliases are accepted and normalized. | -| `automation.socketPassword` | string or null | `""` | Password for password-mode socket access. Use null or an empty string to clear it. | -| `automation.claudeCodeIntegration` | boolean | `true` | Enable cmux integration hooks for Claude Code. | -| `automation.claudeBinaryPath` | string | `""` | Custom path to the claude binary. | -| `automation.cursorIntegration` | boolean | `true` | Enable cmux integration hooks for Cursor. | -| `automation.geminiIntegration` | boolean | `true` | Enable cmux integration hooks for Gemini. | -| `automation.portBase` | integer | `9100` | Starting value for workspace CMUX_PORT assignments. | -| `automation.portRange` | integer | `10` | Number of ports reserved per workspace. | - -## browser - -Embedded browser settings from Settings > Browser. - -| Key | Type | Default | Description | -|---|---|---|---| -| `browser.defaultSearchEngine` | `"google"` or `"duckduckgo"` or `"bing"` or `"kagi"` or `"startpage"` | `"google"` | Default search engine for non-URL queries. | -| `browser.showSearchSuggestions` | boolean | `true` | Show omnibar search suggestions. | -| `browser.theme` | `"system"` or `"light"` or `"dark"` | `"system"` | Embedded browser theme. | -| `browser.openTerminalLinksInCmuxBrowser` | boolean | `true` | Open clicked terminal links in the embedded browser. | -| `browser.interceptTerminalOpenCommandInCmuxBrowser` | boolean | `true` | Intercept terminal open http(s) commands and route them through the embedded browser. | -| `browser.hostsToOpenInEmbeddedBrowser` | array | `[]` | Allowlist of hosts that should stay inside the embedded browser. | -| `browser.urlsToAlwaysOpenExternally` | array | `[]` | Rules that always open matching URLs in the system browser. | -| `browser.insecureHttpHostsAllowedInEmbeddedBrowser` | array | `["localhost", "*.localhost", "127.0.0.1", "::1", "0.0.0.0", "*.localtest.me"]` | HTTP hosts allowed in the embedded browser without a warning prompt. | -| `browser.showImportHintOnBlankTabs` | boolean | `true` | Show the browser import hint on blank tabs. | -| `browser.reactGrabVersion` | string | `"0.1.29"` | Pinned react-grab version for the browser toolbar helper. | - -## shortcuts - -Keyboard shortcut settings from Settings > Keyboard Shortcuts. - -| Key | Type | Default | Description | -|---|---|---|---| -| `shortcuts.bindings` | object | `{}` | Shortcut overrides keyed by cmux action id. Use a string for a single shortcut, an array for a chord, null, an empty string, none, clear, unbound, or disabled to unbind. | diff --git a/skills/cmux-settings/references/shortcut-actions.md b/skills/cmux-settings/references/shortcut-actions.md deleted file mode 100644 index f201ea442148..000000000000 --- a/skills/cmux-settings/references/shortcut-actions.md +++ /dev/null @@ -1,111 +0,0 @@ -# Keyboard shortcut action ids - -Auto-generated from `web/data/cmux.schema.json` (`shortcuts.bindings.propertyNames.enum`). - -Values for `shortcuts.bindings.`: - -- A string like `"cmd+b"` for a single shortcut. -- A two-element array like `["ctrl+b","c"]` for a chord. -- `null` or an empty string (`""`, `"none"`, `"clear"`, `"unbound"`, `"disabled"`) to unbind. - -## App - -- `shortcuts.bindings.openSettings` -- `shortcuts.bindings.reloadConfiguration` -- `shortcuts.bindings.showHideAllWindows` -- `shortcuts.bindings.globalSearch` -- `shortcuts.bindings.newWindow` -- `shortcuts.bindings.closeWindow` -- `shortcuts.bindings.toggleFullScreen` -- `shortcuts.bindings.quit` -- `shortcuts.bindings.openFolder` -- `shortcuts.bindings.sendFeedback` - -## Tabs - -- `shortcuts.bindings.newTab` -- `shortcuts.bindings.reopenPreviousSession` -- `shortcuts.bindings.renameTab` -- `shortcuts.bindings.closeTab` -- `shortcuts.bindings.closeOtherTabsInPane` - -## Workspace - -- `shortcuts.bindings.goToWorkspace` -- `shortcuts.bindings.selectWorkspaceByNumber` -- `shortcuts.bindings.renameWorkspace` -- `shortcuts.bindings.editWorkspaceDescription` -- `shortcuts.bindings.closeWorkspace` - -## Panes and surfaces - -- `shortcuts.bindings.nextSurface` -- `shortcuts.bindings.prevSurface` -- `shortcuts.bindings.selectSurfaceByNumber` -- `shortcuts.bindings.newSurface` -- `shortcuts.bindings.toggleTerminalCopyMode` -- `shortcuts.bindings.focusLeft` -- `shortcuts.bindings.focusRight` -- `shortcuts.bindings.focusUp` -- `shortcuts.bindings.focusDown` -- `shortcuts.bindings.splitRight` -- `shortcuts.bindings.splitDown` -- `shortcuts.bindings.toggleSplitZoom` -- `shortcuts.bindings.equalizeSplits` - -## Command palette - -- `shortcuts.bindings.commandPalette` -- `shortcuts.bindings.commandPaletteNext` -- `shortcuts.bindings.commandPalettePrevious` - -## Notifications - -- `shortcuts.bindings.showNotifications` -- `shortcuts.bindings.jumpToUnread` -- `shortcuts.bindings.toggleUnread` -- `shortcuts.bindings.markOldestUnreadAndJumpNext` -- `shortcuts.bindings.triggerFlash` - -## Right sidebar - -- `shortcuts.bindings.toggleSidebar` -- `shortcuts.bindings.focusRightSidebar` -- `shortcuts.bindings.switchRightSidebarToFiles` -- `shortcuts.bindings.switchRightSidebarToFind` -- `shortcuts.bindings.switchRightSidebarToSessions` -- `shortcuts.bindings.switchRightSidebarToFeed` -- `shortcuts.bindings.switchRightSidebarToDock` -- `shortcuts.bindings.nextSidebarTab` -- `shortcuts.bindings.prevSidebarTab` - -## Browser - -- `shortcuts.bindings.reopenClosedBrowserPanel` -- `shortcuts.bindings.splitBrowserRight` -- `shortcuts.bindings.splitBrowserDown` -- `shortcuts.bindings.openBrowser` -- `shortcuts.bindings.focusBrowserAddressBar` -- `shortcuts.bindings.browserBack` -- `shortcuts.bindings.browserForward` -- `shortcuts.bindings.browserReload` -- `shortcuts.bindings.browserZoomIn` -- `shortcuts.bindings.browserZoomOut` -- `shortcuts.bindings.browserZoomReset` -- `shortcuts.bindings.toggleBrowserDeveloperTools` -- `shortcuts.bindings.showBrowserJavaScriptConsole` - -## Find - -- `shortcuts.bindings.find` -- `shortcuts.bindings.findInDirectory` -- `shortcuts.bindings.findNext` -- `shortcuts.bindings.findPrevious` -- `shortcuts.bindings.hideFind` -- `shortcuts.bindings.useSelectionForFind` - -## Files and React Grab - -- `shortcuts.bindings.toggleFileExplorer` -- `shortcuts.bindings.saveFilePreview` -- `shortcuts.bindings.toggleReactGrab` diff --git a/skills/cmux-settings/scripts/cmux-settings b/skills/cmux-settings/scripts/cmux-settings deleted file mode 100755 index ddff2b9b9090..000000000000 --- a/skills/cmux-settings/scripts/cmux-settings +++ /dev/null @@ -1,450 +0,0 @@ -#!/usr/bin/env python3 -"""Inspect and edit ~/.config/cmux/cmux.json. - -cmux watches the file and auto-reloads on save, so writes take effect -immediately. The file is JSONC (JSON with // and /* */ comments); this -script writes comment-free formatting (2-space indent, trailing newline) -and only writes when the parsed value actually changes. - -Usage: - cmux-settings path print the config path - cmux-settings dump [--no-comments] print current settings (raw or stripped) - cmux-settings get print value at path (JSON) - cmux-settings set set value (v parsed as JSON, falls back to string) - cmux-settings unset remove value at path - cmux-settings list-supported print every settings path the schema recognizes - cmux-settings validate check JSON parses and all set keys are recognized - cmux-settings open open the file in $EDITOR (or VS Code, then TextEdit) - -Examples: - cmux-settings set app.appearance dark - cmux-settings set notifications.dockBadge false - cmux-settings set shortcuts.bindings.toggleSidebar '"cmd+b"' - cmux-settings set shortcuts.bindings.newTab '["ctrl+b","c"]' - cmux-settings unset app.appearance -""" - -from __future__ import annotations - -import argparse -import json -import os -import re -import shlex -import shutil -import subprocess -import sys -import tempfile -from pathlib import Path -from typing import Any - -DEFAULT_PATH = Path.home() / ".config" / "cmux" / "cmux.json" -SCHEMA_URL = ( - "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux.schema.json" -) - - -def strip_jsonc(text: str) -> str: - """Remove // line comments and /* block comments outside strings.""" - out: list[str] = [] - i, n = 0, len(text) - in_string = False - string_quote = "" - while i < n: - ch = text[i] - if in_string: - out.append(ch) - if ch == "\\" and i + 1 < n: - out.append(text[i + 1]) - i += 2 - continue - if ch == string_quote: - in_string = False - i += 1 - continue - if ch in ('"', "'"): - in_string = True - string_quote = ch - out.append(ch) - i += 1 - continue - if ch == "/" and i + 1 < n and text[i + 1] == "/": - j = text.find("\n", i + 2) - i = n if j == -1 else j - continue - if ch == "/" and i + 1 < n and text[i + 1] == "*": - j = text.find("*/", i + 2) - i = n if j == -1 else j + 2 - continue - out.append(ch) - i += 1 - # Drop trailing commas before ] or } (also legal in JSONC). - return strip_trailing_commas_outside_strings("".join(out)) - - -def strip_trailing_commas_outside_strings(text: str) -> str: - """Remove JSONC trailing commas without touching string contents.""" - out: list[str] = [] - i, n = 0, len(text) - in_string = False - string_quote = "" - while i < n: - ch = text[i] - if in_string: - out.append(ch) - if ch == "\\" and i + 1 < n: - out.append(text[i + 1]) - i += 2 - continue - if ch == string_quote: - in_string = False - i += 1 - continue - if ch in ('"', "'"): - in_string = True - string_quote = ch - out.append(ch) - i += 1 - continue - if ch == ",": - j = i + 1 - while j < n and text[j].isspace(): - j += 1 - if j < n and text[j] in "}]": - i += 1 - continue - out.append(ch) - i += 1 - return "".join(out) - - -def load_settings(path: Path) -> dict[str, Any]: - if not path.exists(): - return {"$schema": SCHEMA_URL, "schemaVersion": 1} - raw = path.read_text() - try: - return json.loads(strip_jsonc(raw)) - except json.JSONDecodeError as e: - raise SystemExit(f"error: {path} is not valid JSONC: {e}") - - -def atomic_write(path: Path, data: dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - encoded = json.dumps(data, indent=2, ensure_ascii=False) + "\n" - with tempfile.NamedTemporaryFile( - mode="w", encoding="utf-8", dir=path.parent, delete=False, suffix=".tmp" - ) as tmp: - tmp.write(encoded) - tmp_path = Path(tmp.name) - os.replace(tmp_path, path) - - -def split_path(dotted: str) -> list[str]: - if not dotted: - raise SystemExit("error: empty key path") - parts = dotted.split(".") - if any(part == "" for part in parts): - raise SystemExit("error: empty key path segment") - return parts - - -def get_at(data: Any, parts: list[str]) -> Any: - cur = data - for p in parts: - if not isinstance(cur, dict) or p not in cur: - raise SystemExit(f"error: path not present: {'.'.join(parts)}") - cur = cur[p] - return cur - - -def set_at(data: dict[str, Any], parts: list[str], value: Any) -> bool: - cur = data - for p in parts[:-1]: - if p not in cur: - cur[p] = {} - elif not isinstance(cur[p], dict): - raise SystemExit( - f"error: intermediate key '{p}' is not an object " - f"(got {type(cur[p]).__name__}); cannot set nested path" - ) - cur = cur[p] - leaf = parts[-1] - changed = (leaf not in cur) or cur[leaf] != value - cur[leaf] = value - return changed - - -def unset_at(data: dict[str, Any], parts: list[str]) -> bool: - trail: list[tuple[dict[str, Any], str]] = [] - cur: Any = data - for p in parts[:-1]: - if not isinstance(cur, dict) or p not in cur: - return False - trail.append((cur, p)) - cur = cur[p] - if not isinstance(cur, dict) or parts[-1] not in cur: - return False - del cur[parts[-1]] - # Drop now-empty ancestor objects so the file stays tidy. - for parent, key in reversed(trail): - if isinstance(parent[key], dict) and not parent[key]: - del parent[key] - else: - break - return True - - -def parse_value(raw: str) -> Any: - """Try JSON first; if it fails, fall back to a plain string.""" - try: - return json.loads(raw) - except json.JSONDecodeError: - return raw - - -def flatten(prefix: str, value: Any) -> list[str]: - if isinstance(value, dict): - out: list[str] = [] - for k, v in value.items(): - child = f"{prefix}.{k}" if prefix else k - out.extend(flatten(child, v)) - return out - return [prefix] - - -def supported_paths_from_source(source: Path) -> list[str]: - text = source.read_text() - known_sections = ( - "app.", - "terminal.", - "notifications.", - "sidebar.", - "workspaceColors.", - "sidebarAppearance.", - "automation.", - "browser.", - "shortcuts.", - ) - candidates = re.findall(r'"([a-zA-Z]+\.[a-zA-Z0-9_.]+)"', text) - return sorted({p for p in candidates if p.startswith(known_sections)}) - - -def supported_paths_from_reference(skill_root: Path | None) -> list[str]: - if skill_root is None: - return [] - reference = skill_root / "references" / "all-keys.md" - if not reference.exists(): - return [] - text = reference.read_text() - return sorted(set(re.findall(r"^\| `([^`]+)` \|", text, re.MULTILINE))) - - -def find_source_file() -> Path | None: - here = Path(__file__).resolve() - for parent in here.parents: - direct = parent / "Sources" / "CmuxSettingsJSONPathSupport.swift" - if direct.exists(): - return direct - hq_checkout = parent / "repo" / "Sources" / "CmuxSettingsJSONPathSupport.swift" - if hq_checkout.exists(): - return hq_checkout - return None - - -def find_skill_root() -> Path | None: - here = Path(__file__).resolve() - for parent in here.parents: - if parent.name == "cmux-settings" and (parent / "SKILL.md").exists(): - return parent - if (parent / "references" / "all-keys.md").exists(): - return parent - return None - - -def supported_paths() -> list[str]: - source = find_source_file() - if source is not None: - return supported_paths_from_source(source) - return supported_paths_from_reference(find_skill_root()) - - -def cmd_path(args: argparse.Namespace) -> int: - print(args.file) - return 0 - - -def cmd_dump(args: argparse.Namespace) -> int: - path = Path(args.file) - if args.no_comments: - data = load_settings(path) - print(json.dumps(data, indent=2, ensure_ascii=False)) - else: - if path.exists(): - sys.stdout.write(path.read_text()) - else: - print(f"# {path} does not exist", file=sys.stderr) - return 1 - return 0 - - -def cmd_get(args: argparse.Namespace) -> int: - data = load_settings(Path(args.file)) - value = get_at(data, split_path(args.key)) - print(json.dumps(value, indent=2, ensure_ascii=False)) - return 0 - - -def cmd_set(args: argparse.Namespace) -> int: - path = Path(args.file) - data = load_settings(path) - parts = split_path(args.key) - value = parse_value(args.value) - changed = set_at(data, parts, value) - if not changed: - print(f"unchanged: {args.key} = {json.dumps(value)}") - return 0 - atomic_write(path, data) - print(f"set: {args.key} = {json.dumps(value)}") - return 0 - - -def cmd_unset(args: argparse.Namespace) -> int: - path = Path(args.file) - data = load_settings(path) - if not unset_at(data, split_path(args.key)): - print(f"unchanged: {args.key} (not present)") - return 0 - atomic_write(path, data) - print(f"unset: {args.key}") - return 0 - - -def cmd_list_supported(args: argparse.Namespace) -> int: - paths = supported_paths() - if not paths: - print( - "error: could not load the supported-paths list; " - "run from a cmux checkout or reinstall the cmux-settings skill", - file=sys.stderr, - ) - return 1 - for p in paths: - print(p) - return 0 - - -def cmd_validate(args: argparse.Namespace) -> int: - path = Path(args.file) - data = load_settings(path) - supported = set(supported_paths()) - if not supported: - print( - "warn: could not load schema path list; only checked JSON parses", - file=sys.stderr, - ) - print(f"ok: {path} parses") - return 0 - unknown: list[str] = [] - # Keep this in sync with top-level structural keys in web/data/cmux.schema.json. - structural = { - "$schema", - "schemaVersion", - "actions", - "ui", - "commands", - "vault", - "newWorkspaceCommand", - "surfaceTabBarButtons", - "rightSidebar", - } - for top, value in data.items(): - if top in structural: - continue - if not isinstance(value, dict): - unknown.append(top) - continue - for full in flatten(top, value): - if full in supported: - continue - if any(full.startswith(s + ".") for s in supported): - continue - unknown.append(full) - if unknown: - print("unknown settings keys:") - for u in unknown: - print(f" {u}") - return 1 - print(f"ok: {path} parses and all settings keys are recognized") - return 0 - - -def cmd_open(args: argparse.Namespace) -> int: - path = Path(args.file) - path.parent.mkdir(parents=True, exist_ok=True) - if not path.exists(): - atomic_write(path, {"$schema": SCHEMA_URL, "schemaVersion": 1}) - editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") - if editor: - try: - editor_args = shlex.split(editor) - except ValueError as e: - print(f"error: invalid editor command: {e}", file=sys.stderr) - return 1 - if not editor_args: - print("error: editor command is empty", file=sys.stderr) - return 1 - try: - return subprocess.call([*editor_args, str(path)]) - except FileNotFoundError: - print(f"error: editor not found: {editor_args[0]}", file=sys.stderr) - return 1 - for app in (["code", "--wait"], ["cursor", "--wait"], ["open", "-e"], ["xdg-open"]): - if shutil.which(app[0]): - try: - return subprocess.call([*app, str(path)]) - except FileNotFoundError: - continue - print( - "error: no suitable editor found; set $EDITOR or install code, cursor, open, or xdg-open", - file=sys.stderr, - ) - return 1 - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument( - "--file", - default=str(DEFAULT_PATH), - help=f"settings file path (default: {DEFAULT_PATH})", - ) - sub = parser.add_subparsers(dest="cmd", required=True) - sub.add_parser("path").set_defaults(func=cmd_path) - - p_dump = sub.add_parser("dump") - p_dump.add_argument("--no-comments", action="store_true") - p_dump.set_defaults(func=cmd_dump) - - p_get = sub.add_parser("get") - p_get.add_argument("key") - p_get.set_defaults(func=cmd_get) - - p_set = sub.add_parser("set") - p_set.add_argument("key") - p_set.add_argument("value") - p_set.set_defaults(func=cmd_set) - - p_unset = sub.add_parser("unset") - p_unset.add_argument("key") - p_unset.set_defaults(func=cmd_unset) - - sub.add_parser("list-supported").set_defaults(func=cmd_list_supported) - sub.add_parser("validate").set_defaults(func=cmd_validate) - sub.add_parser("open").set_defaults(func=cmd_open) - - args = parser.parse_args() - return args.func(args) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/cmux-workspace/SKILL.md b/skills/cmux-workspace/SKILL.md deleted file mode 100644 index ff35f8522e4c..000000000000 --- a/skills/cmux-workspace/SKILL.md +++ /dev/null @@ -1,225 +0,0 @@ ---- -name: cmux-workspace -description: "Work inside the current cmux workspace and terminal. Use for cmux workspace, current workspace, caller surface, panes, surfaces, socket targeting, and non-interfering cmux automation." ---- - -# cmux Workspace - -Use this skill when a task should be scoped to the cmux workspace that invoked the agent. A workspace is the sidebar tab-like unit in cmux. It contains split panes, and each pane contains one or more surfaces. A surface is the terminal or browser session the user interacts with. - -## Default Rule - -Scope actions to the current caller workspace unless the user explicitly asks for another workspace, another window, or global state. - -Do not assume the visually focused cmux workspace is the right target. An agent can be running in one workspace while the user is looking at another. Prefer the caller environment first: - -```bash -printf 'workspace=%s\nsurface=%s\nsocket=%s\n' \ - "${CMUX_WORKSPACE_ID:-}" \ - "${CMUX_SURFACE_ID:-}" \ - "${CMUX_SOCKET_PATH:-}" -cmux identify --json -``` - -Use `CMUX_WORKSPACE_ID` as the default workspace anchor and `CMUX_SURFACE_ID` as the default caller terminal/surface anchor. If those are missing, use `cmux identify --json` and be explicit that you are using the currently focused cmux context. - -## Non-Disruptive Automation - -The user may be visually focused on a different workspace, window, or app while an agent works in the caller workspace. Treat layout and focus as separate concerns. Never call focus-changing verbs speculatively. - -Never call these without an explicit user ask: - -- `select-workspace` switches the visible sidebar tab. -- `focus-pane` / `focus-panel` yanks pane or surface focus. -- `tab-action` with focus-changing actions. - -These are user-affecting actions, like clicks. The rule applies even inside the caller's own workspace, since the user may be looking elsewhere. - -Build layout additively, in one shot. Prefer commands that create a new pane already populated with the right surface: - -```bash -# pane and content in one call, no follow-up needed -cmux new-pane --workspace "${CMUX_WORKSPACE_ID}" --type browser --direction right --url "http://127.0.0.1:8765" -cmux new-pane --workspace "${CMUX_WORKSPACE_ID}" --type terminal --direction down -``` - -Avoid create-then-move-then-focus chains. If a layout command rejects a valid `surface:` or `pane:` ref, do not work around it by focusing. Report the bug to the user and stop. - -Pass `--focus false` whenever the verb supports it. `move-surface --focus false` preserves the user's current attention. Other commands may grow the same flag over time (https://github.com/manaflow-ai/cmux/issues/1418, https://github.com/manaflow-ai/cmux/issues/2820). - -## Right-Side Helper Pane - -When opening auxiliary output for the current task (preview apps, TUIs, logs, one-off shells, browser checks), keep the workspace organized by reusing a helper pane to the right of the caller terminal. - -First inspect the caller context and panes: - -```bash -cmux identify --json -cmux list-panes --workspace "${CMUX_WORKSPACE_ID:-}" --json -cmux list-pane-surfaces --workspace "${CMUX_WORKSPACE_ID:-}" --json -``` - -Use this policy: - -- If the caller workspace already has a non-caller helper pane, add a new surface to that pane instead of creating another pane: - ```bash - cmux new-surface --workspace "${CMUX_WORKSPACE_ID:-}" --pane pane: --type terminal --focus false - ``` -- If there is no helper pane, create exactly one right-side pane: - ```bash - cmux new-pane --workspace "${CMUX_WORKSPACE_ID:-}" --type terminal --direction right --focus false - ``` -- If there are multiple obvious stale helper panes from this same automation and the user asked to tidy or reuse, keep one right helper pane and clean up the duplicates. Do not close panes you cannot confidently identify as stale helper output. -- Send commands to the new or reused helper surface by explicit surface ref. Do not focus it unless the user asks. - -This means repeated "open it" requests should normally create tabs inside the existing right helper pane, not more splits. - -## Hierarchy - -- Window: a macOS cmux window. -- Workspace: a sidebar entry. The UI may call it a tab, but CLI/socket APIs call it a workspace. -- Pane: a split region inside a workspace. -- Surface: a tab inside a pane. Surfaces can be terminals or browser panels. -- Panel: internal content type inside a surface. Prefer CLI surface commands instead of panel internals. - -## Inspect Current Context - -```bash -cmux identify --json -cmux current-workspace --json -cmux list-workspaces --json -cmux list-panes --workspace "${CMUX_WORKSPACE_ID:-}" --json -cmux list-pane-surfaces --workspace "${CMUX_WORKSPACE_ID:-}" --json -cmux list-panels --workspace "${CMUX_WORKSPACE_ID:-}" --json -``` - -Use `--id-format both` when logs or handoffs need stable UUIDs plus human refs: - -```bash -cmux --json --id-format both identify -``` - -## Workspace-Scoped Actions - -Prefer explicit workspace flags even when env vars are set. It makes automation auditable and avoids affecting a focused workspace in another window. - -```bash -# create a new workspace when the user asks for a new task area -cmux new-workspace --name "debug auth" --cwd "$PWD" - -# rename / close (only when explicitly requested) -cmux rename-workspace --workspace "${CMUX_WORKSPACE_ID:-}" -- "build fix" -cmux close-workspace --workspace workspace:4 -cmux close-surface --workspace "${CMUX_WORKSPACE_ID:-}" --surface surface:3 - -# additive layout (safe, no focus side effects beyond the command's own defaults) -cmux new-pane --workspace "${CMUX_WORKSPACE_ID:-}" --type terminal --direction right -cmux new-surface --workspace "${CMUX_WORKSPACE_ID:-}" --type terminal - -# focus-changing (USER-AFFECTING, only on explicit ask, see Non-Disruptive Automation above) -cmux select-workspace --workspace workspace:2 -cmux focus-pane --workspace "${CMUX_WORKSPACE_ID:-}" --pane pane:2 -cmux focus-panel --workspace "${CMUX_WORKSPACE_ID:-}" --panel surface:3 -``` - -## Caller Terminal - -The current terminal is the surface that invoked the agent. Treat it as the safest anchor for relative operations. - -```bash -# send to the focused terminal in the caller workspace -cmux send "npm test\n" - -# send to the exact caller surface -cmux send --surface "${CMUX_SURFACE_ID:-}" "git status\n" -cmux send-key --surface "${CMUX_SURFACE_ID:-}" enter -``` - -Do not send keystrokes, close surfaces, or change focus in other workspaces unless the user asked for that target. - -## Moving Surfaces - -Reorder a surface within its pane: - -```bash -cmux move-surface --surface "${CMUX_SURFACE_ID}" --before surface:3 -cmux move-surface --surface "${CMUX_SURFACE_ID}" --after surface:3 -cmux move-surface --surface "${CMUX_SURFACE_ID}" --index 0 -``` - -Move a surface to another existing pane. Pass `--focus false` to keep the user's current attention put: - -```bash -cmux move-surface --surface surface:240 --pane pane:172 --focus false -``` - -Split a surface off into a new pane: - -```bash -cmux drag-surface-to-split --surface surface:240 down -``` - -Known papercut: `drag-surface-to-split` currently routes through V1 and resolves the workspace via UI focus, so it can fail with `ERROR: Surface not found` when the caller's workspace is not the visually focused one. Tracked at https://github.com/manaflow-ai/cmux/issues/1901, related to https://github.com/manaflow-ai/cmux/issues/3189. Until that lands, prefer building the layout additively (see Non-Disruptive Automation above) over create-then-split. - -Do not call `focus-pane` or `focus-panel` to recover from a failed move. Report the failure and stop. - -## Sidebar State - -Status, progress, and logs should usually be attached to the current workspace so the sidebar reflects this task. - -```bash -cmux set-status build "running" --workspace "${CMUX_WORKSPACE_ID:-}" --color "#ff9500" -cmux set-progress 0.4 --label "Building" --workspace "${CMUX_WORKSPACE_ID:-}" -cmux log --workspace "${CMUX_WORKSPACE_ID:-}" --level info -- "Started build" -cmux sidebar-state --workspace "${CMUX_WORKSPACE_ID:-}" --json -cmux clear-status build --workspace "${CMUX_WORKSPACE_ID:-}" -cmux clear-progress --workspace "${CMUX_WORKSPACE_ID:-}" -``` - -## Contributor Reloads - -For cmux app/runtime changes in a cmux source checkout, use tagged reloads from the active worktree. A tagged reload creates an isolated app name, bundle ID, debug socket, and DerivedData path. - -```bash -./scripts/reload.sh --tag -``` - -Never build or launch untagged `cmux DEV`. If tests or tools need a socket, use the tag-specific socket: - -```bash -CMUX_SOCKET_PATH=/tmp/cmux-debug-.sock cmux identify --json -``` - -## Socket and Access - -Use the socket path provided by cmux before falling back to defaults: - -```bash -SOCK="${CMUX_SOCKET_PATH:-/tmp/cmux.sock}" -``` - -Socket access can be off, restricted to cmux-spawned processes, or allow all local processes. If a command cannot connect, inspect capabilities before changing settings: - -```bash -cmux capabilities --json -cmux ping -``` - -## References - -- [references/commands.md](references/commands.md) enumerates workspace, pane, surface, notification, and utility commands. -- [../cmux-browser/SKILL.md](../cmux-browser/SKILL.md) covers browser surfaces with the same current-workspace rule. - -## Rules - -- Work in the current caller workspace by default. -- Use `CMUX_WORKSPACE_ID`, `CMUX_SURFACE_ID`, and `CMUX_SOCKET_PATH` before focused-window fallbacks. -- Prefer explicit `--workspace` and `--surface` flags for mutating actions. -- Never call `focus-pane`, `focus-panel`, `select-workspace`, or focus-changing `tab-action` verbs unless the user explicitly asked. The user may be visually on a different workspace, window, or app. -- Pass `--focus false` on `move-surface` and any creation verb that supports it. -- For auxiliary output, reuse the right-side helper pane; create one only if it does not exist. -- Build layout additively with `new-pane --type ... --url ...` rather than create-then-move-then-focus chains. -- If a CLI command rejects a valid surface or pane ref, report it to the user. Do not work around by focusing. -- Do not close, focus, move, or send input to another workspace unless the user names that target. -- Use short refs for chat and command examples. Use UUIDs only for logs, persistence, or debugging. -- For app/runtime changes in a cmux source checkout, reload with `./scripts/reload.sh --tag ` from the worktree before dogfood handoff. diff --git a/skills/cmux-workspace/agents/openai.yaml b/skills/cmux-workspace/agents/openai.yaml deleted file mode 100644 index e71720a9e6cf..000000000000 --- a/skills/cmux-workspace/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "cmux Workspace" - short_description: "Scope cmux automation to the current workspace, caller terminal, panes, and surfaces." - default_prompt: "Use this skill to identify the current cmux caller workspace and surface, then keep workspace, pane, surface, browser, sidebar, and socket actions scoped to that context unless the user explicitly names another target." diff --git a/skills/cmux-workspace/references/commands.md b/skills/cmux-workspace/references/commands.md deleted file mode 100644 index ff91b9f3213d..000000000000 --- a/skills/cmux-workspace/references/commands.md +++ /dev/null @@ -1,110 +0,0 @@ -# cmux Workspace Command Reference - -Use these commands from a cmux terminal. Most commands infer the caller workspace from `CMUX_WORKSPACE_ID`, but explicit flags are safer for automation. - -## Context - -```bash -cmux identify --json -cmux current-workspace --json -cmux capabilities --json -cmux ping -``` - -## Windows and Workspaces - -```bash -cmux list-windows -cmux current-window -cmux new-window -cmux focus-window --window window:2 -cmux close-window --window window:2 - -cmux list-workspaces -cmux list-workspaces --json -cmux new-workspace --name "task" --cwd "$PWD" -cmux new-workspace --command "npm run dev" -cmux new-workspace --layout '{"root":{"type":"terminal"}}' -cmux current-workspace -cmux select-workspace --workspace workspace:2 -cmux rename-workspace --workspace workspace:2 -- "new name" -cmux close-workspace --workspace workspace:2 -cmux reorder-workspace --workspace workspace:4 --before workspace:2 -cmux move-workspace-to-window --workspace workspace:4 --window window:1 -``` - -## Panes and Surfaces - -```bash -cmux list-panes --workspace "$CMUX_WORKSPACE_ID" -cmux list-pane-surfaces --workspace "$CMUX_WORKSPACE_ID" --pane pane:1 -cmux list-panels --workspace "$CMUX_WORKSPACE_ID" -cmux tree --workspace "$CMUX_WORKSPACE_ID" - -cmux new-split right --workspace "$CMUX_WORKSPACE_ID" -cmux new-split down --workspace "$CMUX_WORKSPACE_ID" --surface "$CMUX_SURFACE_ID" -cmux new-pane --workspace "$CMUX_WORKSPACE_ID" --type terminal --direction right -cmux new-pane --workspace "$CMUX_WORKSPACE_ID" --type browser --url http://localhost:3000 -cmux new-surface --workspace "$CMUX_WORKSPACE_ID" --type terminal --pane pane:1 -cmux new-surface --workspace "$CMUX_WORKSPACE_ID" --type browser --pane pane:1 --url http://localhost:3000 - -cmux focus-pane --workspace "$CMUX_WORKSPACE_ID" --pane pane:2 -cmux focus-panel --workspace "$CMUX_WORKSPACE_ID" --panel surface:3 -cmux close-surface --workspace "$CMUX_WORKSPACE_ID" --surface surface:3 -cmux move-surface --surface surface:7 --pane pane:2 --focus true -cmux reorder-surface --surface surface:7 --before surface:3 -cmux move-tab-to-new-workspace --surface surface:7 --title "browser" -``` - -## Input - -```bash -cmux send "echo hello\n" -cmux send-key enter -cmux send --surface "$CMUX_SURFACE_ID" "git status\n" -cmux send-key --surface "$CMUX_SURFACE_ID" enter -cmux read-screen --surface "$CMUX_SURFACE_ID" -``` - -## Sidebar Metadata - -```bash -cmux set-status build "running" --workspace "$CMUX_WORKSPACE_ID" --icon hammer --color "#ff9500" -cmux clear-status build --workspace "$CMUX_WORKSPACE_ID" -cmux list-status --workspace "$CMUX_WORKSPACE_ID" -cmux set-progress 0.5 --workspace "$CMUX_WORKSPACE_ID" --label "Building" -cmux clear-progress --workspace "$CMUX_WORKSPACE_ID" -cmux log --workspace "$CMUX_WORKSPACE_ID" --level info -- "Build started" -cmux list-log --workspace "$CMUX_WORKSPACE_ID" --limit 20 -cmux clear-log --workspace "$CMUX_WORKSPACE_ID" -cmux sidebar-state --workspace "$CMUX_WORKSPACE_ID" --json -``` - -## Notifications and Attention - -```bash -cmux notify --title "Done" --body "Task complete" -cmux list-notifications --json -cmux clear-notifications -cmux trigger-flash --workspace "$CMUX_WORKSPACE_ID" --surface "$CMUX_SURFACE_ID" -cmux surface-health --workspace "$CMUX_WORKSPACE_ID" --json -``` - -## Config and Docs - -```bash -cmux docs api -cmux docs browser -cmux docs settings -cmux settings path -cmux settings cmux-json -cmux settings shortcuts -cmux reload-config -``` - -## Tagged Reloads - -```bash -./scripts/reload.sh --tag -CMUX_SOCKET_PATH=/tmp/cmux-debug-.sock cmux identify --json -``` diff --git a/skills/cmux/SKILL.md b/skills/cmux/SKILL.md index 7da5e71d9bd9..78e0c57c2e3a 100644 --- a/skills/cmux/SKILL.md +++ b/skills/cmux/SKILL.md @@ -78,7 +78,5 @@ cmux settings shortcuts | [references/windows-workspaces.md](references/windows-workspaces.md) | Window/workspace lifecycle and reorder/move | | [references/panes-surfaces.md](references/panes-surfaces.md) | Splits, surfaces, move/reorder, focus routing | | [references/trigger-flash-and-health.md](references/trigger-flash-and-health.md) | Flash cue and surface health checks | -| [../cmux-workspace/SKILL.md](../cmux-workspace/SKILL.md) | Current caller workspace rules and non-disruptive automation | -| [../cmux-settings/SKILL.md](../cmux-settings/SKILL.md) | Safe cmux.json settings edits and validation | | [../cmux-browser/SKILL.md](../cmux-browser/SKILL.md) | Browser automation on surface-backed webviews | | [../cmux-markdown/SKILL.md](../cmux-markdown/SKILL.md) | Markdown viewer panel with live file watching | diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md new file mode 100644 index 000000000000..d2a739eb5a5a --- /dev/null +++ b/skills/release/SKILL.md @@ -0,0 +1,79 @@ +--- +name: release +description: "Prepare and ship a cmux release end-to-end: choose the next version, curate user-facing changelog entries, bump versions, open and monitor a release PR, merge, tag, and verify published artifacts. Use when asked to cut, prepare, publish, or tag a new release." +--- + +# Release + +Run this workflow to prepare and publish a cmux release. + +## Workflow + +1. Determine the version: +- Read `MARKETING_VERSION` from `cmux.xcodeproj/project.pbxproj`. +- Default to a minor bump unless the user explicitly requests patch/major/specific version. + +2. Create a release branch: +- `git checkout -b release/vX.Y.Z` + +3. Gather user-facing changes and contributors since the last tag: +- `git describe --tags --abbrev=0` +- `git log --oneline ..HEAD --no-merges` +- Keep only end-user visible changes (features, bug fixes, UX/perf behavior). +- **Collect contributors:** For each PR, get the author with `gh pr view --repo manaflow-ai/cmux --json author --jq '.author.login'`. Also check linked issue reporters with `gh issue view --json author --jq '.author.login'`. +- Build a deduplicated list of all contributor `@handle`s. + +4. Update changelogs: +- `CHANGELOG.md` is the source of truth: `web/app/[locale]/docs/changelog/page.tsx` renders the per-version bullet lists directly from it. +- Update `CHANGELOG.md` using categories `Added`, `Changed`, `Fixed`, `Removed`. +- **Credit contributors inline** (see Contributor Credits below). +- **Confirm shipped scope with the user before drafting user-facing entries.** A merged PR is not the same as a shipped feature: features can be behind beta toggles, internal flags, partially landed, reverted later, or otherwise not user-ready. Before writing the `Added`/`Changed` sections, list the candidate user-facing items and ask the user which are actually ready to ship in this version. Drop anything they say is not ready, plus any entries that depend on it. Re-confirm if the version, scope, or shipped surface changes mid-release. +- Mirror the curated highlights in `web/app/[locale]/docs/changelog/changelog-media.ts` to the same shipped scope. The docs changelog page reads its hero title and feature highlights from this file (bullets still come from `CHANGELOG.md`). If the user drops a feature, also remove it from the version's `features` and update its `title` so the highlights match the bullets. +- If no user-facing changes exist, confirm with the user before continuing. + +5. Bump app version metadata: +- Prefer `./scripts/bump-version.sh`: + - `./scripts/bump-version.sh` (minor) + - `./scripts/bump-version.sh patch|major|X.Y.Z` +- Ensure both `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` are updated. + +6. Commit and push branch: +- Stage release files (changelog + version updates). +- Commit with `Bump version to X.Y.Z`. +- `git push -u origin release/vX.Y.Z`. + +7. Create release PR: +- `gh pr create --title "Release vX.Y.Z" --body "..."` +- Include a concise changelog summary in the PR body. + +8. Watch CI and resolve failures: +- `gh pr checks --watch` +- Fix failing checks, push, and wait for green. + +9. Merge and sync `main`: +- `gh pr merge --squash --delete-branch` +- `git checkout main && git pull --ff-only` + +10. Run the pre-tag guard, then create and push tag: +- `./scripts/release-pretag-guard.sh` +- If it fails, run `./scripts/bump-version.sh`, commit the build-number bump, push/merge that change, and retry the tag. +- `git tag vX.Y.Z` +- `git push origin vX.Y.Z` + +11. Verify release workflow and assets: +- `gh run watch --repo manaflow-ai/cmux` +- Confirm release exists in GitHub Releases and includes `cmux-macos.dmg`. + +## Changelog Rules + +- Include only user-visible changes that actually ship in this version. A merged PR alone is not enough: confirm with the user that each candidate feature is shipped (not behind a beta toggle, not partially landed, not deferred). When in doubt, ask before listing it. +- Exclude internal-only changes (CI, tests, docs-only edits, refactors without behavior changes). +- Write concise user-facing bullets in present tense. + +## Contributor Credits + +Credit the people who made each release happen: + +- **Per-entry:** Append `— thanks @user!` for community code contributions. Use `— thanks @user for the report!` for bug reporters (when different from PR author). No callout for core team (`lawrencecchen`, `austinywang`) — core work is the baseline. +- **Summary:** Add a `### Thanks to N contributors!` section at the bottom of each release with an alphabetical list of all `[@handle](https://github.com/handle)` links (including core team). +- **GitHub Release body:** Include the same "Thanks to N contributors!" section with linked handles. diff --git a/skills/release/agents/openai.yaml b/skills/release/agents/openai.yaml new file mode 100644 index 000000000000..ea4bcfb97820 --- /dev/null +++ b/skills/release/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "cmux Release" + short_description: "Prepare and publish cmux releases from version bump to tag." + default_prompt: "Use this skill to run the cmux release workflow: choose version, generate user-facing changelog updates, bump versions, open and monitor release PRs, merge, tag, and verify release artifacts." diff --git a/tests/test_codex_feed_hooks.py b/tests/test_codex_feed_hooks.py index 450b57418c9c..aaba095537c3 100644 --- a/tests/test_codex_feed_hooks.py +++ b/tests/test_codex_feed_hooks.py @@ -1378,61 +1378,23 @@ def test_install_recovers_hook_trust_when_cmux_marker_is_unclosed( ) -def test_install_preserves_plugin_tables_inside_stale_cmux_hook_trust_marker( +def test_install_preserves_third_party_hook_trust_inside_cmux_marker( cli_path: str, root: Path ) -> None: - codex_home = root / "codex-home-stale-trust-with-plugins" + codex_home = root / "codex-home-stale-trust-with-third-party" codex_home.mkdir() hooks_path = codex_home / "hooks.json" stale_key = f"{hooks_path.resolve()}:pre_tool_use:0:0" - stale_old_cmux_key = f"{hooks_path.resolve()}:pre_tool_use:9:0" - stale_old_cmux_hash = codex_command_hook_hash( - event_label="pre_tool_use", - matcher=None, - command=cmux_codex_feed_command("PreToolUse"), - timeout=120_000, - status_message=None, - ) - stale_legacy_key = f"{hooks_path.resolve()}:pre_tool_use:10:0" - stale_legacy_hash = codex_command_hook_hash( - event_label="pre_tool_use", - matcher=None, - command="cmux feed-hook --source codex --event PreToolUse", - timeout=120_000, - status_message=None, - ) - same_file_user_key = f"{hooks_path.resolve()}:pre_tool_use:8:0" - escaped_user_key = "/tmp/third-party\\t/hooks.json:pre_tool_use:0:0" third_party_key = "/tmp/third-party/hooks.json:pre_tool_use:0:0" config_path = codex_home / "config.toml" config_path.write_text( "[features]\n" "hooks = true\n" "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 begin\n" - "preserve_loose_config = true\n" f'[ hooks . state . "{stale_key}" ] # stale cmux trust\n' 'trusted_hash = "sha256:stale"\n' - "\n" - f'[hooks.state."{escaped_user_key}"]\n' - 'trusted_hash = "sha256:escaped-user"\n' - "\n" - f'[hooks.state."{stale_old_cmux_key}"]\n' - f'trusted_hash = "{stale_old_cmux_hash}"\n' - "\n" - f'[hooks.state."{stale_legacy_key}"]\n' - f'trusted_hash = "{stale_legacy_hash}"\n' - "\n" - f'[hooks.state."{same_file_user_key}"]\n' - 'trusted_hash = "sha256:same-file-user"\n' - "\n" f'[hooks.state."{third_party_key}"]\n' 'trusted_hash = "sha256:third-party"\n' - "\n" - '[plugins."documents@openai-primary-runtime"]\n' - "enabled = true\n" - "\n" - '[plugins."browser@openai-bundled"]\n' - "enabled = true\n" "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 end\n", encoding="utf-8", ) @@ -1453,40 +1415,10 @@ def test_install_preserves_plugin_tables_inside_stale_cmux_hook_trust_marker( ) config_toml = config_path.read_text(encoding="utf-8") - if '[plugins."documents@openai-primary-runtime"]' not in config_toml: - raise AssertionError(f"documents plugin table was removed: {config_toml!r}") - if '[plugins."browser@openai-bundled"]' not in config_toml: - raise AssertionError(f"browser plugin table was removed: {config_toml!r}") - if "preserve_loose_config = true" not in config_toml: - raise AssertionError(f"loose config line was removed: {config_toml!r}") if 'trusted_hash = "sha256:third-party"' not in config_toml: raise AssertionError(f"third-party hook trust was removed: {config_toml!r}") - if 'trusted_hash = "sha256:same-file-user"' not in config_toml: - raise AssertionError(f"same-file user hook trust was removed: {config_toml!r}") - if 'trusted_hash = "sha256:escaped-user"' not in config_toml: - raise AssertionError(f"escaped-key user hook trust was removed: {config_toml!r}") if 'trusted_hash = "sha256:stale"' in config_toml: raise AssertionError(f"stale cmux hook trust was preserved: {config_toml!r}") - if stale_old_cmux_key in config_toml: - raise AssertionError(f"old cmux hook trust was preserved: {config_toml!r}") - if stale_legacy_key in config_toml or stale_legacy_hash in config_toml: - raise AssertionError(f"legacy cmux hook trust was preserved: {config_toml!r}") - trust_begin = "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 begin" - trust_end = "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 end" - if config_toml.count(trust_begin) != 1: - raise AssertionError(f"install did not write one fresh cmux hook trust marker: {config_toml!r}") - if config_toml.count(trust_end) != 1: - raise AssertionError(f"install did not close the fresh cmux trust block: {config_toml!r}") - trust_begin_index = config_toml.index(trust_begin) - trust_end_index = config_toml.index(trust_end) - for plugin_header in ( - '[plugins."documents@openai-primary-runtime"]', - '[plugins."browser@openai-bundled"]', - ): - plugin_index = config_toml.index(plugin_header) - if trust_begin_index < plugin_index < trust_end_index: - raise AssertionError(f"plugin table remained inside fresh cmux trust block: {config_toml!r}") - hooks = json.loads(hooks_path.read_text(encoding="utf-8")) state = codex_hook_trust_state(config_toml) expected_trust = expected_cmux_codex_hook_trust(hooks, hooks_path) @@ -1497,113 +1429,10 @@ def test_install_preserves_plugin_tables_inside_stale_cmux_hook_trust_marker( ) -def test_install_enables_hooks_when_stale_trust_marker_captures_dotted_feature( - cli_path: str, root: Path -) -> None: - codex_home = root / "codex-home-stale-trust-with-dotted-feature" - codex_home.mkdir() - hooks_path = codex_home / "hooks.json" - stale_key = f"{hooks_path.resolve()}:pre_tool_use:0:0" - config_path = codex_home / "config.toml" - config_path.write_text( - "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 begin\n" - f'[hooks.state."{stale_key}"]\n' - 'trusted_hash = "sha256:stale"\n' - "features.experimental = true\n" - "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 end\n", - encoding="utf-8", - ) - env = os.environ.copy() - env["CODEX_HOME"] = str(codex_home) - - result = subprocess.run( - [cli_path, "hooks", "codex", "install", "--yes"], - capture_output=True, - text=True, - check=False, - env=env, - timeout=20, - ) - if result.returncode != 0: - raise AssertionError( - f"hooks codex install failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" - ) - - config_toml = config_path.read_text(encoding="utf-8") - if "hooks = true" not in config_toml and "features.hooks = true" not in config_toml: - raise AssertionError(f"install did not enable Codex hooks: {config_toml!r}") - if 'trusted_hash = "sha256:stale"' in config_toml: - raise AssertionError(f"stale cmux hook trust was preserved: {config_toml!r}") - - -def test_uninstall_preserves_third_party_hook_trust_inside_cmux_marker( - cli_path: str, root: Path -) -> None: - codex_home = root / "codex-home-uninstall-stale-third-party-trust" - codex_home.mkdir() - env = os.environ.copy() - env["CODEX_HOME"] = str(codex_home) - - result = subprocess.run( - [cli_path, "hooks", "codex", "install", "--yes"], - capture_output=True, - text=True, - check=False, - env=env, - timeout=20, - ) - if result.returncode != 0: - raise AssertionError( - f"hooks codex install failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" - ) - - config_path = codex_home / "config.toml" - trust_end = "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 end" - same_file_user_key = f"{(codex_home / 'hooks.json').resolve()}:pre_tool_use:8:0" - third_party_key = "/tmp/third-party/hooks.json:pre_tool_use:0:0" - config_toml = config_path.read_text(encoding="utf-8") - config_path.write_text( - config_toml.replace( - trust_end, - f'[hooks.state."{same_file_user_key}"]\n' - 'trusted_hash = "sha256:same-file-user"\n' - f'[hooks.state."{third_party_key}"]\n' - 'trusted_hash = "sha256:third-party"\n' - f"{trust_end}", - ), - encoding="utf-8", - ) - - result = subprocess.run( - [cli_path, "hooks", "codex", "uninstall", "--yes"], - capture_output=True, - text=True, - check=False, - env=env, - timeout=20, - ) - if result.returncode != 0: - raise AssertionError( - f"hooks codex uninstall failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" - ) - - config_toml = config_path.read_text(encoding="utf-8") - if "cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738" in config_toml: - raise AssertionError(f"cmux hook trust marker was preserved: {config_toml!r}") - state = codex_hook_trust_state(config_toml) - for key in state: - if key.startswith(f"{(codex_home / 'hooks.json').resolve()}:") and key != same_file_user_key: - raise AssertionError(f"cmux hook trust was preserved: {config_toml!r}") - if 'trusted_hash = "sha256:same-file-user"' not in config_toml: - raise AssertionError(f"same-file user hook trust was removed: {config_toml!r}") - if 'trusted_hash = "sha256:third-party"' not in config_toml: - raise AssertionError(f"third-party hook trust was removed: {config_toml!r}") - - -def test_uninstall_retry_removes_stale_cmux_hook_trust_after_hooks_are_cleaned( +def test_uninstall_removes_standalone_cmux_hook_trust_tables( cli_path: str, root: Path ) -> None: - codex_home = root / "codex-home-uninstall-retry-stale-trust" + codex_home = root / "codex-home-uninstall-standalone-trust" codex_home.mkdir() env = os.environ.copy() env["CODEX_HOME"] = str(codex_home) @@ -1627,177 +1456,17 @@ def test_uninstall_retry_removes_stale_cmux_hook_trust_after_hooks_are_cleaned( if not expected_trust: raise AssertionError(f"expected cmux Codex trust entries, got {expected_trust!r}") - config_path = codex_home / "config.toml" - trust_end = "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 end" - same_file_user_key = f"{hooks_path.resolve()}:pre_tool_use:8:0" - legacy_key = f"{hooks_path.resolve()}:pre_tool_use:9:0" - legacy_hash = codex_command_hook_hash( - event_label="pre_tool_use", - matcher=None, - command="cmux feed-hook --source codex --event PreToolUse", - timeout=120_000, - status_message=None, - ) + cmux_key, cmux_hash = next(iter(expected_trust.items())) third_party_key = "/tmp/third-party/hooks.json:pre_tool_use:0:0" - config_toml = config_path.read_text(encoding="utf-8") - config_path.write_text( - config_toml.replace( - trust_end, - f'[hooks.state."{same_file_user_key}"]\n' - 'trusted_hash = "sha256:same-file-user"\n' - f'[hooks.state."{legacy_key}"]\n' - f'trusted_hash = "{legacy_hash}"\n' + config_path = codex_home / "config.toml" + with config_path.open("a", encoding="utf-8") as handle: + handle.write( + f'[hooks.state."{cmux_key}"]\n' + f'trusted_hash = "{cmux_hash}"\n' f'[hooks.state."{third_party_key}"]\n' 'trusted_hash = "sha256:third-party"\n' - f"{trust_end}", - ), - encoding="utf-8", - ) - hooks_path.write_text('{"hooks": {}}\n', encoding="utf-8") - - result = subprocess.run( - [cli_path, "hooks", "codex", "uninstall", "--yes"], - capture_output=True, - text=True, - check=False, - env=env, - timeout=20, - ) - if result.returncode != 0: - raise AssertionError( - f"hooks codex uninstall retry failed exit={result.returncode}\n" - f"stdout={result.stdout}\nstderr={result.stderr}" - ) - - config_toml = config_path.read_text(encoding="utf-8") - if "cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738" in config_toml: - raise AssertionError(f"cmux hook trust marker was preserved: {config_toml!r}") - for key, trusted_hash in expected_trust.items(): - if key in config_toml or trusted_hash in config_toml: - raise AssertionError(f"stale cmux hook trust was preserved: {config_toml!r}") - if legacy_key in config_toml or legacy_hash in config_toml: - raise AssertionError(f"legacy cmux hook trust was preserved: {config_toml!r}") - if 'trusted_hash = "sha256:same-file-user"' not in config_toml: - raise AssertionError(f"same-file user hook trust was removed: {config_toml!r}") - if 'trusted_hash = "sha256:third-party"' not in config_toml: - raise AssertionError(f"third-party hook trust was removed: {config_toml!r}") - - -def test_uninstall_retry_preserves_user_hook_trust_at_default_cmux_key( - cli_path: str, root: Path -) -> None: - codex_home = root / "codex-home-uninstall-retry-user-default-key" - codex_home.mkdir() - hooks_path = codex_home / "hooks.json" - config_path = codex_home / "config.toml" - user_key = f"{hooks_path.resolve()}:pre_tool_use:0:0" - user_hooks = { - "hooks": { - "PreToolUse": [ - { - "hooks": [ - {"type": "command", "command": "printf user", "timeout": 1000} - ] - } - ] - } - } - hooks_path.write_text(json.dumps(user_hooks, indent=2) + "\n", encoding="utf-8") - config_path.write_text( - f'[hooks.state."{user_key}"]\n' - 'trusted_hash = "sha256:user-default-index"\n', - encoding="utf-8", - ) - env = os.environ.copy() - env["CODEX_HOME"] = str(codex_home) - - result = subprocess.run( - [cli_path, "hooks", "codex", "install", "--yes"], - capture_output=True, - text=True, - check=False, - env=env, - timeout=20, - ) - if result.returncode != 0: - raise AssertionError( - f"hooks codex install failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" ) - installed_hooks = json.loads(hooks_path.read_text(encoding="utf-8")) - expected_trust = expected_cmux_codex_hook_trust(installed_hooks, hooks_path) - if not expected_trust: - raise AssertionError(f"expected cmux Codex trust entries, got {expected_trust!r}") - hooks_path.write_text(json.dumps(user_hooks, indent=2) + "\n", encoding="utf-8") - - result = subprocess.run( - [cli_path, "hooks", "codex", "uninstall", "--yes"], - capture_output=True, - text=True, - check=False, - env=env, - timeout=20, - ) - if result.returncode != 0: - raise AssertionError( - f"hooks codex uninstall retry failed exit={result.returncode}\n" - f"stdout={result.stdout}\nstderr={result.stderr}" - ) - - config_toml = config_path.read_text(encoding="utf-8") - if 'trusted_hash = "sha256:user-default-index"' not in config_toml: - raise AssertionError(f"user hook trust at default cmux key was removed: {config_toml!r}") - for key, trusted_hash in expected_trust.items(): - if trusted_hash in config_toml: - raise AssertionError(f"stale cmux hook trust was preserved: {config_toml!r}") - if key != user_key and key in config_toml: - raise AssertionError(f"stale cmux hook trust was preserved: {config_toml!r}") - - -def test_uninstall_removes_legacy_codex_hook_trust(cli_path: str, root: Path) -> None: - codex_home = root / "codex-home-uninstall-legacy-trust" - codex_home.mkdir() - hooks_path = codex_home / "hooks.json" - config_path = codex_home / "config.toml" - legacy_command = "cmux feed-hook --source codex --event PreToolUse" - legacy_key = f"{hooks_path.resolve()}:pre_tool_use:0:0" - legacy_hash = codex_command_hook_hash( - event_label="pre_tool_use", - matcher=None, - command=legacy_command, - timeout=120_000, - status_message=None, - ) - hooks_path.write_text( - json.dumps( - { - "hooks": { - "PreToolUse": [ - { - "hooks": [ - { - "type": "command", - "command": legacy_command, - "timeout": 120_000, - } - ] - } - ] - } - }, - indent=2, - ) - + "\n", - encoding="utf-8", - ) - config_path.write_text( - f'[hooks.state."{legacy_key}"]\n' - f'trusted_hash = "{legacy_hash}"\n', - encoding="utf-8", - ) - env = os.environ.copy() - env["CODEX_HOME"] = str(codex_home) - result = subprocess.run( [cli_path, "hooks", "codex", "uninstall", "--yes"], capture_output=True, @@ -1811,12 +1480,11 @@ def test_uninstall_removes_legacy_codex_hook_trust(cli_path: str, root: Path) -> f"hooks codex uninstall failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" ) - hooks = json.loads(hooks_path.read_text(encoding="utf-8")) - if legacy_command in json.dumps(hooks): - raise AssertionError(f"legacy cmux hook command was preserved: {hooks!r}") config_toml = config_path.read_text(encoding="utf-8") - if legacy_key in config_toml or legacy_hash in config_toml: - raise AssertionError(f"legacy cmux hook trust was preserved: {config_toml!r}") + if cmux_key in config_toml or cmux_hash in config_toml: + raise AssertionError(f"standalone cmux hook trust was preserved: {config_toml!r}") + if 'trusted_hash = "sha256:third-party"' not in config_toml: + raise AssertionError(f"third-party hook trust was removed: {config_toml!r}") def test_uninstall_codex_hooks_removes_legacy_managed_block(cli_path: str, root: Path) -> None: @@ -2097,12 +1765,8 @@ def main() -> int: test_uninstall_removes_cmux_owned_codex_hooks_feature(cli_path, root) test_uninstall_preserves_unowned_hook_trust_when_cmux_marker_is_unclosed(cli_path, root) test_install_recovers_hook_trust_when_cmux_marker_is_unclosed(cli_path, root) - test_install_preserves_plugin_tables_inside_stale_cmux_hook_trust_marker(cli_path, root) - test_install_enables_hooks_when_stale_trust_marker_captures_dotted_feature(cli_path, root) - test_uninstall_preserves_third_party_hook_trust_inside_cmux_marker(cli_path, root) - test_uninstall_retry_removes_stale_cmux_hook_trust_after_hooks_are_cleaned(cli_path, root) - test_uninstall_retry_preserves_user_hook_trust_at_default_cmux_key(cli_path, root) - test_uninstall_removes_legacy_codex_hook_trust(cli_path, root) + test_install_preserves_third_party_hook_trust_inside_cmux_marker(cli_path, root) + test_uninstall_removes_standalone_cmux_hook_trust_tables(cli_path, root) test_uninstall_codex_hooks_removes_legacy_managed_block(cli_path, root) test_install_surfaces_invalid_codex_config_encoding(cli_path, root) test_uninstall_surfaces_invalid_codex_config_encoding(cli_path, root) diff --git a/tests_v2/test_background_split_send_text_starts_terminal.py b/tests_v2/test_background_split_send_text_starts_terminal.py deleted file mode 100644 index 32f14688fbe1..000000000000 --- a/tests_v2/test_background_split_send_text_starts_terminal.py +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env python3 -"""Regression: surface.send_text must start a background split terminal without selecting it.""" - -from __future__ import annotations - -import logging -import os -import shlex -import sys -import tempfile -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock") - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _wait_for_file_text(path: Path, needle: str, timeout_s: float = 8.0) -> str: - deadline = time.time() + timeout_s - last_text = "" - while time.time() < deadline: - if path.exists(): - last_text = path.read_text(encoding="utf-8", errors="replace") - if needle in last_text: - return last_text - time.sleep(0.1) - raise cmuxError(f"Timed out waiting for {needle!r} from background split terminal: {last_text!r}") - - -def _first_terminal_surface_id(payload: dict) -> str: - surfaces = payload.get("surfaces") or [] - for row in surfaces: - if row.get("type") == "terminal": - surface_id = str(row.get("id") or "") - if surface_id: - return surface_id - raise cmuxError(f"surface.list returned no terminal surface: {payload}") - - -def main() -> int: - with cmux(SOCKET_PATH) as c: - baseline_workspace = c.current_workspace() - created_workspace = "" - marker_path = Path(tempfile.gettempdir()) / f"cmux-bg-split-send-{time.time_ns()}.txt" - try: - payload = c._call("workspace.create", {}) or {} - created_workspace = str(payload.get("workspace_id") or "") - _must(bool(created_workspace), f"workspace.create returned no workspace_id: {payload}") - _must( - c.current_workspace() == baseline_workspace, - "workspace.create should preserve selected workspace", - ) - - surfaces_payload = c._call("surface.list", {"workspace_id": created_workspace}) or {} - initial_surface = _first_terminal_surface_id(surfaces_payload) - - split_payload = c._call( - "surface.split", - { - "workspace_id": created_workspace, - "surface_id": initial_surface, - "direction": "right", - "focus": False, - }, - ) or {} - split_surface = str(split_payload.get("surface_id") or "") - _must(bool(split_surface), f"surface.split returned no surface_id: {split_payload}") - _must( - c.current_workspace() == baseline_workspace, - "surface.split in a background workspace should not steal workspace focus", - ) - - token = f"CMUX_BG_SPLIT_SEND_{time.time_ns()}" - command = ( - "python3 -c " - + shlex.quote( - f"from pathlib import Path; Path({marker_path.as_posix()!r}).write_text({token!r}, encoding='utf-8')" - ) - + "\n" - ) - send_payload = c._call( - "surface.send_text", - { - "workspace_id": created_workspace, - "surface_id": split_surface, - "text": command, - }, - ) or {} - _must( - str(send_payload.get("surface_id") or "") == split_surface, - f"surface.send_text returned unexpected surface_id: {send_payload}", - ) - - _wait_for_file_text(marker_path, token) - _must( - c.current_workspace() == baseline_workspace, - "surface.send_text should start the background terminal without selecting its workspace", - ) - finally: - try: - marker_path.unlink() - except FileNotFoundError: - pass - if created_workspace: - try: - c.close_workspace(created_workspace) - except Exception: - logging.exception("Failed to clean up workspace %s", created_workspace) - - print("PASS: background split surface.send_text starts the terminal without focus") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_cli_background_terminal_helpers_start_pty.py b/tests_v2/test_cli_background_terminal_helpers_start_pty.py deleted file mode 100644 index e97aae772fa4..000000000000 --- a/tests_v2/test_cli_background_terminal_helpers_start_pty.py +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/env python3 -"""Regression: CLI-created background terminal helpers should start a PTY.""" - -from __future__ import annotations - -import glob -import os -import re -import subprocess -import sys -import time -from pathlib import Path -from typing import List, Tuple - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock") - - -class cmuxSkip(Exception): - pass - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run_cli(cli: str, args: List[str], check: bool = True) -> Tuple[int, str]: - env = dict(os.environ) - env.pop("CMUX_WORKSPACE_ID", None) - env.pop("CMUX_SURFACE_ID", None) - env.pop("CMUX_TAB_ID", None) - - cmd = [cli, "--socket", SOCKET_PATH] + args - proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) - merged = f"{proc.stdout}\n{proc.stderr}".strip() - if check and proc.returncode != 0: - raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") - return proc.returncode, proc.stdout.strip() if proc.returncode == 0 else merged - - -def _extract_ref(output: str, kind: str) -> str: - match = re.search(rf"\b{kind}:\d+\b", output) - if not match: - raise cmuxError(f"Could not find {kind} ref in CLI output: {output!r}") - return match.group(0) - - -def _wait_for_read_screen(cli: str, workspace_ref: str, surface_ref: str, token: str) -> str: - deadline = time.time() + 8.0 - last_output = "" - while time.time() < deadline: - code, output = _run_cli( - cli, - [ - "read-screen", - "--workspace", - workspace_ref, - "--surface", - surface_ref, - "--scrollback", - "--lines", - "80", - ], - check=False, - ) - last_output = output - if code == 0 and token in output: - return output - time.sleep(0.1) - raise cmuxError(f"read-screen never observed {token!r} for {surface_ref}: {last_output!r}") - - -def _exercise_helper(cli: str, workspace_ref: str, surface_ref: str, label: str) -> None: - token = f"CMUX_HELPER_PTY_{label}_{int(time.time() * 1000)}" - _run_cli( - cli, - [ - "send", - "--workspace", - workspace_ref, - "--surface", - surface_ref, - "--", - f"echo {token}\\n", - ], - ) - text = _wait_for_read_screen(cli, workspace_ref, surface_ref, token) - _must(token in text, f"helper terminal output missing {token!r}: {text!r}") - - -def _create_background_workspace(cli: str) -> str: - _, output = _run_cli(cli, ["new-workspace", "--focus", "false"]) - workspace_ref = output.removeprefix("OK ").strip() - _must(bool(workspace_ref), f"new-workspace returned no workspace ref: {output!r}") - return workspace_ref - - -def _find_unhosted_background_workspace(c: cmux, cli: str, baseline_ws: str) -> Tuple[str, List[str]]: - created_workspaces: List[str] = [] - try: - for _ in range(16): - workspace_ref = _create_background_workspace(cli) - created_workspaces.append(workspace_ref) - _run_cli(cli, ["select-workspace", "--workspace", baseline_ws]) - - health = c._call("surface.health", {"workspace_id": workspace_ref}) or {} - surfaces = health.get("surfaces") or [] - if any(row.get("type") == "terminal" and row.get("in_window") is False for row in surfaces): - created_workspaces.remove(workspace_ref) - return workspace_ref, created_workspaces - - raise cmuxSkip("could not create an unhosted background workspace for helper terminal regression") - except Exception: - for workspace_ref in created_workspaces: - try: - c.close_workspace(workspace_ref) - except Exception: - pass - raise - - -def main() -> int: - cli = _find_cli_binary() - - with cmux(SOCKET_PATH) as c: - baseline = c._call("workspace.current") or {} - baseline_ws = str(baseline.get("workspace_ref") or baseline.get("workspace_id") or "") - _must(bool(baseline_ws), f"workspace.current returned no workspace_id: {baseline}") - - workspace_ref = "" - cleanup_workspaces: List[str] = [] - - try: - try: - workspace_ref, cleanup_workspaces = _find_unhosted_background_workspace(c, cli, baseline_ws) - except cmuxSkip as exc: - print(f"SKIP: {exc}") - return 0 - panes = c._call("pane.list", {"workspace_id": workspace_ref}) or {} - pane_rows = panes.get("panes") or [] - _must(bool(pane_rows), f"pane.list returned no panes for background workspace: {panes}") - pane_ref = str(pane_rows[0].get("ref") or pane_rows[0].get("id") or "") - _must(bool(pane_ref), f"pane.list returned pane without ref/id: {panes}") - - _, surface_output = _run_cli( - cli, - [ - "new-surface", - "--workspace", - workspace_ref, - "--pane", - pane_ref, - "--type", - "terminal", - "--focus", - "false", - ], - ) - helper_surface_ref = _extract_ref(surface_output, "surface") - _exercise_helper(cli, workspace_ref, helper_surface_ref, "surface") - - _, pane_output = _run_cli( - cli, - [ - "new-pane", - "--workspace", - workspace_ref, - "--type", - "terminal", - "--direction", - "right", - "--focus", - "false", - ], - ) - helper_pane_surface_ref = _extract_ref(pane_output, "surface") - _exercise_helper(cli, workspace_ref, helper_pane_surface_ref, "pane") - - current = c._call("workspace.current") or {} - _must( - str(current.get("workspace_ref") or current.get("workspace_id") or "") == baseline_ws, - f"helper creation should not switch selected workspace: {current}", - ) - finally: - for cleanup_workspace in cleanup_workspaces: - try: - c.close_workspace(cleanup_workspace) - except Exception: - pass - try: - if workspace_ref: - c.close_workspace(workspace_ref) - except Exception: - pass - - print("PASS: CLI-created background terminal helpers start a PTY") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_cli_new_workspace_command_queue.py b/tests_v2/test_cli_new_workspace_command_queue.py index 91dec9f52adf..9b19443d3d7e 100644 --- a/tests_v2/test_cli_new_workspace_command_queue.py +++ b/tests_v2/test_cli_new_workspace_command_queue.py @@ -23,17 +23,6 @@ def _must(cond: bool, msg: str) -> None: raise cmuxError(msg) -def _float_env(name: str, default: float) -> float: - raw = os.environ.get(name) - if not raw: - return default - try: - value = float(raw) - except ValueError: - return default - return value if value > 0 else default - - def _find_cli_binary() -> str: env_cli = os.environ.get("CMUXTERM_CLI") if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): @@ -58,21 +47,14 @@ def _run_cli(cli: str, args: list[str]) -> tuple[subprocess.CompletedProcess[str env.pop("CMUX_SURFACE_ID", None) env.pop("CMUX_TAB_ID", None) - command = [cli, "--socket", SOCKET_PATH, *args] - timeout = _float_env("CMUX_TEST_CLI_RUN_TIMEOUT", 30.0) started = time.monotonic() - try: - proc = subprocess.run( - command, - capture_output=True, - text=True, - check=False, - env=env, - timeout=timeout, - ) - except subprocess.TimeoutExpired as exc: - details = f"stdout={exc.output!r} stderr={exc.stderr!r}" - raise cmuxError(f"CLI timed out after {timeout:.1f}s: {command!r}; {details}") from exc + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH] + args, + capture_output=True, + text=True, + check=False, + env=env, + ) elapsed = time.monotonic() - started return proc, elapsed @@ -96,11 +78,7 @@ def main() -> int: proc, elapsed = _run_cli(cli, ["new-workspace", "--command", cmd_text]) combined = f"{proc.stdout}\n{proc.stderr}".strip() _must(proc.returncode == 0, f"CLI failed ({proc.returncode}): {combined}") - quick_return_timeout = _float_env("NEW_WORKSPACE_COMMAND_TIMEOUT", 5.0) - _must( - elapsed < quick_return_timeout, - f"new-workspace --command should return quickly, took {elapsed:.2f}s", - ) + _must(elapsed < 1.5, f"new-workspace --command should return quickly, took {elapsed:.2f}s") output = (proc.stdout or "").strip() _must(output.startswith("OK "), f"Expected OK response, got: {output!r}") diff --git a/tests_v2/test_cli_new_workspace_layout_command_queue.py b/tests_v2/test_cli_new_workspace_layout_command_queue.py deleted file mode 100644 index 34c0e0e3b8d8..000000000000 --- a/tests_v2/test_cli_new_workspace_layout_command_queue.py +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env python3 -"""Regression: `new-workspace --layout` terminal commands should execute without focus.""" - -from __future__ import annotations - -import glob -import json -import os -import shlex -import subprocess -import sys -import tempfile -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock") - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _float_env(name: str, default: float) -> float: - raw = os.environ.get(name) - if not raw: - return default - try: - value = float(raw) - except ValueError: - return default - return value if value > 0 else default - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run_cli(cli: str, args: list[str]) -> tuple[subprocess.CompletedProcess[str], float]: - env = dict(os.environ) - env.pop("CMUX_WORKSPACE_ID", None) - env.pop("CMUX_SURFACE_ID", None) - env.pop("CMUX_TAB_ID", None) - - command = [cli, "--socket", SOCKET_PATH, *args] - timeout = _float_env("CMUX_TEST_CLI_RUN_TIMEOUT", 30.0) - started = time.monotonic() - try: - proc = subprocess.run( - command, - capture_output=True, - text=True, - check=False, - env=env, - timeout=timeout, - ) - except subprocess.TimeoutExpired as exc: - details = f"stdout={exc.output!r} stderr={exc.stderr!r}" - raise cmuxError(f"CLI timed out after {timeout:.1f}s: {command!r}; {details}") from exc - elapsed = time.monotonic() - started - return proc, elapsed - - -def main() -> int: - cli = _find_cli_binary() - marker = Path(tempfile.gettempdir()) / f"cmux_new_workspace_layout_command_{os.getpid()}.txt" - created_ws_id: str | None = None - - try: - marker.unlink(missing_ok=True) - except OSError: - pass - - with cmux(SOCKET_PATH) as c: - try: - baseline_ws_id = c.current_workspace() - token = f"layout-{os.getpid()}-{int(time.time() * 1000)}" - command = ( - "python3 -c " - + shlex.quote( - f"from pathlib import Path; Path({marker.as_posix()!r}).write_text({token!r}, encoding='utf-8')" - ) - ) - layout = { - "pane": { - "surfaces": [ - { - "type": "terminal", - "command": command, - } - ] - } - } - - proc, elapsed = _run_cli(cli, ["new-workspace", "--layout", json.dumps(layout)]) - combined = f"{proc.stdout}\n{proc.stderr}".strip() - _must(proc.returncode == 0, f"CLI failed ({proc.returncode}): {combined}") - quick_return_timeout = _float_env("NEW_WORKSPACE_LAYOUT_TIMEOUT", 5.0) - _must( - elapsed < quick_return_timeout, - f"new-workspace --layout should return quickly, took {elapsed:.2f}s", - ) - - output = (proc.stdout or "").strip() - _must(output.startswith("OK "), f"Expected OK response, got: {output!r}") - created_ws_id = output[3:].strip() - _must(bool(created_ws_id), f"Missing workspace id in output: {output!r}") - _must(c.current_workspace() == baseline_ws_id, "new-workspace --layout should preserve selected workspace") - - observed = "" - deadline = time.time() + 12.0 - while time.time() < deadline: - if marker.exists(): - try: - observed = marker.read_text(encoding="utf-8").strip() - except OSError: - observed = "" - if observed: - break - time.sleep(0.05) - - _must(marker.exists(), f"Layout command marker file was not created: {marker}") - _must(observed == token, f"Layout command did not execute as expected: expected={token!r} observed={observed!r}") - _must(c.current_workspace() == baseline_ws_id, "Layout command execution should not switch selected workspace") - finally: - if created_ws_id: - try: - c.close_workspace(created_ws_id) - except Exception: - pass - - try: - marker.unlink(missing_ok=True) - except OSError: - pass - - print("PASS: new-workspace --layout commands execute without opening the created workspace") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/web/.env.example b/web/.env.example index 0683bddc91d4..fdbcc8c31f09 100644 --- a/web/.env.example +++ b/web/.env.example @@ -48,10 +48,10 @@ PGHOST= PGPORT= PGUSER= -# Optional Stack Auth create-credit gating. Leave this empty for active-limit-only -# free-plan enforcement. Set an item id only when the free plan should consume -# one team-scoped create-credit item per successful create. -CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID= +# Stack Auth create-credit gating. The free plan consumes one team-scoped Stack Auth +# item credit per successful create. Configure the Stack Auth free/include-by-default +# product to grant 20 of this item. Set this to "none" to disable create credits. +CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID=cmux-vm-create-credit CMUX_VM_PLAN_FREE_CREATE_CREDIT_COST= # Optional global or provider-specific overrides. Paid plans use create credits only when # a plan-specific or global item id is configured. diff --git a/web/app/[locale]/docs/skills/page.tsx b/web/app/[locale]/docs/skills/page.tsx index 760e5946177f..6495643f248b 100644 --- a/web/app/[locale]/docs/skills/page.tsx +++ b/web/app/[locale]/docs/skills/page.tsx @@ -15,38 +15,6 @@ const skills = [ descriptionKey: "cmuxDescription", useKey: "cmuxUse", }, - { - id: "cmux-workspace", - path: "skills/cmux-workspace/SKILL.md", - command: "cmux current-workspace --json", - nameKey: "workspaceName", - descriptionKey: "workspaceDescription", - useKey: "workspaceUse", - }, - { - id: "cmux-settings", - path: "skills/cmux-settings/SKILL.md", - command: "skills/cmux-settings/scripts/cmux-settings list-supported", - nameKey: "settingsName", - descriptionKey: "settingsDescription", - useKey: "settingsUse", - }, - { - id: "cmux-customization", - path: "skills/cmux-customization/SKILL.md", - command: "cmux reload-config", - nameKey: "customizationName", - descriptionKey: "customizationDescription", - useKey: "customizationUse", - }, - { - id: "cmux-diagnostics", - path: "skills/cmux-diagnostics/SKILL.md", - command: "skills/cmux-diagnostics/scripts/cmux-diagnostics", - nameKey: "diagnosticsName", - descriptionKey: "diagnosticsDescription", - useKey: "diagnosticsUse", - }, { id: "cmux-browser", path: "skills/cmux-browser/SKILL.md", @@ -63,116 +31,21 @@ const skills = [ descriptionKey: "markdownDescription", useKey: "markdownUse", }, -] as const; - -const skillCoverage = [ - { - id: "cmux", - nameKey: "cmuxName", - scopeKey: "cmuxScope", - referencesKey: "cmuxReferences", - }, - { - id: "cmux-workspace", - nameKey: "workspaceName", - scopeKey: "workspaceScope", - referencesKey: "workspaceReferences", - }, - { - id: "cmux-settings", - nameKey: "settingsName", - scopeKey: "settingsScope", - referencesKey: "settingsReferences", - }, - { - id: "cmux-customization", - nameKey: "customizationName", - scopeKey: "customizationScope", - referencesKey: "customizationReferences", - }, { - id: "cmux-diagnostics", - nameKey: "diagnosticsName", - scopeKey: "diagnosticsScope", - referencesKey: "diagnosticsReferences", + id: "cmux-debug-windows", + path: "skills/cmux-debug-windows/SKILL.md", + command: "skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh", + nameKey: "debugWindowsName", + descriptionKey: "debugWindowsDescription", + useKey: "debugWindowsUse", }, { - id: "cmux-browser", - nameKey: "browserName", - scopeKey: "browserScope", - referencesKey: "browserReferences", - }, - { - id: "cmux-markdown", - nameKey: "markdownName", - scopeKey: "markdownScope", - referencesKey: "markdownReferences", - }, -] as const; - -const suggestedSkills = [ - { - id: "cmux-ssh", - nameKey: "suggestSshName", - useKey: "suggestSshUse", - whyKey: "suggestSshWhy", - }, - { - id: "cmux-cloud-vm", - nameKey: "suggestCloudVmName", - useKey: "suggestCloudVmUse", - whyKey: "suggestCloudVmWhy", - }, - { - id: "cmux-vault", - nameKey: "suggestVaultName", - useKey: "suggestVaultUse", - whyKey: "suggestVaultWhy", - }, -] as const; - -const customizationExamples = [ - { - id: "worktree-agents", - nameKey: "exampleWorktreeName", - surfaceKey: "exampleWorktreeSurface", - useKey: "exampleWorktreeUse", - }, - { - id: "full-stack-dev", - nameKey: "exampleFullStackName", - surfaceKey: "exampleFullStackSurface", - useKey: "exampleFullStackUse", - }, - { - id: "ssh-devbox", - nameKey: "exampleSshName", - surfaceKey: "exampleSshSurface", - useKey: "exampleSshUse", - }, - { - id: "review-pr", - nameKey: "exampleReviewName", - surfaceKey: "exampleReviewSurface", - useKey: "exampleReviewUse", - }, - { - id: "docs-workspace", - nameKey: "exampleDocsName", - surfaceKey: "exampleDocsSurface", - useKey: "exampleDocsUse", - }, - { - id: "ci-watch", - nameKey: "exampleCiName", - surfaceKey: "exampleCiSurface", - useKey: "exampleCiUse", - }, - { - id: "quick-agent-buttons", - nameKey: "exampleAgentButtonsName", - surfaceKey: "exampleAgentButtonsSurface", - useKey: "exampleAgentButtonsUse", + id: "release", + path: "skills/release/SKILL.md", + command: "./scripts/bump-version.sh", + nameKey: "releaseName", + descriptionKey: "releaseDescription", + useKey: "releaseUse", }, ] as const; @@ -204,12 +77,7 @@ export default function SkillsPage() { code: (chunks) => {chunks}, })}

- {`# Install all cmux skills -npx skills add manaflow-ai/cmux -g -y - -# Or install just diagnostics -npx skills add manaflow-ai/cmux --skill cmux-diagnostics -g -y`} - {`curl -fsSL https://raw.githubusercontent.com/manaflow-ai/cmux/main/skills.sh | bash`} + {`curl -fsSL https://raw.githubusercontent.com/manaflow-ai/cmux/main/skills.sh | bash`} {t.rich("installDestination", { code: (chunks) => {chunks}, @@ -256,66 +124,6 @@ npx skills add manaflow-ai/cmux --skill cmux-diagnostics -g -y`} - {t("coverageTitle")} -

{t("coverageIntro")}

- - - - - - - - - - {skillCoverage.map((skill) => ( - - - - - - ))} - -
{t("skillHeader")}{t("scopeHeader")}{t("referencesHeader")}
- {t(skill.nameKey)} -
- {skill.id} -
{t(skill.scopeKey)}{t(skill.referencesKey)}
- - {t("customizationExamplesTitle")} -

{t("customizationExamplesIntro")}

- - - - - - - - - - {customizationExamples.map((example) => ( - - - - - - ))} - -
{t("exampleHeader")}{t("exampleSurfaceHeader")}{t("exampleUseHeader")}
- {t(example.nameKey)} -
- {example.id} -
{t(example.surfaceKey)}{t(example.useKey)}
- - {t.rich("customizationExamplesCallout", { - code: (chunks) => {chunks}, - })} - - {[ - t("customizationPromptWorktree"), - t("customizationPromptFullStack"), - t("customizationPromptAgentButtons"), - ].join("\n")} - {t("helpMenuTitle")}

{t.rich("helpMenuIntro", { @@ -337,32 +145,6 @@ skills//templates/*`} })} - {t("suggestionsTitle")} -

{t("suggestionsIntro")}

- - - - - - - - - - {suggestedSkills.map((skill) => ( - - - - - - ))} - -
{t("suggestionHeader")}{t("suggestionUseHeader")}{t("suggestionWhyHeader")}
- {t(skill.nameKey)} -
- {skill.id} -
{t(skill.useKey)}{t(skill.whyKey)}
- {t("suggestionsCallout")} - {t("relatedTitle")}
  • diff --git a/web/app/api/vm/route.ts b/web/app/api/vm/route.ts index 4999b07e679f..bb1732c99763 100644 --- a/web/app/api/vm/route.ts +++ b/web/app/api/vm/route.ts @@ -1,11 +1,6 @@ // Authenticated REST facade over the VM control plane. Native clients use this surface so // provider credentials stay behind server-side ownership checks. -import { - unauthorized, - verifyRequest, - type AuthedUser, -} from "../../../services/vms/auth"; import { defaultProviderId, type ProviderId, @@ -37,11 +32,6 @@ import { runVmWorkflow, } from "../../../services/vms/workflows"; import { recordSpanError, setSpanAttributes } from "../../../services/telemetry"; -import { - measureVmAsync, - measureVmSync, - VmTimingRecorder, -} from "../../../services/vms/timings"; export const dynamic = "force-dynamic"; @@ -96,30 +86,22 @@ export async function POST(request: Request): Promise { "/api/vm", { "cmux.vm.operation": "create" }, "/api/vm POST failed", - async ({ user: initialUser, span, authDurationMs, routeStartedAtMs, setResponseFinalizer }) => { - const timing = new VmTimingRecorder(span, "create", { startedAt: routeStartedAtMs }); - timing.record("auth", authDurationMs); - setResponseFinalizer((response) => timing.finish({ status: response.status })); - let user: AuthedUser = initialUser; - { - // Runtime-validate the payload before we call a paid provider. An invalid `provider` - // (client sending `"aws"` or `"docker"`) previously slipped past the type cast and - // surfaced as a 500 from the driver after provisioning had already half-succeeded. - // Allow callers to send no body at all. The handler already falls through to - // default provider/image, so a bare `curl -X POST /api/vm` should create a default - // VM. Empty is a default-create; malformed or non-object JSON is rejected below. - let parsedBody: { readonly bodyWasEmpty: boolean; readonly raw: unknown }; + async ({ user, span }) => { + // Runtime-validate the payload before we call a paid provider. An invalid `provider` + // (client sending `"aws"` or `"docker"`) previously slipped past the type cast and + // surfaced as a 500 from the driver after provisioning had already half-succeeded. + // Allow callers to send no body at all. The handler already falls through to + // default provider/image, so a bare `curl -X POST /api/vm` should create a default + // VM. Empty is a default-create; malformed or non-object JSON is rejected below. + const rawText = await request.text(); + const bodyWasEmpty = rawText.length === 0; + let raw: unknown; + if (bodyWasEmpty) { + raw = undefined; + } else { try { - parsedBody = await measureVmAsync(timing, "request_parse", async () => { - const rawText = await request.text(); - const bodyWasEmpty = rawText.length === 0; - if (bodyWasEmpty) { - return { bodyWasEmpty, raw: undefined as unknown }; - } - return { bodyWasEmpty, raw: JSON.parse(rawText) as unknown }; - }); + raw = JSON.parse(rawText); } catch (err) { - if (!(err instanceof SyntaxError)) throw err; recordSpanError(span, err); return vmErrorResponse({ error: "vm_json_parse_failed", @@ -128,239 +110,197 @@ export async function POST(request: Request): Promise { action: "Send `{}` for the default VM, or include only documented fields such as `image` and `teamId`.", }); } - const { bodyWasEmpty, raw } = parsedBody; - if (!bodyWasEmpty && (raw === null || typeof raw !== "object" || Array.isArray(raw))) { - recordSpanError(span, new Error("Cloud VM create body was not a JSON object")); + } + if (!bodyWasEmpty && (raw === null || typeof raw !== "object" || Array.isArray(raw))) { + recordSpanError(span, new Error("Cloud VM create body was not a JSON object")); + return vmErrorResponse({ + error: "vm_expected_object", + status: 400, + message: "Cloud VM create expected a JSON object body.", + action: "Send `{}` for the default VM, or include only documented fields such as `image` and `teamId`.", + }); + } + const candidate = (raw ?? {}) as Record; + if (candidate.image !== undefined && typeof candidate.image !== "string") { + return vmErrorResponse({ + error: "vm_invalid_request", + status: 400, + message: "`image` must be a string when provided.", + action: "Remove `image` to use the default Cloud VM image, or pass a supported Cloud VM image id.", + details: { field: "image" }, + }); + } + if (candidate.provider !== undefined) { + if (typeof candidate.provider !== "string") { return vmErrorResponse({ - error: "vm_expected_object", + error: "vm_invalid_request", status: 400, - message: "Cloud VM create expected a JSON object body.", - action: "Send `{}` for the default VM, or include only documented fields such as `image` and `teamId`.", + message: "Cloud VM service override must be a string when provided.", + action: "Remove the override to use the default Cloud VM service.", + details: { field: "provider" }, }); } - const candidate = (raw ?? {}) as Record; - if (candidate.image !== undefined && typeof candidate.image !== "string") { + if (candidate.provider !== "e2b" && candidate.provider !== "freestyle") { return vmErrorResponse({ - error: "vm_invalid_request", + error: "vm_invalid_provider", status: 400, - message: "`image` must be a string when provided.", - action: "Remove `image` to use the default Cloud VM image, or pass a supported Cloud VM image id.", - details: { field: "image" }, + message: "Unsupported Cloud VM service override.", + action: "Remove the override to use the default Cloud VM service.", + details: { field: "provider" }, }); } - if (candidate.provider !== undefined) { - if (typeof candidate.provider !== "string") { - return vmErrorResponse({ - error: "vm_invalid_request", - status: 400, - message: "Cloud VM service override must be a string when provided.", - action: "Remove the override to use the default Cloud VM service.", - details: { field: "provider" }, - }); - } - if (candidate.provider !== "e2b" && candidate.provider !== "freestyle") { - return vmErrorResponse({ - error: "vm_invalid_provider", - status: 400, - message: "Unsupported Cloud VM service override.", - action: "Remove the override to use the default Cloud VM service.", - details: { field: "provider" }, - }); - } - } - const bodyBillingTeamId = candidate.billingTeamId ?? candidate.teamId; - if (bodyBillingTeamId !== undefined && typeof bodyBillingTeamId !== "string") { - return invalidTeamIdResponse(); - } - if (typeof bodyBillingTeamId === "string" && bodyBillingTeamId.trim().length === 0) { - return invalidTeamIdResponse(); - } - if (requestHasBlankVmTeamId(request)) { - return invalidTeamIdResponse(); + } + const bodyBillingTeamId = candidate.billingTeamId ?? candidate.teamId; + if (bodyBillingTeamId !== undefined && typeof bodyBillingTeamId !== "string") { + return vmErrorResponse({ + error: "vm_invalid_request", + status: 400, + message: "`teamId` must be a string when provided.", + action: "Use a team id from `cmux auth status`, or omit `teamId` when the signed-in account has one team.", + details: { field: "teamId" }, + }); + } + const body: { image?: string; provider?: ProviderId; billingTeamId?: string } = { + image: typeof candidate.image === "string" ? candidate.image : undefined, + provider: candidate.provider as ProviderId | undefined, + billingTeamId: typeof bodyBillingTeamId === "string" ? bodyBillingTeamId.trim() : undefined, + }; + const provider = body.provider ?? defaultProviderId(); + let imageSelection; + try { + assertVmCreateEnabled(provider); + imageSelection = resolveVmImage(provider, body.image); + } catch (err) { + if (isVmCreateDisabledError(err)) { + return vmErrorResponse({ + error: "vm_create_disabled", + status: 503, + message: "Cloud VM creation is disabled for this environment.", + action: "Ask an admin to enable Cloud VM creation, then retry.", + reason: "Cloud VM creation is disabled.", + }); } - const body: { image?: string; provider?: ProviderId; billingTeamId?: string } = { - image: typeof candidate.image === "string" ? candidate.image : undefined, - provider: candidate.provider as ProviderId | undefined, - billingTeamId: typeof bodyBillingTeamId === "string" ? bodyBillingTeamId.trim() : undefined, - }; - const provider = body.provider ?? defaultProviderId(); - let imageSelection; - try { - assertVmCreateEnabled(provider); - imageSelection = resolveVmImage(provider, body.image); - } catch (err) { - if (isVmCreateDisabledError(err)) { - return vmErrorResponse({ - error: "vm_create_disabled", - status: 503, - message: "Cloud VM creation is disabled for this environment.", - action: "Ask an admin to enable Cloud VM creation, then retry.", - reason: "Cloud VM creation is disabled.", - }); - } - if (isVmImageConfigError(err)) { - return vmErrorResponse({ - error: "vm_image_config_error", - status: 503, - message: "The requested Cloud VM image is not available in this environment.", - action: "Retry without `image` to use the default Cloud VM image, or ask an admin to configure a supported image.", - reason: "Cloud VM image configuration is unavailable.", - details: { imageRequested: err.image !== undefined }, - }); - } - throw err; + if (isVmImageConfigError(err)) { + return vmErrorResponse({ + error: "vm_image_config_error", + status: 503, + message: "The requested Cloud VM image is not available in this environment.", + action: "Retry without `image` to use the default Cloud VM image, or ask an admin to configure a supported image.", + reason: "Cloud VM image configuration is unavailable.", + details: { imageRequested: err.image !== undefined }, + }); } - const image = imageSelection.image; - // Idempotency-Key is standard HTTP; we also accept x-cmux-idempotency-key for CLI - // callers that don't know about RFC-style keys. Trim + clamp to a reasonable length - // so we don't store unbounded idempotency metadata. - const rawKey = ( - request.headers.get("idempotency-key") || - request.headers.get("x-cmux-idempotency-key") || - "" - ).trim(); - const idempotencyKey = rawKey ? rawKey.slice(0, 128) : undefined; - setSpanAttributes(span, { - "cmux.vm.provider": provider, - "cmux.vm.image_set": image.length > 0, - "cmux.vm.image_version": imageSelection.imageVersion, - "cmux.vm.image_manifest": !!imageSelection.manifestEntry, - "cmux.idempotency_key_set": !!idempotencyKey, + throw err; + } + const image = imageSelection.image; + // Idempotency-Key is standard HTTP; we also accept x-cmux-idempotency-key for CLI + // callers that don't know about RFC-style keys. Trim + clamp to a reasonable length + // so we don't store unbounded idempotency metadata. + const rawKey = ( + request.headers.get("idempotency-key") || + request.headers.get("x-cmux-idempotency-key") || + "" + ).trim(); + const idempotencyKey = rawKey ? rawKey.slice(0, 128) : undefined; + setSpanAttributes(span, { + "cmux.vm.provider": provider, + "cmux.vm.image_set": image.length > 0, + "cmux.vm.image_version": imageSelection.imageVersion, + "cmux.vm.image_manifest": !!imageSelection.manifestEntry, + "cmux.idempotency_key_set": !!idempotencyKey, + }); + + const requestedBillingTeamId = body.billingTeamId || requestedVmTeamIdFromRequest(request); + let entitlements; + try { + entitlements = resolveVmEntitlements(user, process.env, { + requestedBillingTeamId, + requireTeam: true, }); + } catch (err) { + if (isVmBillingTeamResolutionError(err)) { + return billingTeamErrorResponse(err); + } + throw err; + } + setSpanAttributes(span, { + "cmux.billing.team_id_set": !!entitlements.billingTeamId, + "cmux.billing.customer_type": entitlements.billingCustomerType, + "cmux.billing.plan_id": entitlements.planId, + "cmux.billing.requested_team_id_set": !!requestedBillingTeamId, + "cmux.vm.max_active": entitlements.maxActiveVms, + }); - const requestedBillingTeamId = body.billingTeamId || requestedVmTeamIdFromRequest(request); - if (requestedBillingTeamId && !user.teamIds.includes(requestedBillingTeamId)) { - const refreshedUser = await measureVmAsync(timing, "auth", () => - verifyRequest(request, { requestedTeamId: requestedBillingTeamId }) - ); - if (!refreshedUser) return unauthorized(); - user = refreshedUser; + let created; + try { + created = await runVmWorkflow(createVm({ + userId: user.id, + billingCustomerType: entitlements.billingCustomerType, + billingTeamId: entitlements.billingTeamId, + billingPlanId: entitlements.planId, + maxActiveVms: entitlements.maxActiveVms, + image, + imageVersion: imageSelection.imageVersion, + provider, + idempotencyKey, + })); + } catch (err) { + if (isVmCreateInProgressError(err)) { + return vmErrorResponse({ + error: "vm_create_in_progress", + status: 409, + message: "A Cloud VM create is already running for this request.", + action: "Wait for the first `cmux vm new` to finish. If your terminal was interrupted, retry the same command and cmux will reuse the in-flight request.", + details: { idempotencyKeySet: !!err.idempotencyKey }, + }); } - let entitlements; - try { - entitlements = measureVmSync(timing, "entitlements", () => - resolveVmEntitlements(user, process.env, { - requestedBillingTeamId, - requireTeam: true, - }) - ); - } catch (err) { - if (isVmBillingTeamResolutionError(err)) { - return billingTeamErrorResponse(err); - } - throw err; + if (isVmCreateFailedError(err)) { + return vmErrorResponse({ + error: "vm_create_failed", + status: 500, + message: "The previous Cloud VM create attempt failed.", + action: "Retry with a fresh `cmux vm new`. If it fails again, copy the details and contact support.", + details: { + idempotencyKeySet: !!err.idempotencyKey, + }, + }); } - setSpanAttributes(span, { - "cmux.billing.team_id_set": !!entitlements.billingTeamId, - "cmux.billing.customer_type": entitlements.billingCustomerType, - "cmux.billing.plan_id": entitlements.planId, - "cmux.billing.requested_team_id_set": !!requestedBillingTeamId, - "cmux.vm.max_active": entitlements.maxActiveVms, - }); - - let created; - try { - created = await runVmWorkflow(createVm({ - userId: user.id, - billingCustomerType: entitlements.billingCustomerType, - billingTeamId: entitlements.billingTeamId, - billingPlanId: entitlements.planId, - maxActiveVms: entitlements.maxActiveVms, - image, - imageVersion: imageSelection.imageVersion, - provider, - idempotencyKey, - timing, - })); - } catch (err) { - if (isVmCreateInProgressError(err)) { - return vmErrorResponse({ - error: "vm_create_in_progress", - status: 409, - message: "A Cloud VM create is already running for this request.", - action: "Wait for the first `cmux vm new` to finish. If your terminal was interrupted, retry the same command and cmux will reuse the in-flight request.", - details: { idempotencyKeySet: !!err.idempotencyKey }, - }); - } - if (isVmCreateFailedError(err)) { - return vmErrorResponse({ - error: "vm_create_failed", - status: 500, - message: "The previous Cloud VM create attempt failed.", - action: "Retry with a fresh `cmux vm new`. If it fails again, copy the details and contact support.", - details: { - idempotencyKeySet: !!err.idempotencyKey, - }, - }); - } - if (isVmLimitExceededError(err)) { - return vmErrorResponse({ - error: "vm_active_limit_exceeded", - status: 402, - message: `This plan allows ${err.limit} active Cloud VM${err.limit === 1 ? "" : "s"} at a time.`, - action: "Run `cmux vm ls`, then stop or delete an active VM with `cmux vm rm ` before creating another. Paused VMs do not count against this limit.", - extra: { limit: err.limit }, - details: { limit: err.limit }, - }); - } - if (isVmCreateCreditsInsufficientError(err)) { - return vmErrorResponse({ - error: "vm_create_credits_insufficient", - status: 402, - message: "This team has no Cloud VM create credits left.", - action: "Upgrade the team's plan or ask an admin to add Cloud VM create credits, then retry.", - extra: { amount: err.amount }, - details: { amount: err.amount }, - }); - } - const workflowError = vmWorkflowErrorResponse(err); - if (workflowError) return workflowError; - throw err; + if (isVmLimitExceededError(err)) { + return vmErrorResponse({ + error: "vm_active_limit_exceeded", + status: 402, + message: `This plan allows ${err.limit} active Cloud VM${err.limit === 1 ? "" : "s"} at a time.`, + action: "Run `cmux vm ls`, then stop or delete an active VM with `cmux vm rm ` before creating another. Paused VMs do not count against this limit.", + extra: { limit: err.limit }, + details: { limit: err.limit }, + }); } - setSpanAttributes(span, { "cmux.vm.id": created.providerVmId }); - return jsonResponse({ - id: created.providerVmId, - provider: created.provider, - image: created.image, - imageVersion: created.imageVersion, - createdAt: created.createdAt, - }); + if (isVmCreateCreditsInsufficientError(err)) { + return vmErrorResponse({ + error: "vm_create_credits_insufficient", + status: 402, + message: "This team has no Cloud VM create credits left.", + action: "Upgrade the team's plan or ask an admin to add Cloud VM create credits, then retry.", + extra: { amount: err.amount }, + details: { amount: err.amount }, + }); + } + const workflowError = vmWorkflowErrorResponse(err); + if (workflowError) return workflowError; + throw err; } + setSpanAttributes(span, { "cmux.vm.id": created.providerVmId }); + return jsonResponse({ + id: created.providerVmId, + provider: created.provider, + image: created.image, + imageVersion: created.imageVersion, + createdAt: created.createdAt, + }); }, ); } -function invalidTeamIdResponse(): Response { - return vmErrorResponse({ - error: "vm_invalid_request", - status: 400, - message: "`teamId` must be a non-empty string when provided.", - action: "Use a team id from `cmux auth status`, or omit `teamId` when the signed-in account has one team.", - details: { field: "teamId" }, - }); -} - -function requestHasBlankVmTeamId(request: Request): boolean { - for (const header of ["x-cmux-team-id", "x-cmux-billing-team-id"]) { - const value = request.headers.get(header); - if (value !== null && value.trim().length === 0) return true; - } - - let url: URL; - try { - url = new URL(request.url); - } catch { - return false; - } - - for (const key of ["teamId", "team_id", "billingTeamId", "billing_team_id"]) { - for (const value of url.searchParams.getAll(key)) { - if (value.trim().length === 0) return true; - } - } - return false; -} - function billingTeamErrorResponse(err: { readonly code: "vm_billing_team_required" | "vm_billing_team_not_found"; readonly status: number; diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index 45c7d6a65a1a..20fdcfa44905 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -30,7 +30,7 @@ export default function sitemap(): MetadataRoute.Sitemap { { path: "/docs/keyboard-shortcuts", lastModified: "2026-04-03", changeFrequency: "monthly" as const, priority: 0.7 }, { path: "/docs/api", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 }, { path: "/docs/browser-automation", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 }, - { path: "/docs/skills", lastModified: "2026-05-15", changeFrequency: "monthly" as const, priority: 0.8 }, + { path: "/docs/skills", lastModified: "2026-05-01", changeFrequency: "monthly" as const, priority: 0.8 }, { path: "/docs/notifications", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 }, { path: "/docs/ssh", lastModified: "2026-03-31", changeFrequency: "monthly" as const, priority: 0.8 }, { path: "/docs/agent-integrations/claude-code-teams", lastModified: "2026-03-30", changeFrequency: "monthly" as const, priority: 0.7 }, diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 4dff87f634b3..13d0cb67129e 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -73,7 +73,12 @@ export const shortcutCategories: ShortcutCategory[] = [ blurbKey: "workspacesBlurb", shortcuts: [ { id: "toggleSidebar", combos: [["⌘", "B"]], description: { en: "Toggle left sidebar", ja: "左サイドバーを切り替え" } }, - { id: "toggleFileExplorer", combos: [["⌘", "⌥", "B"]], description: { en: "Toggle right sidebar", ja: "右サイドバーを切り替え" } }, + { + id: "toggleFileExplorer", + combos: [["⌘", "⌥", "B"]], + description: { en: "Toggle right sidebar", ja: "右サイドバーを切り替え" }, + note: { en: "outside focused terminals", ja: "フォーカス中のターミナル以外" }, + }, { id: "newTab", combos: [["⌘", "N"]], description: { en: "New workspace", ja: "新規ワークスペース" } }, { id: "openFolder", combos: [["⌘", "O"]], description: { en: "Open folder", ja: "フォルダを開く" } }, { @@ -114,6 +119,12 @@ export const shortcutCategories: ShortcutCategory[] = [ { id: "closeOtherTabsInPane", combos: [["⌥", "⌘", "T"]], description: { en: "Close other tabs in pane", ja: "ペイン内の他のタブを閉じる" } }, { id: "reopenClosedBrowserPanel", combos: [["⌘", "⇧", "T"]], description: { en: "Reopen closed browser panel", ja: "閉じたブラウザパネルを再度開く" } }, { id: "toggleTerminalCopyMode", combos: [["⌘", "⇧", "M"]], description: { en: "Toggle terminal copy mode", ja: "ターミナルコピーモードを切り替え" } }, + { + id: "toggleTerminalSidekick", + combos: [["⌥", "⌘", "B"]], + description: { en: "Toggle terminal sidekick browser", ja: "ターミナルサイドキックブラウザを切り替え" }, + note: { en: "focused terminal", ja: "フォーカス中のターミナル" }, + }, { id: "saveFilePreview", combos: [["⌘", "S"]], @@ -186,7 +197,6 @@ export const shortcutCategories: ShortcutCategory[] = [ shortcuts: [ { id: "showNotifications", combos: [["⌘", "I"]], description: { en: "Show notifications", ja: "通知を表示" } }, { id: "jumpToUnread", combos: [["⌘", "⇧", "U"]], description: { en: "Jump to latest unread", ja: "最新の未読へ移動" } }, - { id: "toggleUnread", combos: [["⌥", "⌘", "U"]], description: { en: "Toggle current item unread state", ja: "現在の項目の未読状態を切り替え" } }, { id: "markOldestUnreadAndJumpNext", combos: [["⌃", "⌘", "U"]], description: { en: "Mark current item as oldest unread and jump to the next latest unread", ja: "現在の項目を最古の未読にして次の最新未読へ移動" } }, { id: "triggerFlash", combos: [["⌘", "⇧", "H"]], description: { en: "Flash focused panel", ja: "フォーカス中のパネルをフラッシュ" } }, ], diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 934556cbda0a..91d3f647ab95 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -882,7 +882,6 @@ "sendFeedback", "showNotifications", "jumpToUnread", - "toggleUnread", "markOldestUnreadAndJumpNext", "focusRightSidebar", "switchRightSidebarToFiles", diff --git a/web/messages/en.json b/web/messages/en.json index 8742134d9b5a..0f4ac6ece958 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -952,13 +952,12 @@ "skills": { "title": "Skills", "metaTitle": "Skills", - "metaDescription": "Install and use cmux agent skills for core CLI control, workspace automation, settings, customization, diagnostics, browser automation, and markdown panels.", - "intro": "cmux ships skills that teach coding agents how to use cmux CLI control, current-workspace automation, settings, customization, diagnostics, browser surfaces, and markdown panels.", + "metaDescription": "Install and use cmux agent skills for core CLI control, browser automation, markdown panels, debug windows, and releases.", + "intro": "cmux ships skills that teach coding agents how to use cmux CLI control, browser surfaces, markdown panels, debug windows, and the release workflow.", "installTitle": "Install", - "installIntro": "Use Vercel's skills CLI for the standard multi-agent install flow, or use skills.sh when you only need the Codex skills directory.", - "installWithVercel": "Install with Vercel skills", - "installWithSkillsSh": "Install with skills.sh", - "installDestination": "By default, skills.sh installs into ~/.codex/skills, or $CODEX_HOME/skills when CODEX_HOME is set. Pass --dest when your agent reads skills from another directory.", + "installIntro": "Run skills.sh to install the checked-in skills into your Codex skills directory.", + "installFromGitHub": "Install from GitHub", + "installDestination": "By default, the script installs into ~/.codex/skills, or $CODEX_HOME/skills when CODEX_HOME is set. Pass --dest when your agent reads skills from another directory.", "localInstallTitle": "Install from a checkout", "localInstallIntro": "From a cloned cmux checkout, the script uses the local skills directory.", "localInstallCommands": "Local commands", @@ -971,93 +970,23 @@ "cmuxName": "cmux Core", "cmuxDescription": "Controls windows, workspaces, panes, surfaces, focus, moves, reorder, and routing with the cmux CLI.", "cmuxUse": "Use it when an agent needs deterministic placement or navigation in a multi-pane cmux layout.", - "workspaceName": "cmux Workspace", - "workspaceDescription": "Keeps automation scoped to the current caller workspace, caller surface, panes, sidebar metadata, and socket context.", - "workspaceUse": "Use it when an agent should add panes, send input, inspect state, or open helper surfaces without disrupting another workspace.", - "settingsName": "cmux Settings", - "settingsDescription": "Inspects, edits, validates, and opens ~/.config/cmux/cmux.json with a bundled helper script.", - "settingsUse": "Use it when changing appearance, sidebar, notification, browser, automation, or shortcut settings by JSON path.", - "customizationName": "cmux Customization", - "customizationDescription": "Customizes cmux.json actions, plus-button behavior, tab bar buttons, workspace layouts, Dock controls, Feed hooks, sidebar settings, Command Palette entries, shortcuts, and Ghostty-owned terminal preferences.", - "customizationUse": "Use it to make cmux open a user's worktrees, multiple checkouts, SSH sessions, dev tools, or project layout from the exact UI entrypoints they want.", - "diagnosticsName": "cmux Diagnostics", - "diagnosticsDescription": "Runs support-safe checks for cmux CLI health, socket access, hooks, session restore, settings, and agent binaries.", - "diagnosticsUse": "Use it when notifications, hooks, restore, or automation are not behaving as expected.", "browserName": "cmux Browser", "browserDescription": "Automates cmux webview surfaces with snapshot refs, DOM actions, waits, screenshots, and session state.", "browserUse": "Use it for browser tasks that should run inside cmux instead of a separate browser automation tool.", "markdownName": "cmux Markdown Viewer", "markdownDescription": "Opens markdown files in a formatted cmux panel with live reload.", "markdownUse": "Use it to show plans, docs, notes, and task lists beside the terminal while work is happening.", - "coverageTitle": "What the skills cover", - "coverageIntro": "The top-level SKILL.md files stay short. Deeper command details live in references, scripts, and templates next to each skill.", - "scopeHeader": "Scope", - "referencesHeader": "Reference coverage", - "cmuxScope": "Topology control for windows, workspaces, panes, surfaces, focus, moves, reorders, split-off flows, and attention flashes.", - "cmuxReferences": "Handle syntax, caller targeting, window and workspace lifecycle, pane and surface routing, trigger flash, and health checks.", - "workspaceScope": "Current-workspace automation rules for caller context, additive panes, helper surfaces, sidebar metadata, input, and socket selection.", - "workspaceReferences": "Workspace command reference covering context, windows, workspaces, panes, surfaces, input, sidebar state, notifications, docs, and tagged reloads.", - "settingsScope": "cmux.json settings reads and writes, key lookup, JSONC parsing, safe atomic updates, validation, editor opening, and shortcut binding edits.", - "settingsReferences": "Generated settings key list, shortcut action ids, schema URL, supported path detection, and the bundled cmux-settings helper.", - "customizationScope": "End-user customization across cmux.json settings, actions, commands, workspace layouts, plus-button click and right-click menus, surface tab bar buttons, Dock controls, Feed and notification hooks, sidebar metadata, Command Palette entries, shortcuts, and Ghostty config boundaries.", - "customizationReferences": "Config-surface selection, what can be customized, global versus project-local scope, action examples, plus-button wiring, tab bar button examples, Dock examples, Feed hooks, workspace layout examples, reload steps, validation rules, and safety constraints.", - "customizationExamplesTitle": "Customization examples library", - "customizationExamplesIntro": "These are reusable workflow patterns that a user can ask an agent to apply with cmux Customization.", - "exampleHeader": "Example", - "exampleSurfaceHeader": "Customizes", - "exampleUseHeader": "Good for", - "exampleWorktreeName": "Worktree agents", - "exampleWorktreeSurface": "Plus-button click, plus-button right-click menu, Command Palette, workspace layout", - "exampleWorktreeUse": "Opening a dedicated worktree or checkout with paired Codex, Claude, SSH, or helper terminals.", - "exampleFullStackName": "Full-stack dev", - "exampleFullStackSurface": "Workspace command, browser preview, Dock controls", - "exampleFullStackUse": "Starting frontend, backend, tests, logs, and preview panes in the same repeatable layout.", - "exampleSshName": "SSH devbox", - "exampleSshSurface": "Workspace command, remote terminal, browser surface", - "exampleSshUse": "Connecting to a remote environment while keeping local previews, notes, and navigation in cmux.", - "exampleReviewName": "Review PR", - "exampleReviewSurface": "Workspace command, browser surface, markdown or terminal notes", - "exampleReviewUse": "Opening GitHub status, the pull request, and review notes in a single workspace.", - "exampleDocsName": "Docs workspace", - "exampleDocsSurface": "Workspace command, markdown panel, browser preview", - "exampleDocsUse": "Editing documentation with the rendered page, local dev server, and source notes visible.", - "exampleCiName": "CI watch", - "exampleCiSurface": "Dock controls, Feed TUI, notification hooks", - "exampleCiUse": "Watching GitHub Actions, CircleCI, release monitors, and agent hook events without losing the main workspace.", - "exampleAgentButtonsName": "Quick agent buttons", - "exampleAgentButtonsSurface": "Surface tab bar buttons, actions, Command Palette entries", - "exampleAgentButtonsUse": "Adding one-click Codex, Claude, or custom-agent launchers while keeping built-in tab buttons visible.", - "customizationExamplesCallout": "Detailed snippets live in skills/cmux-customization/references/examples.md. Agents load that file only when an examples-library pattern is needed.", - "customizationExamplePrompts": "Example prompts", - "customizationPromptWorktree": "Use $cmux-customization to set up the worktree-agents example.", - "customizationPromptFullStack": "Use $cmux-customization to create a project-local full-stack-dev layout with Dock controls.", - "customizationPromptAgentButtons": "Use $cmux-customization to add quick-agent-buttons for Codex and Claude.", - "diagnosticsScope": "Read-only health checks for CLI reachability, socket access, cmux environment, settings validation, hook installation markers, session stores, auto-resume settings, and supported agent binaries.", - "diagnosticsReferences": "Bundled support-safe diagnostic script, hook setup commands, session restore interpretation, notification checks, and rules for redacting sensitive files.", - "browserScope": "Browser automation inside cmux webview surfaces, including navigation, DOM actions, waits, state capture, screenshots, and snapshots.", - "browserReferences": "Command mapping, snapshot ref lifecycle, authentication, session persistence, video status, proxy behavior, and reusable automation templates.", - "markdownScope": "Formatted markdown panels that live beside terminals and reload as files change.", - "markdownReferences": "Command syntax, routing options, live reload behavior, atomic file replacement, and markdown rendering coverage.", + "debugWindowsName": "cmux Debug Windows", + "debugWindowsDescription": "Manages Sidebar Debug, Background Debug, Menu Bar Extra Debug, and copyable debug snapshots.", + "debugWindowsUse": "Use it when iterating on debug window UI or collecting debug configuration from a tagged build.", + "releaseName": "cmux Release", + "releaseDescription": "Runs the cmux release workflow from version selection through changelog, release PR, tag, and artifact checks.", + "releaseUse": "Use it for maintainers cutting or preparing a cmux release.", "helpMenuTitle": "Help menu", "helpMenuIntro": "The macOS Help menu mirrors this docs sidebar and includes Skills. Use the Skills item in Help to open this page from the app.", "authoringTitle": "Skill layout", "authoringIntro": "Keep the main SKILL.md concise. Put deeper command tables, scripts, and reusable templates next to the skill.", "authoringCallout": "When adding a new skill, include agents/openai.yaml so the installer exposes a clear name, short description, and default prompt.", - "suggestionsTitle": "Suggested future skills", - "suggestionsIntro": "These are good candidates if the skill set grows beyond the current installed list.", - "suggestionHeader": "Skill idea", - "suggestionUseHeader": "Use case", - "suggestionWhyHeader": "Why it helps", - "suggestSshName": "cmux SSH", - "suggestSshUse": "Drive remote workspaces, SSH URL launches, browser routing through remote networks, and reconnect behavior.", - "suggestSshWhy": "Remote sessions are a major cmux surface with behavior that differs from local terminal workflows.", - "suggestCloudVmName": "cmux Cloud VM", - "suggestCloudVmUse": "Operate Cloud VM create, attach, exec, SSH endpoint, billing, and provider troubleshooting flows.", - "suggestCloudVmWhy": "Cloud VM work crosses the web app, database, provider APIs, and smoke tests.", - "suggestVaultName": "cmux Vault", - "suggestVaultUse": "Manage vault-backed agent configuration, credentials, and restore behavior without leaking secrets into prompts.", - "suggestVaultWhy": "Vault workflows need stricter handling than normal settings because they affect secrets and agent startup.", - "suggestionsCallout": "Keep future skills focused on repeatable workflows with real command sequences. If a skill would only restate product docs, add it to docs instead.", "relatedTitle": "Related docs", "relatedBrowserAutomation": "Browser Automation", "relatedApi": "API Reference", diff --git a/web/messages/ja.json b/web/messages/ja.json index 8298953062f6..e11bfbf65add 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -911,13 +911,12 @@ "skills": { "title": "スキル", "metaTitle": "スキル", - "metaDescription": "cmux のエージェントスキルをインストールして、CLI 制御、workspace 自動化、settings、customization、diagnostics、ブラウザ自動化、Markdown パネルに使えます。", - "intro": "cmux には、コーディングエージェントに cmux CLI 制御、current workspace 自動化、settings、customization、diagnostics、ブラウザサーフェス、Markdown パネルの使い方を教えるスキルが含まれています。", + "metaDescription": "cmux のエージェントスキルをインストールして、CLI 制御、ブラウザ自動化、Markdown パネル、デバッグウィンドウ、リリース作業に使えます。", + "intro": "cmux には、コーディングエージェントに cmux CLI 制御、ブラウザサーフェス、Markdown パネル、デバッグウィンドウ、リリースワークフローの使い方を教えるスキルが含まれています。", "installTitle": "インストール", - "installIntro": "標準の複数エージェント向けインストールには Vercel の skills CLI を使います。Codex の skills ディレクトリだけでよい場合は skills.sh も使えます。", - "installWithVercel": "Vercel skills でインストール", - "installWithSkillsSh": "skills.sh でインストール", - "installDestination": "デフォルトでは、skills.sh は ~/.codex/skills にインストールします。CODEX_HOME が設定されている場合は $CODEX_HOME/skills にインストールします。エージェントが別のディレクトリからスキルを読む場合は --dest を渡してください。", + "installIntro": "skills.sh を実行すると、リポジトリ内のスキルを Codex のスキルディレクトリへインストールできます。", + "installFromGitHub": "GitHub からインストール", + "installDestination": "デフォルトでは、スクリプトは ~/.codex/skills にインストールします。CODEX_HOME が設定されている場合は $CODEX_HOME/skills にインストールします。エージェントが別のディレクトリからスキルを読む場合は --dest を渡してください。", "localInstallTitle": "チェックアウトからインストール", "localInstallIntro": "クローン済みの cmux チェックアウトでは、スクリプトはローカルの skills ディレクトリを使います。", "localInstallCommands": "ローカルコマンド", @@ -930,93 +929,23 @@ "cmuxName": "cmux Core", "cmuxDescription": "cmux CLI でウィンドウ、ワークスペース、ペイン、サーフェス、フォーカス、移動、並べ替え、ルーティングを制御します。", "cmuxUse": "複数ペインの cmux レイアウト内で、エージェントが決定的な配置や移動を必要とするときに使います。", - "workspaceName": "cmux Workspace", - "workspaceDescription": "現在の呼び出し元ワークスペース、呼び出し元サーフェス、ペイン、サイドバー metadata、socket context に自動化を限定します。", - "workspaceUse": "ほかのワークスペースを邪魔せずに、エージェントがペイン追加、入力送信、状態確認、補助サーフェス作成を行うときに使います。", - "settingsName": "cmux Settings", - "settingsDescription": "同梱 helper script で ~/.config/cmux/cmux.json を確認、編集、検証、open します。", - "settingsUse": "appearance、sidebar、notification、browser、automation、shortcut の設定を JSON path で変更するときに使います。", - "customizationName": "cmux Customization", - "customizationDescription": "cmux.json の action、プラスボタン動作、タブバー button、workspace layout、Dock control、Feed hook、sidebar setting、Command Palette entry、shortcut、Ghostty が扱う terminal preference を調整します。", - "customizationUse": "worktree、複数 checkout、SSH session、dev tool、project layout を、ユーザーが望む UI entrypoint から開けるようにするときに使います。", - "diagnosticsName": "cmux Diagnostics", - "diagnosticsDescription": "cmux CLI、socket access、hook、session restore、settings、agent binary を、support-safe に確認します。", - "diagnosticsUse": "notification、hook、restore、automation が期待どおり動かないときに使います。", "browserName": "cmux Browser", "browserDescription": "snapshot 参照、DOM 操作、待機、スクリーンショット、セッション状態を使って cmux の webview サーフェスを自動化します。", "browserUse": "別のブラウザ自動化ツールではなく、cmux 内で実行すべきブラウザタスクに使います。", "markdownName": "cmux Markdown Viewer", "markdownDescription": "Markdown ファイルを、ライブリロード付きの整形された cmux パネルで開きます。", "markdownUse": "作業中に計画、ドキュメント、メモ、タスクリストをターミナルの横に表示するときに使います。", - "coverageTitle": "スキルが扱う範囲", - "coverageIntro": "トップレベルの SKILL.md は短く保ちます。詳しいコマンド情報は、各スキルの横にある references、scripts、templates に置きます。", - "scopeHeader": "範囲", - "referencesHeader": "参照資料の内容", - "cmuxScope": "ウィンドウ、ワークスペース、ペイン、サーフェス、フォーカス、移動、並べ替え、split-off、注意喚起フラッシュのトポロジー制御です。", - "cmuxReferences": "ハンドル構文、呼び出し元ターゲティング、ウィンドウとワークスペースのライフサイクル、ペインとサーフェスのルーティング、trigger flash、ヘルスチェックです。", - "workspaceScope": "呼び出し元 context、追加型ペイン、補助サーフェス、sidebar metadata、入力、socket 選択に関する current workspace 自動化ルールです。", - "workspaceReferences": "context、window、workspace、pane、surface、input、sidebar state、notification、docs、tagged reload を扱う workspace command reference です。", - "settingsScope": "cmux.json の読み書き、key lookup、JSONC parsing、安全な atomic update、validation、editor open、shortcut binding 編集です。", - "settingsReferences": "生成済み settings key list、shortcut action id、schema URL、supported path 検出、同梱 cmux-settings helper です。", - "customizationScope": "cmux.json settings、action、command、workspace layout、プラスボタンの click と右クリック menu、surface tab bar button、Dock control、Feed と notification hook、sidebar metadata、Command Palette entry、shortcut、Ghostty config との境界をまたぐ end-user customization です。", - "customizationReferences": "設定面の選択、customize できる範囲、global と project-local の使い分け、action 例、プラスボタン wiring、tab bar button 例、Dock 例、Feed hook、workspace layout 例、reload 手順、validation rule、安全制約です。", - "customizationExamplesTitle": "Customization examples library", - "customizationExamplesIntro": "cmux Customization を使って agent に適用を依頼できる、再利用可能な workflow pattern です。", - "exampleHeader": "例", - "exampleSurfaceHeader": "カスタマイズ対象", - "exampleUseHeader": "向いている用途", - "exampleWorktreeName": "Worktree agents", - "exampleWorktreeSurface": "プラスボタン click、プラスボタン右クリック menu、Command Palette、workspace layout", - "exampleWorktreeUse": "専用 worktree や checkout を開き、Codex、Claude、SSH、補助 terminal を並べる用途です。", - "exampleFullStackName": "Full-stack dev", - "exampleFullStackSurface": "Workspace command、browser preview、Dock control", - "exampleFullStackUse": "frontend、backend、test、log、preview pane を同じ再現可能な layout で起動します。", - "exampleSshName": "SSH devbox", - "exampleSshSurface": "Workspace command、remote terminal、browser surface", - "exampleSshUse": "remote 環境へ接続しつつ、local preview、note、navigation を cmux 内に保ちます。", - "exampleReviewName": "Review PR", - "exampleReviewSurface": "Workspace command、browser surface、markdown または terminal notes", - "exampleReviewUse": "GitHub status、pull request、review note を 1 つの workspace で開きます。", - "exampleDocsName": "Docs workspace", - "exampleDocsSurface": "Workspace command、markdown panel、browser preview", - "exampleDocsUse": "rendered page、local dev server、source note を見ながら documentation を編集します。", - "exampleCiName": "CI watch", - "exampleCiSurface": "Dock control、Feed TUI、notification hook", - "exampleCiUse": "main workspace を邪魔せずに GitHub Actions、CircleCI、release monitor、agent hook event を監視します。", - "exampleAgentButtonsName": "Quick agent buttons", - "exampleAgentButtonsSurface": "Surface tab bar button、action、Command Palette entry", - "exampleAgentButtonsUse": "built-in tab button を残しつつ、Codex、Claude、custom agent の one-click launcher を追加します。", - "customizationExamplesCallout": "詳しい snippet は skills/cmux-customization/references/examples.md にあります。agent は examples-library pattern が必要なときだけそのファイルを読み込みます。", - "customizationExamplePrompts": "プロンプト例", - "customizationPromptWorktree": "$cmux-customization を使って worktree-agents example を設定してください。", - "customizationPromptFullStack": "$cmux-customization を使って、Dock control 付きの project-local full-stack-dev layout を作成してください。", - "customizationPromptAgentButtons": "$cmux-customization を使って Codex と Claude の quick-agent-buttons を追加してください。", - "diagnosticsScope": "CLI 到達性、socket access、cmux environment、settings validation、hook installation marker、session store、auto-resume setting、対応 agent binary の read-only health check です。", - "diagnosticsReferences": "同梱の support-safe diagnostic script、hook setup command、session restore の解釈、notification check、機密ファイルを伏せるルールです。", - "browserScope": "cmux の webview サーフェス内でのブラウザ自動化です。ナビゲーション、DOM 操作、待機、状態取得、スクリーンショット、snapshot を扱います。", - "browserReferences": "コマンド対応表、snapshot 参照のライフサイクル、認証、セッション永続化、動画の現状、プロキシ動作、再利用可能な自動化テンプレートです。", - "markdownScope": "ターミナルの横に置けて、ファイル変更に合わせて再読み込みされる整形済み Markdown パネルです。", - "markdownReferences": "コマンド構文、ルーティングオプション、ライブリロード動作、atomic なファイル置換、Markdown レンダリング範囲です。", + "debugWindowsName": "cmux Debug Windows", + "debugWindowsDescription": "Sidebar Debug、Background Debug、Menu Bar Extra Debug、コピー可能なデバッグ snapshot を管理します。", + "debugWindowsUse": "デバッグウィンドウ UI を調整するときや、タグ付きビルドからデバッグ設定を収集するときに使います。", + "releaseName": "cmux Release", + "releaseDescription": "バージョン選択から changelog、リリース PR、タグ、アーティファクト確認まで、cmux のリリースワークフローを実行します。", + "releaseUse": "cmux リリースを準備または作成するメンテナー向けです。", "helpMenuTitle": "ヘルプメニュー", "helpMenuIntro": "macOS の Help メニューはこのドキュメントサイドバーに合わせており、Skills も含みます。アプリからこのページを開くには、Help の Skills 項目を使います。", "authoringTitle": "スキルの構成", "authoringIntro": "メインの SKILL.md は簡潔に保ちます。詳しいコマンド表、スクリプト、再利用可能なテンプレートはスキルの横に置きます。", "authoringCallout": "新しいスキルを追加するときは、インストーラーが明確な名前、短い説明、デフォルトプロンプトを表示できるように agents/openai.yaml を含めてください。", - "suggestionsTitle": "今後あると便利なスキル", - "suggestionsIntro": "現在インストールされるスキルより先に広げるなら、次の候補が有用です。", - "suggestionHeader": "スキル案", - "suggestionUseHeader": "用途", - "suggestionWhyHeader": "有用な理由", - "suggestSshName": "cmux SSH", - "suggestSshUse": "リモートワークスペース、SSH URL 起動、リモートネットワーク経由のブラウザルーティング、再接続動作を扱います。", - "suggestSshWhy": "リモートセッションは cmux の主要な surface で、ローカル terminal workflow と異なる挙動があります。", - "suggestCloudVmName": "cmux Cloud VM", - "suggestCloudVmUse": "Cloud VM の create、attach、exec、SSH endpoint、billing、provider トラブルシュートを扱います。", - "suggestCloudVmWhy": "Cloud VM 作業は web app、database、provider API、smoke test をまたぎます。", - "suggestVaultName": "cmux Vault", - "suggestVaultUse": "secret を prompt に漏らさず、vault-backed agent configuration、credential、restore behavior を管理します。", - "suggestVaultWhy": "Vault workflow は secret と agent startup に関わるため、通常の settings より厳密な扱いが必要です。", - "suggestionsCallout": "今後のスキルは、実際のコマンド列を持つ反復可能なワークフローに絞ってください。製品ドキュメントを言い換えるだけなら、スキルではなくドキュメントに追加します。", "relatedTitle": "関連ドキュメント", "relatedBrowserAutomation": "ブラウザ自動化", "relatedApi": "API リファレンス", diff --git a/web/scripts/cloud-vm/smoke-vm-api.mjs b/web/scripts/cloud-vm/smoke-vm-api.mjs index fd45a532ca44..2612850cc162 100755 --- a/web/scripts/cloud-vm/smoke-vm-api.mjs +++ b/web/scripts/cloud-vm/smoke-vm-api.mjs @@ -1,9 +1,6 @@ #!/usr/bin/env node -import { execFileSync } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { @@ -13,14 +10,11 @@ import { requireEnvKeys, } from "./projects.mjs"; -const usage = "Usage: smoke-vm-api.mjs [web-dir] [--create] [--provider e2b|freestyle] [--url https://preview.example] [--vercel-curl] [--skip-attach]"; +const usage = "Usage: smoke-vm-api.mjs [web-dir] [--create] [--provider e2b|freestyle]"; const args = process.argv.slice(2); const { webDir, target, project, rest } = parseWebDirAndTarget(args, usage); const shouldCreate = rest.includes("--create"); -const useVercelCurl = rest.includes("--vercel-curl"); -const skipAttach = rest.includes("--skip-attach"); const provider = optionValue(rest, "--provider") ?? "e2b"; -const targetUrl = optionValue(rest, "--url") ?? project.url; const REQUEST_TIMEOUT_MS = 45_000; if (shouldCreate && provider !== "e2b" && provider !== "freestyle") { @@ -37,7 +31,6 @@ let vmId; let authHeaders; async function fetchWithTimeout(url, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) { - if (useVercelCurl) return vercelCurlFetch(url, init, timeoutMs); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { @@ -47,60 +40,6 @@ async function fetchWithTimeout(url, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) } } -function vercelCurlFetch(url, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) { - const parsed = new URL(url); - const scratch = mkdtempSync(path.join(tmpdir(), "cmux-vercel-curl-")); - const responsePath = path.join(scratch, "response.txt"); - const bodyPath = path.join(scratch, "body.txt"); - const configPath = path.join(scratch, "curl.conf"); - try { - const headers = init.headers ?? {}; - const lines = [ - "silent", - "show-error", - "location", - `output = ${JSON.stringify(responsePath)}`, - 'write-out = "%{http_code}"', - ]; - const method = init.method?.toUpperCase(); - if (method) lines.push(`request = ${JSON.stringify(method)}`); - for (const [name, value] of Object.entries(headers)) { - lines.push(`header = ${JSON.stringify(`${name}: ${value}`)}`); - } - if (init.body !== undefined) { - writeFileSync(bodyPath, init.body); - lines.push(`data-binary = ${JSON.stringify(`@${bodyPath}`)}`); - } - writeFileSync(configPath, `${lines.join("\n")}\n`, { mode: 0o600 }); - - const statusOutput = execFileSync("vercel", [ - "curl", - `${parsed.pathname}${parsed.search}`, - "--deployment", - parsed.origin, - "--scope", - "manaflow", - "--", - "--config", - configPath, - ], { - encoding: "utf8", - timeout: timeoutMs + 10_000, - stdio: ["ignore", "pipe", "pipe"], - }).trim(); - const statusMatch = statusOutput.match(/(\d{3})$/); - if (!statusMatch) throw new Error(`vercel curl did not return an HTTP status: ${statusOutput}`); - const status = Number(statusMatch[1]); - const responseText = readFileSync(responsePath, "utf8"); - return { - status, - text: async () => responseText, - }; - } finally { - rmSync(scratch, { recursive: true, force: true }); - } -} - try { const env = loadTargetEnv(project); requireEnvKeys(env, [ @@ -130,10 +69,10 @@ try { "x-stack-refresh-token": tokens.refreshToken, }; - const unauth = await fetchWithTimeout(`${targetUrl}/api/vm`); + const unauth = await fetchWithTimeout(`${project.url}/api/vm`); if (unauth.status !== 401) throw new Error(`unauthenticated GET /api/vm expected 401, got ${unauth.status}`); - const authed = await fetchWithTimeout(`${targetUrl}/api/vm`, { headers: authHeaders }); + const authed = await fetchWithTimeout(`${project.url}/api/vm`, { headers: authHeaders }); const authedText = await authed.text(); if (authed.status !== 200) throw new Error(`authenticated GET /api/vm expected 200, got ${authed.status}: ${authedText}`); const authedJson = JSON.parse(authedText); @@ -142,20 +81,17 @@ try { ok: true, target, projectId, - url: targetUrl, unauthStatus: unauth.status, authedListStatus: authed.status, beforeCount: Array.isArray(authedJson.vms) ? authedJson.vms.length : null, }; if (shouldCreate) { - const createStartedAt = performance.now(); - const create = await fetchWithTimeout(`${targetUrl}/api/vm`, { + const create = await fetchWithTimeout(`${project.url}/api/vm`, { method: "POST", headers: { ...authHeaders, "content-type": "application/json", "idempotency-key": `smoke-${suffix}` }, body: JSON.stringify({ provider }), }); - const createDurationMs = Math.round(performance.now() - createStartedAt); const createText = await create.text(); if (create.status !== 200) throw new Error(`POST /api/vm expected 200, got ${create.status}: ${createText}`); const created = JSON.parse(createText); @@ -165,29 +101,20 @@ try { } vmId = created.id; - let attachTransport; - let attachDurationMs; - if (!skipAttach) { - const attachStartedAt = performance.now(); - const attach = await fetchWithTimeout(`${targetUrl}/api/vm/${encodeURIComponent(vmId)}/attach-endpoint`, { - method: "POST", - headers: { ...authHeaders, "content-type": "application/json" }, - body: JSON.stringify({ requireDaemon: true }), - }); - attachDurationMs = Math.round(performance.now() - attachStartedAt); - const attachText = await attach.text(); - if (attach.status !== 200) throw new Error(`POST attach-endpoint expected 200, got ${attach.status}: ${attachText}`); - const attached = JSON.parse(attachText); - if (attached.transport !== "websocket") throw new Error(`expected websocket attach, got ${attached.transport}`); - attachTransport = attached.transport; - } + const attach = await fetchWithTimeout(`${project.url}/api/vm/${encodeURIComponent(vmId)}/attach-endpoint`, { + method: "POST", + headers: { ...authHeaders, "content-type": "application/json" }, + body: JSON.stringify({ requireDaemon: true }), + }); + const attachText = await attach.text(); + if (attach.status !== 200) throw new Error(`POST attach-endpoint expected 200, got ${attach.status}: ${attachText}`); + const attached = JSON.parse(attachText); + if (attached.transport !== "websocket") throw new Error(`expected websocket attach, got ${attached.transport}`); - const destroyStartedAt = performance.now(); - const destroy = await fetchWithTimeout(`${targetUrl}/api/vm/${encodeURIComponent(vmId)}`, { + const destroy = await fetchWithTimeout(`${project.url}/api/vm/${encodeURIComponent(vmId)}`, { method: "DELETE", headers: authHeaders, }); - const destroyDurationMs = Math.round(performance.now() - destroyStartedAt); const destroyText = await destroy.text(); if (destroy.status !== 200) throw new Error(`DELETE /api/vm/${vmId} expected 200, got ${destroy.status}: ${destroyText}`); vmId = undefined; @@ -195,12 +122,8 @@ try { Object.assign(result, { createdProvider: created.provider, imageVersion: created.imageVersion, - createDurationMs, - ...(skipAttach - ? { attachSkipped: true } - : { attachTransport, attachDurationMs }), + attachTransport: attached.transport, destroyed: true, - destroyDurationMs, }); } @@ -208,7 +131,7 @@ try { } catch (error) { if (vmId && authHeaders) { try { - const destroy = await fetchWithTimeout(`${targetUrl}/api/vm/${encodeURIComponent(vmId)}`, { + const destroy = await fetchWithTimeout(`${project.url}/api/vm/${encodeURIComponent(vmId)}`, { method: "DELETE", headers: authHeaders, }); diff --git a/web/scripts/cloud-vm/stress-vm-api.mjs b/web/scripts/cloud-vm/stress-vm-api.mjs index 06b609fc63be..e91a2536b602 100644 --- a/web/scripts/cloud-vm/stress-vm-api.mjs +++ b/web/scripts/cloud-vm/stress-vm-api.mjs @@ -11,12 +11,11 @@ import { requireEnvKeys, } from "./projects.mjs"; -const usage = "Usage: stress-vm-api.mjs [web-dir] [--count N] [--concurrency N] [--provider e2b|freestyle|default] [--url https://preview.example]"; +const usage = "Usage: stress-vm-api.mjs [web-dir] [--count N] [--concurrency N] [--provider e2b|freestyle|default]"; const { webDir, target, project, rest } = parseWebDirAndTarget(process.argv.slice(2), usage); const count = positiveInteger(optionValue(rest, "--count") ?? "8", "--count"); const concurrency = Math.min(positiveInteger(optionValue(rest, "--concurrency") ?? "4", "--concurrency"), count); const provider = optionValue(rest, "--provider") ?? "default"; -const targetUrl = optionValue(rest, "--url") ?? project.url; if (!["default", "e2b", "freestyle"].includes(provider)) { console.error("--provider must be default, e2b, or freestyle"); process.exit(2); @@ -79,16 +78,12 @@ console.log(JSON.stringify({ ok: !interruptedSignal && failures.length === 0, target, project: project.projectName, - url: targetUrl, provider, count, concurrency, attempted: attempted.length, interruptedSignal: interruptedSignal || undefined, durationMs: Date.now() - startedAt, - createDurationMs: summarizeDurations(successes.map((result) => result.createDurationMs)), - attachDurationMs: summarizeDurations(successes.map((result) => result.attachDurationMs)), - rpcDurationMs: summarizeDurations(successes.map((result) => result.rpcDurationMs)), successes: successes.length, providerCounts, imageVersionCounts, @@ -103,9 +98,6 @@ async function runCase(index) { let user; let vmId; let authHeaders; - let createDurationMs; - let attachDurationMs; - let rpcDurationMs; const cleanup = { vmDeleted: false, userDeleted: false }; let cleanupPromise; const cleanupCase = () => { @@ -136,8 +128,7 @@ async function runCase(index) { }; const createBody = provider === "default" ? {} : { provider }; - const createStartedAt = performance.now(); - const create = await fetchWithTimeout(`${targetUrl}/api/vm`, { + const create = await fetchWithTimeout(`${project.url}/api/vm`, { method: "POST", headers: { ...authHeaders, @@ -146,7 +137,6 @@ async function runCase(index) { }, body: JSON.stringify(createBody), }, 90_000); - createDurationMs = Math.round(performance.now() - createStartedAt); const createText = await create.text(); if (create.status !== 200) throw new Error(`POST /api/vm expected 200, got ${create.status}: ${createText}`); const created = JSON.parse(createText); @@ -154,13 +144,11 @@ async function runCase(index) { vmId = created.id; throwIfInterrupted(); - const attachStartedAt = performance.now(); - const attach = await fetchWithTimeout(`${targetUrl}/api/vm/${encodeURIComponent(vmId)}/attach-endpoint`, { + const attach = await fetchWithTimeout(`${project.url}/api/vm/${encodeURIComponent(vmId)}/attach-endpoint`, { method: "POST", headers: { ...authHeaders, "content-type": "application/json" }, body: JSON.stringify({ requireDaemon: true }), }, 45_000); - attachDurationMs = Math.round(performance.now() - attachStartedAt); const attachText = await attach.text(); if (attach.status !== 200) throw new Error(`POST attach-endpoint expected 200, got ${attach.status}: ${attachText}`); const attached = JSON.parse(attachText); @@ -170,9 +158,7 @@ async function runCase(index) { } throwIfInterrupted(); - const rpcStartedAt = performance.now(); const rpc = await rpcProxyHealthz(attached.daemon.url, attached.daemon.token, attached.daemon.sessionId); - rpcDurationMs = Math.round(performance.now() - rpcStartedAt); throwIfInterrupted(); return { ok: true, @@ -181,9 +167,6 @@ async function runCase(index) { imageVersion: created.imageVersion, attachTransport: attached.transport, rpcCapabilities: rpc.capabilities, - createDurationMs, - attachDurationMs, - rpcDurationMs, durationMs: Date.now() - started, cleanup, }; @@ -193,9 +176,6 @@ async function runCase(index) { index, message: error instanceof Error ? error.message : String(error), vmIdSet: !!vmId, - createDurationMs, - attachDurationMs, - rpcDurationMs, durationMs: Date.now() - started, cleanup, }; @@ -212,7 +192,7 @@ async function cleanupRun({ getVmId, getAuthHeaders, getUser, cleanup }) { if (vmId && authHeaders) { try { - const destroy = await fetchWithTimeout(`${targetUrl}/api/vm/${encodeURIComponent(vmId)}`, { + const destroy = await fetchWithTimeout(`${project.url}/api/vm/${encodeURIComponent(vmId)}`, { method: "DELETE", headers: authHeaders, }, 45_000); @@ -381,27 +361,3 @@ function countBy(values) { return acc; }, {}); } - -function summarizeDurations(values) { - const numbers = values - .filter((value) => Number.isFinite(value)) - .sort((a, b) => a - b); - if (numbers.length === 0) return null; - const total = numbers.reduce((sum, value) => sum + value, 0); - return { - min: numbers[0], - p50: percentile(numbers, 0.5), - p95: percentile(numbers, 0.95), - max: numbers[numbers.length - 1], - avg: Math.round(total / numbers.length), - }; -} - -function percentile(sortedNumbers, fraction) { - if (sortedNumbers.length === 1) return sortedNumbers[0]; - const index = Math.max(0, Math.min( - sortedNumbers.length - 1, - Math.ceil(sortedNumbers.length * fraction) - 1, - )); - return sortedNumbers[index]; -} diff --git a/web/services/vms/README.md b/web/services/vms/README.md index a5000a1ecbbd..9b7f7cc1a6e1 100644 --- a/web/services/vms/README.md +++ b/web/services/vms/README.md @@ -194,14 +194,14 @@ Set these Vercel environment variables per production/staging environment: - `E2B_CMUXD_WS_TEMPLATE`, E2B template alias/name for WebSocket PTY sandboxes. - `FREESTYLE_SANDBOX_SNAPSHOT`, Freestyle snapshot id. - `CMUX_VM_DEFAULT_PROVIDER`, `freestyle` or `e2b`. -- `CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID`, optional Stack Auth team item used as the free-plan create-credit bucket. Leave unset to skip free-plan create-credit accounting; set to `none`, `disabled`, `off`, or `false` to explicitly opt out. +- `CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID`, Stack Auth team item used as the free-plan create-credit bucket. Defaults to `cmux-vm-create-credit`; set to `none`, `disabled`, `off`, or `false` to opt out. - `CMUX_VM_PLAN_FREE_CREATE_CREDIT_COST`, optional free-plan per-create cost. Defaults to `1`. - `CMUX_VM_PLAN_FREE_INITIAL_CREATE_CREDITS`, optional first-use seed for the free-plan Stack Auth create-credit item. Defaults to `20`. - `CMUX_VM_CREATE_CREDIT_ITEM_ID`, optional global Stack Auth item used as a prepaid create-credit bucket for every plan without a plan-specific item. Set to `none`, `disabled`, `off`, or `false` to opt out of create credits for plans without a plan-specific value. - `CMUX_VM_CREATE_CREDIT_COST`, default `1`. - `CMUX_VM_CREATE_CREDIT_COST_E2B`, optional provider-specific override. - `CMUX_VM_CREATE_CREDIT_COST_FREESTYLE`, optional provider-specific override. -- `CMUX_VM_FREE_MAX_ACTIVE_VMS`, default `5`. +- `CMUX_VM_FREE_MAX_ACTIVE_VMS`, default `1`. - `CMUX_VM_PAID_MAX_ACTIVE_VMS`, default `10`. - Stack Auth environment variables. - Axiom/OpenTelemetry exporter variables. @@ -314,6 +314,6 @@ Operational note: Freestyle is the intended default when `CMUX_VM_DEFAULT_PROVID ## Usage, limits, and pricing -The usage ledger is in Postgres. VM create pricing gates can use Stack Auth payment items, but free-plan create credits are opt-in. Configure `CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID` only when the free plan should consume a prepaid create-credit bucket. When enabled, the create workflow records a one-time local grant row, seeds the configured Stack Auth item credits once per billing team, reserves one create credit only for a newly inserted row, calls the provider, and refunds the credit if provisioning fails before a usable VM exists. +The usage ledger is in Postgres. VM create pricing gates use Stack Auth payment items. The free plan uses the team-scoped item `cmux-vm-create-credit` by default. Configure the Stack Auth free product as team-owned, include-by-default, and grant 20 of that item with no repeat and no expiry for dashboard visibility. Because Stack Auth currently does not materialize include-by-default item grants for normal project teams, the create workflow also records a one-time local grant row and seeds 20 Stack Auth item credits the first time a free-plan team creates a VM. Set `CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID=none` in local or self-hosted deployments that intentionally do not use Stack Auth create credits. The create workflow inserts the idempotent VM row first, seeds the free-plan credits once per billing team, reserves one Stack Auth create credit only for a newly inserted row, calls the provider, and refunds the credit if provisioning fails before a usable VM exists. -Plan limits are team-based. Stack Auth personal teams should stay enabled for both dev/staging and production projects (`createTeamOnSignUp` / `teams.createPersonalTeamOnSignUp`). New VM rows store `billing_team_id` and `billing_plan_id`; the free plan allows five active VMs at a time by default. Paused and destroyed VMs do not count against the active limit. Paid plan activation should write a readable plan id such as `pro` into Stack Auth team read-only metadata (`cmuxVmPlan`) or equivalent billing sync metadata, then configure the matching `CMUX_VM_PLAN__MAX_ACTIVE_VMS` env var. Paid plans only consume Stack Auth create credits when `CMUX_VM_PLAN__CREATE_CREDIT_ITEM_ID` or the global `CMUX_VM_CREATE_CREDIT_ITEM_ID` is configured. +Plan limits are team-based. Stack Auth personal teams should stay enabled for both dev/staging and production projects (`createTeamOnSignUp` / `teams.createPersonalTeamOnSignUp`). New VM rows store `billing_team_id` and `billing_plan_id`; the free plan allows one active VM at a time and 20 total successful creates by default. Paid plan activation should write a readable plan id such as `pro` into Stack Auth team read-only metadata (`cmuxVmPlan`) or equivalent billing sync metadata, then configure the matching `CMUX_VM_PLAN__MAX_ACTIVE_VMS` env var. Paid plans only consume Stack Auth create credits when `CMUX_VM_PLAN__CREATE_CREDIT_ITEM_ID` or the global `CMUX_VM_CREATE_CREDIT_ITEM_ID` is configured. diff --git a/web/services/vms/auth.ts b/web/services/vms/auth.ts index 694a25c243b4..8ce4061f2a7e 100644 --- a/web/services/vms/auth.ts +++ b/web/services/vms/auth.ts @@ -25,10 +25,7 @@ export type AuthedTeam = { * * Returns the resolved user or null if unauthenticated. */ -export async function verifyRequest( - request: Request, - options: { readonly requestedTeamId?: string | null } = {}, -): Promise { +export async function verifyRequest(request: Request): Promise { if (!isStackConfigured()) { return null; } @@ -45,7 +42,7 @@ export async function verifyRequest( tokenStore: { accessToken, refreshToken }, }); if (user) { - return await authedUserFromStackUser(user, options); + return await authedUserFromStackUser(user); } } } @@ -53,21 +50,14 @@ export async function verifyRequest( // Fall back to the Next.js cookie flow (when browser hits the route). const user = await stackServerApp.getUser({ tokenStore: request as unknown as { headers: { get(name: string): string | null } } }); if (user) { - return await authedUserFromStackUser(user, options); + return await authedUserFromStackUser(user); } return null; } -async function authedUserFromStackUser( - user: StackUserLike, - options: { readonly requestedTeamId?: string | null }, -): Promise { +async function authedUserFromStackUser(user: StackUserLike): Promise { const selectedTeam = teamLike(user.selectedTeam); - const requestedTeamId = normalizedOptionalString(options.requestedTeamId); - // When the selected team is enough, entitlements resolve from it before any - // multi-team guard needs a full team list. - const needsListedTeams = !selectedTeam || (!!requestedTeamId && requestedTeamId !== selectedTeam.id); - const listedTeams = needsListedTeams && typeof user.listTeams === "function" + const listedTeams = typeof user.listTeams === "function" ? (await user.listTeams()).map(teamLike).filter((team): team is TeamLike => !!team) : []; const teamIds = uniqueStrings([ @@ -142,11 +132,6 @@ function uniqueTeams(values: readonly (TeamLike | null | undefined)[]): readonly return teams; } -function normalizedOptionalString(value: string | null | undefined): string | null { - const normalized = value?.trim(); - return normalized ? normalized : null; -} - export function unauthorized(): Response { return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, diff --git a/web/services/vms/billingGateway.ts b/web/services/vms/billingGateway.ts index 7d83e1a6c3d7..25d109b12c4e 100644 --- a/web/services/vms/billingGateway.ts +++ b/web/services/vms/billingGateway.ts @@ -190,7 +190,7 @@ function createCreditItemId( if (global.kind === "disabled") return null; if (global.kind === "item") return global.itemId; - return null; + return normalizedPlanId(planId) === "free" ? DEFAULT_FREE_CREATE_CREDIT_ITEM_ID : null; } function resolvedCreateCreditItemIdValue( diff --git a/web/services/vms/entitlements.ts b/web/services/vms/entitlements.ts index 0a2aa21529f6..2114361f6d17 100644 --- a/web/services/vms/entitlements.ts +++ b/web/services/vms/entitlements.ts @@ -114,7 +114,7 @@ function activeVmLimitForPlan(planId: string, env: Record Effect.Effect; + }) => Effect.Effect; readonly markCreateRunning: (input: { readonly id: string; readonly providerVmId: string; @@ -90,16 +90,6 @@ export type VmRepositoryShape = { readonly imageId?: string; readonly metadata?: Record; }) => Effect.Effect; - readonly recordUsageEvents: (inputs: readonly { - readonly userId: string; - readonly billingTeamId?: string | null; - readonly billingPlanId?: string | null; - readonly vmId?: string | null; - readonly eventType: string; - readonly provider?: ProviderId; - readonly imageId?: string; - readonly metadata?: Record; - }[]) => Effect.Effect; }; export class VmRepository extends Context.Tag("cmux/VmRepository")< @@ -308,7 +298,7 @@ export const VmRepositoryLive = Layer.succeed(VmRepository, { markProviderObservedStatus: (input) => dbEffect("markProviderObservedStatus", async () => { const db = cloudDb(); - const updated = await db + await db .update(cloudVms) .set({ status: input.status, @@ -321,9 +311,7 @@ export const VmRepositoryLive = Layer.succeed(VmRepository, { eq(cloudVms.providerVmId, input.providerVmId), ne(cloudVms.status, "destroyed"), ), - ) - .returning({ id: cloudVms.id }); - return updated.length > 0; + ); }), markCreateRunning: (input) => @@ -478,19 +466,4 @@ export const VmRepositoryLive = Layer.succeed(VmRepository, { metadata: input.metadata ?? {}, }); }), - recordUsageEvents: (inputs) => - dbEffect("recordUsageEvents", async () => { - if (inputs.length === 0) return; - const db = cloudDb(); - await db.insert(cloudVmUsageEvents).values(inputs.map((input) => ({ - userId: input.userId, - billingTeamId: input.billingTeamId ?? null, - billingPlanId: input.billingPlanId ?? null, - vmId: input.vmId ?? null, - eventType: input.eventType, - provider: input.provider, - imageId: input.imageId, - metadata: input.metadata ?? {}, - }))); - }), }); diff --git a/web/services/vms/routeHelpers.ts b/web/services/vms/routeHelpers.ts index 458ac65df573..78a5ae676e4d 100644 --- a/web/services/vms/routeHelpers.ts +++ b/web/services/vms/routeHelpers.ts @@ -6,7 +6,6 @@ import { isVmDatabaseError, isVmProviderOperationError, } from "./errors"; -import { recordSpanTiming } from "./timings"; /** Bearer + refresh token pair the mac app stashes in keychain. */ export type StackBearer = { accessToken: string; refreshToken: string }; @@ -24,9 +23,6 @@ export function parseBearer(request: Request): StackBearer | null { export type AuthedVmRouteContext = { user: AuthedUser; span: Span; - authDurationMs: number; - routeStartedAtMs: number; - setResponseFinalizer: (finalizer: ((response: Response) => void) | null) => void; }; export async function withAuthedVmApiRoute( @@ -41,45 +37,26 @@ export async function withAuthedVmApiRoute( route, { "cmux.subsystem": "vm-cloud", ...attributes }, async (span) => { - let responseFinalizer: ((response: Response) => void) | null = null; - const setResponseFinalizer = (finalizer: ((response: Response) => void) | null) => { - responseFinalizer = finalizer; - }; - const finalize = (response: Response): Response => { - if (!responseFinalizer) return response; - try { - responseFinalizer(response); - } catch (err) { - recordSpanError(span, err); - console.error(`${failureLog}: response finalizer failed`, err); - } - return response; - }; - try { - const routeStartedAtMs = performance.now(); const bearer = parseBearer(request); - const authStart = performance.now(); - const user = await verifyRequest(request, { requestedTeamId: requestedVmTeamIdFromRequest(request) }); - const authDurationMs = performance.now() - authStart; - recordSpanTiming(span, "auth", authDurationMs); + const user = await verifyRequest(request); if (!user) return unauthorized(); if (requiresBrowserMutationProtection(request.method, bearer) && !browserMutationOriginAllowed(request)) { return jsonResponse({ error: "forbidden" }, 403); } - return finalize(await handler({ user, span, authDurationMs, routeStartedAtMs, setResponseFinalizer })); + return await handler({ user, span }); } catch (err) { recordSpanError(span, err); console.error(failureLog, err); const workflowError = vmWorkflowErrorResponse(err); - if (workflowError) return finalize(workflowError); - return finalize(vmErrorResponse({ + if (workflowError) return workflowError; + return vmErrorResponse({ error: "vm_internal_error", status: 500, message: "Cloud VM request failed unexpectedly.", action: "Try again. If it keeps failing, copy this error and contact support so we can inspect the server logs.", details: { route }, - })); + }); } }, ); diff --git a/web/services/vms/timings.ts b/web/services/vms/timings.ts deleted file mode 100644 index 3922ef55efb1..000000000000 --- a/web/services/vms/timings.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { Span } from "@opentelemetry/api"; -import * as Effect from "effect/Effect"; - -export type VmTimingStage = - | "auth" - | "request_parse" - | "entitlements" - | "begin_create" - | "limit_reconcile" - | "billing" - | "provider_create" - | "mark_running" - | "usage_events" - | "total"; - -export type VmTimingSink = { - readonly record: (stage: VmTimingStage, durationMs: number) => void; -}; - -export class VmTimingRecorder implements VmTimingSink { - private readonly durations = new Map(); - private readonly counts = new Map(); - private readonly startedAt: number; - private readonly debugTimings: boolean; - private finished = false; - - constructor( - private readonly span: Span, - private readonly operation: string, - options: { readonly startedAt?: number; readonly debugTimings?: boolean } = {}, - ) { - this.startedAt = options.startedAt ?? performance.now(); - this.debugTimings = options.debugTimings ?? process.env.CMUX_VM_DEBUG_TIMINGS === "1"; - } - - record(stage: VmTimingStage, durationMs: number): void { - const duration = roundedMs(durationMs); - const total = roundedMs((this.durations.get(stage) ?? 0) + duration); - const count = (this.counts.get(stage) ?? 0) + 1; - this.durations.set(stage, total); - this.counts.set(stage, count); - this.span.setAttribute(`cmux.vm.timing.${stage}_ms`, total); - this.span.setAttribute(`cmux.vm.timing.${stage}_count`, count); - } - - finish(context: Record = {}): void { - if (this.finished) return; - this.finished = true; - this.record("total", performance.now() - this.startedAt); - if (!this.debugTimings) return; - console.info("cmux vm timings", JSON.stringify({ - operation: this.operation, - ...context, - timings: this.snapshot(), - })); - } - - snapshot(): Record { - return Object.fromEntries( - [...this.durations.entries()].map(([stage, duration]) => [stage, duration]), - ); - } -} - -export function recordSpanTiming(span: Span, stage: VmTimingStage, durationMs: number): void { - span.setAttribute(`cmux.vm.timing.${stage}_ms`, roundedMs(durationMs)); -} - -export function measureVmEffect( - timing: VmTimingSink | undefined, - stage: VmTimingStage, - effect: Effect.Effect, -): Effect.Effect { - if (!timing) return effect; - return Effect.suspend(() => { - const start = performance.now(); - return effect.pipe( - Effect.ensuring( - Effect.sync(() => { - timing.record(stage, performance.now() - start); - }), - ), - ); - }); -} - -export async function measureVmAsync( - timing: VmTimingSink | undefined, - stage: VmTimingStage, - fn: () => Promise, -): Promise { - if (!timing) return await fn(); - const start = performance.now(); - try { - return await fn(); - } finally { - timing.record(stage, performance.now() - start); - } -} - -export function measureVmSync( - timing: VmTimingSink | undefined, - stage: VmTimingStage, - fn: () => T, -): T { - if (!timing) return fn(); - const start = performance.now(); - try { - return fn(); - } finally { - timing.record(stage, performance.now() - start); - } -} - -function roundedMs(durationMs: number): number { - return Math.round(durationMs * 100) / 100; -} diff --git a/web/services/vms/workflows.ts b/web/services/vms/workflows.ts index 19b7936203e7..1ce21115f57f 100644 --- a/web/services/vms/workflows.ts +++ b/web/services/vms/workflows.ts @@ -21,7 +21,6 @@ import { VmCreateFailedError, VmCreateInProgressError, VmNotFoundError, - isVmLimitExceededError, vmWorkflowErrorCause, type VmDatabaseError, type VmWorkflowError, @@ -31,13 +30,11 @@ import { VmProviderGateway, VmProviderGatewayLive, type VmProviderGatewayShape } import { VmRepository, VmRepositoryLive, - type BeginCreateResult, type CloudVmStatus, type CloudVmLeaseKind, type CloudVmRow, type VmRepositoryShape, } from "./repository"; -import { measureVmEffect, type VmTimingSink } from "./timings"; export type VmEntry = { readonly providerVmId: string; @@ -77,13 +74,13 @@ export function createVm(input: { readonly image: string; readonly imageVersion?: string | null; readonly idempotencyKey?: string; - readonly timing?: VmTimingSink; }): Effect.Effect { return Effect.gen(function* () { const repo = yield* VmRepository; const providers = yield* VmProviderGateway; const billing = yield* VmBillingGateway; - const create = yield* beginCreateWithLazyProviderRefresh(repo, providers, input); + yield* refreshActiveLimitProviderStatuses(repo, providers, input); + const create = yield* repo.beginCreate(input); if (!create.inserted) { const existing = create.vm; @@ -103,14 +100,81 @@ export function createVm(input: { return vmEntryFromRow(existing); } - const creditReservation = yield* reserveCreateCredit(billing, repo, input, create.vm); - yield* recordCreateRequestedEvents(repo, input, create.vm, creditReservation); + yield* seedInitialCreateCredits(billing, repo, input, create.vm).pipe( + Effect.catchAll((err) => + repo.recordUsageEvent({ + userId: input.userId, + billingTeamId: input.billingTeamId, + billingPlanId: input.billingPlanId, + vmId: create.vm.id, + eventType: "vm.create.credit.grant_failed", + provider: input.provider, + imageId: input.image, + metadata: { + idempotencyKeySet: !!input.idempotencyKey, + imageVersion: input.imageVersion ?? null, + message: errorMessage(err), + }, + }).pipe(Effect.catchAll(() => Effect.void)) + ), + ); - const handle = yield* measureVmEffect( - input.timing, - "provider_create", - providers.create(input.provider, { image: input.image }), - ).pipe( + const creditReservation = yield* billing.reserveCreate({ + userId: input.userId, + billingCustomerType: input.billingCustomerType, + billingTeamId: input.billingTeamId, + billingPlanId: input.billingPlanId, + provider: input.provider, + image: input.image, + imageVersion: input.imageVersion ?? null, + vmId: create.vm.id, + idempotencyKey: input.idempotencyKey, + }).pipe( + Effect.tapError((err) => + Effect.all([ + repo.markCreateFailed({ + id: create.vm.id, + code: "billing_reserve_failed", + message: errorMessage(err), + }), + repo.recordUsageEvent({ + userId: input.userId, + billingTeamId: input.billingTeamId, + billingPlanId: input.billingPlanId, + vmId: create.vm.id, + eventType: "vm.create.billing_failed", + provider: input.provider, + imageId: input.image, + metadata: { + idempotencyKeySet: !!input.idempotencyKey, + imageVersion: input.imageVersion ?? null, + errorTag: typeof err === "object" && err !== null && "_tag" in err + ? String((err as { _tag?: unknown })._tag) + : null, + }, + }), + ], { discard: true }).pipe(Effect.catchAll(() => Effect.void)) + ), + ); + + yield* recordCreditEvent(repo, create.vm, "vm.create.credit.reserved", creditReservation) + .pipe(Effect.catchAll(() => Effect.void)); + + yield* repo.recordUsageEvent({ + userId: input.userId, + billingTeamId: input.billingTeamId, + billingPlanId: input.billingPlanId, + vmId: create.vm.id, + eventType: "vm.create.requested", + provider: input.provider, + imageId: input.image, + metadata: { + idempotencyKeySet: !!input.idempotencyKey, + imageVersion: input.imageVersion ?? null, + }, + }).pipe(Effect.catchAll(() => Effect.void)); + + const handle = yield* providers.create(input.provider, { image: input.image }).pipe( Effect.tapError((err) => Effect.all([ refundCredit(billing, repo, create.vm, creditReservation), @@ -133,67 +197,46 @@ export function createVm(input: { ), ); - const running = yield* measureVmEffect( - input.timing, - "mark_running", - repo.markCreateRunning({ + const running = yield* repo + .markCreateRunning({ id: create.vm.id, providerVmId: handle.providerVmId, image: handle.image, imageVersion: input.imageVersion ?? null, - }), - ).pipe( - Effect.catchAll((err) => - Effect.gen(function* () { - yield* providers.destroy(input.provider, handle.providerVmId).pipe(Effect.catchAll(() => Effect.void)); - yield* refundCredit(billing, repo, create.vm, creditReservation); - yield* repo.markCreateFailed({ - id: create.vm.id, - code: "database_finalize_failed", - message: "Cloud VM state update failed.", - }).pipe(Effect.catchAll(() => Effect.void)); - yield* recordCreateFailureEvent( - repo, - input, - create.vm, - "database_finalize_failed", - errorMessage(err.cause), - ).pipe(Effect.catchAll(() => Effect.void)); - return yield* Effect.fail(err); - }), - ), - ); + }) + .pipe( + Effect.catchAll((err) => + Effect.gen(function* () { + yield* providers.destroy(input.provider, handle.providerVmId).pipe(Effect.catchAll(() => Effect.void)); + yield* refundCredit(billing, repo, create.vm, creditReservation); + yield* repo.markCreateFailed({ + id: create.vm.id, + code: "database_finalize_failed", + message: err.message, + }).pipe(Effect.catchAll(() => Effect.void)); + return yield* Effect.fail(err); + }), + ), + ); - yield* recordCreateSuccessEvents(repo, input, running); + yield* repo.recordUsageEvent({ + userId: input.userId, + billingTeamId: running.billingTeamId, + billingPlanId: running.billingPlanId, + vmId: running.id, + eventType: "vm.created", + provider: running.provider, + imageId: running.imageId, + metadata: { + idempotencyKeySet: !!input.idempotencyKey, + imageVersion: running.imageVersion, + }, + }).pipe(Effect.catchAll(() => Effect.void)); return vmEntryFromRow(running); }); } -function beginCreateWithLazyProviderRefresh( - repo: VmRepositoryShape, - providers: VmProviderGatewayShape, - input: { - readonly userId: string; - readonly billingTeamId: string; - readonly timing?: VmTimingSink; - } & Parameters[0], -): Effect.Effect { - return measureVmEffect(input.timing, "begin_create", repo.beginCreate(input)).pipe( - Effect.catchAll((err) => { - if (!isVmLimitExceededError(err)) return Effect.fail(err); - return Effect.gen(function* () { - yield* measureVmEffect( - input.timing, - "limit_reconcile", - refreshActiveLimitProviderStatuses(repo, providers, input), - ).pipe(Effect.catchAll(() => Effect.void)); - return yield* measureVmEffect(input.timing, "begin_create", repo.beginCreate(input)); - }); - }), - ); -} - function refreshActiveLimitProviderStatuses( repo: VmRepositoryShape, providers: VmProviderGatewayShape, @@ -215,32 +258,16 @@ function refreshActiveLimitProviderStatuses( if (vm.provider !== "freestyle" || !providerVmId) return Effect.void; return Effect.gen(function* () { const providerStatus = yield* getStatus(vm.provider, providerVmId).pipe( - Effect.catchAll((err) => - isProviderNotFoundError(err) - ? Effect.succeed("destroyed" as const) - : Effect.succeed(null), - ), + Effect.catchAll(() => Effect.succeed(null)), ); if (!providerStatus || providerStatus === "creating") return; const dbStatus = dbStatusFromProviderStatus(providerStatus); if (dbStatus === vm.status) return; - const didUpdate = yield* repo.markProviderObservedStatus({ + yield* repo.markProviderObservedStatus({ id: vm.id, providerVmId, status: dbStatus, - }).pipe(Effect.catchAll(() => Effect.succeed(false))); - if (didUpdate && dbStatus === "destroyed") { - yield* repo.recordUsageEvent({ - userId: vm.userId, - billingTeamId: vm.billingTeamId, - billingPlanId: vm.billingPlanId, - vmId: vm.id, - eventType: "vm.destroyed", - provider: vm.provider, - imageId: vm.imageId, - metadata: { source: "provider_status_refresh" }, - }).pipe(Effect.catchAll(() => Effect.void)); - } + }).pipe(Effect.catchAll(() => Effect.void)); }); }, { concurrency: "unbounded", discard: true }); }); @@ -452,202 +479,6 @@ function recordCreditEvent( }); } -function reserveCreateCredit( - billing: VmBillingGatewayShape, - repo: VmRepositoryShape, - input: { - readonly userId: string; - readonly billingCustomerType: BillingCustomerType; - readonly billingTeamId: string; - readonly billingPlanId: string; - readonly provider: ProviderId; - readonly image: string; - readonly imageVersion?: string | null; - readonly idempotencyKey?: string; - readonly timing?: VmTimingSink; - }, - vm: CloudVmRow, -) { - return measureVmEffect( - input.timing, - "billing", - Effect.gen(function* () { - yield* seedInitialCreateCredits(billing, repo, input, vm).pipe( - Effect.catchAll((err) => - repo.recordUsageEvent({ - userId: input.userId, - billingTeamId: input.billingTeamId, - billingPlanId: input.billingPlanId, - vmId: vm.id, - eventType: "vm.create.credit.grant_failed", - provider: input.provider, - imageId: input.image, - metadata: { - idempotencyKeySet: !!input.idempotencyKey, - imageVersion: input.imageVersion ?? null, - message: errorMessage(err), - }, - }).pipe(Effect.catchAll(() => Effect.void)) - ), - ); - - const creditReservation = yield* billing.reserveCreate({ - userId: input.userId, - billingCustomerType: input.billingCustomerType, - billingTeamId: input.billingTeamId, - billingPlanId: input.billingPlanId, - provider: input.provider, - image: input.image, - imageVersion: input.imageVersion ?? null, - vmId: vm.id, - idempotencyKey: input.idempotencyKey, - }).pipe( - Effect.tapError((err) => - Effect.all([ - repo.markCreateFailed({ - id: vm.id, - code: "billing_reserve_failed", - message: errorMessage(err), - }), - repo.recordUsageEvent({ - userId: input.userId, - billingTeamId: input.billingTeamId, - billingPlanId: input.billingPlanId, - vmId: vm.id, - eventType: "vm.create.billing_failed", - provider: input.provider, - imageId: input.image, - metadata: { - idempotencyKeySet: !!input.idempotencyKey, - imageVersion: input.imageVersion ?? null, - errorTag: typeof err === "object" && err !== null && "_tag" in err - ? String((err as { _tag?: unknown })._tag) - : null, - }, - }), - ], { discard: true }).pipe(Effect.catchAll(() => Effect.void)) - ), - ); - return creditReservation; - }), - ); -} - -function recordCreateRequestedEvents( - repo: VmRepositoryShape, - input: { - readonly userId: string; - readonly billingTeamId: string; - readonly billingPlanId: string; - readonly provider: ProviderId; - readonly image: string; - readonly imageVersion?: string | null; - readonly idempotencyKey?: string; - readonly timing?: VmTimingSink; - }, - requestedVm: CloudVmRow, - creditReservation: VmCreateCreditReservation, -) { - return measureVmEffect( - input.timing, - "usage_events", - repo.recordUsageEvents([ - ...(creditReservation.kind === "none" - ? [] - : [creditUsageEvent(requestedVm, "vm.create.credit.reserved", creditReservation)]), - { - userId: input.userId, - billingTeamId: input.billingTeamId, - billingPlanId: input.billingPlanId, - vmId: requestedVm.id, - eventType: "vm.create.requested", - provider: input.provider, - imageId: input.image, - metadata: { - idempotencyKeySet: !!input.idempotencyKey, - imageVersion: input.imageVersion ?? null, - }, - }, - ]).pipe(Effect.catchAll(() => Effect.void)), - ); -} - -function recordCreateSuccessEvents( - repo: VmRepositoryShape, - input: { - readonly idempotencyKey?: string; - readonly timing?: VmTimingSink; - }, - running: CloudVmRow, -) { - return measureVmEffect( - input.timing, - "usage_events", - repo.recordUsageEvents([ - { - userId: running.userId, - billingTeamId: running.billingTeamId, - billingPlanId: running.billingPlanId, - vmId: running.id, - eventType: "vm.created", - provider: running.provider, - imageId: running.imageId, - metadata: { - idempotencyKeySet: !!input.idempotencyKey, - imageVersion: running.imageVersion, - }, - }, - ]).pipe(Effect.catchAll(() => Effect.void)), - ); -} - -function recordCreateFailureEvent( - repo: VmRepositoryShape, - input: { - readonly userId: string; - readonly billingTeamId: string; - readonly billingPlanId: string; - readonly provider: ProviderId; - readonly image: string; - }, - requestedVm: CloudVmRow, - operation: string, - message: string, -) { - return repo.recordUsageEvent({ - userId: input.userId, - billingTeamId: input.billingTeamId, - billingPlanId: input.billingPlanId, - vmId: requestedVm.id, - eventType: "vm.create.failed", - provider: input.provider, - imageId: input.image, - metadata: { operation, message }, - }); -} - -function creditUsageEvent( - vm: CloudVmRow, - eventType: string, - reservation: Exclude, -) { - return { - userId: vm.userId, - billingTeamId: vm.billingTeamId, - billingPlanId: vm.billingPlanId, - vmId: vm.id, - eventType, - provider: vm.provider, - imageId: vm.imageId, - metadata: { - itemId: reservation.itemId, - amount: reservation.amount, - customerType: reservation.customerType, - customerIdSet: !!reservation.customerId, - }, - }; -} - function seedInitialCreateCredits( billing: VmBillingGatewayShape, repo: VmRepositoryShape, diff --git a/web/tests/vm-billing-gateway.test.ts b/web/tests/vm-billing-gateway.test.ts index 61b26275a54f..789fd3aea0ab 100644 --- a/web/tests/vm-billing-gateway.test.ts +++ b/web/tests/vm-billing-gateway.test.ts @@ -33,17 +33,9 @@ beforeEach(() => { }); describe("Stack VM billing gateway", () => { - test("does not resolve free-plan create credits by default", () => { + test("resolves the default free-plan initial create-credit grant", () => { const gateway = makeStackVmBillingGateway({}); - expect(gateway.resolveInitialCreateCreditGrant(createInput())).toEqual({ kind: "none" }); - }); - - test("resolves configured free-plan initial create-credit grants", () => { - const gateway = makeStackVmBillingGateway({ - CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID: DEFAULT_FREE_CREATE_CREDIT_ITEM_ID, - }); - expect(gateway.resolveInitialCreateCreditGrant(createInput())).toEqual({ kind: "stack_item", itemId: DEFAULT_FREE_CREATE_CREDIT_ITEM_ID, @@ -73,23 +65,11 @@ describe("Stack VM billing gateway", () => { expect(increaseQuantity).toHaveBeenCalledWith(20); }); - test("does not consume a free-plan Stack Auth create-credit item by default", async () => { - stackConfigured = false; + test("consumes the default free-plan Stack Auth create-credit item", async () => { const gateway = makeStackVmBillingGateway({}); const reservation = await Effect.runPromise(gateway.reserveCreate(createInput())); - expect(reservation).toEqual({ kind: "none" }); - expect(getItem).not.toHaveBeenCalled(); - }); - - test("consumes a configured free-plan Stack Auth create-credit item", async () => { - const gateway = makeStackVmBillingGateway({ - CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID: DEFAULT_FREE_CREATE_CREDIT_ITEM_ID, - }); - - const reservation = await Effect.runPromise(gateway.reserveCreate(createInput())); - expect(getItem).toHaveBeenCalledWith({ teamId: "team-billing", itemId: DEFAULT_FREE_CREATE_CREDIT_ITEM_ID, @@ -182,9 +162,7 @@ describe("Stack VM billing gateway", () => { test("fails before provider create when Stack Auth create credits are exhausted", async () => { tryDecreaseQuantity.mockResolvedValue(false); - const gateway = makeStackVmBillingGateway({ - CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID: DEFAULT_FREE_CREATE_CREDIT_ITEM_ID, - }); + const gateway = makeStackVmBillingGateway({}); const error = await Effect.runPromise( gateway.reserveCreate(createInput()).pipe(Effect.flip), diff --git a/web/tests/vm-route-auth.test.ts b/web/tests/vm-route-auth.test.ts index 4dc089045d69..ac7ce54c04a9 100644 --- a/web/tests/vm-route-auth.test.ts +++ b/web/tests/vm-route-auth.test.ts @@ -48,8 +48,6 @@ const { DELETE } = await import("../app/api/vm/[id]/route"); const attachRoute = await import("../app/api/vm/[id]/attach-endpoint/route"); const execRoute = await import("../app/api/vm/[id]/exec/route"); const sshRoute = await import("../app/api/vm/[id]/ssh-endpoint/route"); -const { VmProviderOperationError } = await import("../services/vms/errors"); -const { withAuthedVmApiRoute } = await import("../services/vms/routeHelpers"); beforeEach(() => { restoreVmEnv(); @@ -114,10 +112,6 @@ describe("VM REST auth", () => { }); test("authenticated provisioning runs the Effect VM workflow", async () => { - const listTeams = mock(async () => [{ - id: "team-1", - clientReadOnlyMetadata: { cmuxVmPlan: "pro" }, - }]); getUser.mockResolvedValue({ id: "user-1", displayName: null, @@ -126,7 +120,10 @@ describe("VM REST auth", () => { id: "team-1", clientReadOnlyMetadata: { cmuxVmPlan: "pro" }, }, - listTeams, + listTeams: async () => [{ + id: "team-1", + clientReadOnlyMetadata: { cmuxVmPlan: "pro" }, + }], }); runVmWorkflow.mockResolvedValue({ providerVmId: "provider-vm-1", @@ -150,7 +147,7 @@ describe("VM REST auth", () => { image: "snapshot-test", createdAt: 1_777_000_000_000, }); - expect(createVm).toHaveBeenCalledWith(expect.objectContaining({ + expect(createVm).toHaveBeenCalledWith({ userId: "user-1", billingCustomerType: "team", billingTeamId: "team-1", @@ -160,8 +157,7 @@ describe("VM REST auth", () => { image: "snapshot-test", imageVersion: null, idempotencyKey: "idem-1", - })); - expect(listTeams).not.toHaveBeenCalled(); + }); expect(runVmWorkflow).toHaveBeenCalled(); }); @@ -193,16 +189,6 @@ describe("VM REST auth", () => { }); test("uses the native client's requested Stack team for billing", async () => { - const listTeams = mock(async () => [ - { - id: "team-1", - clientReadOnlyMetadata: { cmuxVmPlan: "pro" }, - }, - { - id: "team-2", - clientReadOnlyMetadata: { cmuxVmPlan: "free" }, - }, - ]); getUser.mockResolvedValue({ id: "user-1", displayName: null, @@ -211,7 +197,16 @@ describe("VM REST auth", () => { id: "team-1", clientReadOnlyMetadata: { cmuxVmPlan: "pro" }, }, - listTeams, + listTeams: async () => [ + { + id: "team-1", + clientReadOnlyMetadata: { cmuxVmPlan: "pro" }, + }, + { + id: "team-2", + clientReadOnlyMetadata: { cmuxVmPlan: "free" }, + }, + ], }); runVmWorkflow.mockResolvedValue({ providerVmId: "provider-vm-team-2", @@ -238,98 +233,8 @@ describe("VM REST auth", () => { billingCustomerType: "team", billingTeamId: "team-2", billingPlanId: "free", - maxActiveVms: 5, + maxActiveVms: 1, })); - expect(listTeams).toHaveBeenCalledTimes(1); - }); - - test("validates a JSON body team id only when it differs from the selected team", async () => { - const listTeams = mock(async () => [ - { - id: "team-1", - clientReadOnlyMetadata: { cmuxVmPlan: "pro" }, - }, - { - id: "team-2", - clientReadOnlyMetadata: { cmuxVmPlan: "free" }, - }, - ]); - getUser.mockResolvedValue({ - id: "user-1", - displayName: null, - primaryEmail: "user@example.com", - selectedTeam: { - id: "team-1", - clientReadOnlyMetadata: { cmuxVmPlan: "pro" }, - }, - listTeams, - }); - runVmWorkflow.mockResolvedValue({ - providerVmId: "provider-vm-body-team", - provider: "freestyle", - image: "snapshot-test", - imageVersion: null, - createdAt: 1_777_000_000_000, - }); - - const response = await POST( - new Request("https://cmux.test/api/vm", { - method: "POST", - headers: { - authorization: "Bearer access-token", - "x-stack-refresh-token": "refresh-token", - }, - body: JSON.stringify({ - provider: "freestyle", - image: "snapshot-test", - teamId: "team-2", - }), - }), - ); - - expect(response.status).toBe(200); - expect(getUser).toHaveBeenCalledTimes(2); - expect(listTeams).toHaveBeenCalledTimes(1); - expect(createVm).toHaveBeenCalledWith(expect.objectContaining({ - billingCustomerType: "team", - billingTeamId: "team-2", - billingPlanId: "free", - maxActiveVms: 5, - })); - }); - - test("rejects blank team ids before reaching workflows", async () => { - getUser.mockResolvedValue(authedStackUser()); - const requests = [ - new Request("https://cmux.test/api/vm", { - method: "POST", - headers: { origin: "https://cmux.test" }, - body: JSON.stringify({ provider: "freestyle", image: "snapshot-test", teamId: " " }), - }), - new Request("https://cmux.test/api/vm?teamId=%20%20", { - method: "POST", - headers: { origin: "https://cmux.test" }, - body: JSON.stringify({ provider: "freestyle", image: "snapshot-test" }), - }), - new Request("https://cmux.test/api/vm", { - method: "POST", - headers: { origin: "https://cmux.test", "x-cmux-team-id": " " }, - body: JSON.stringify({ provider: "freestyle", image: "snapshot-test" }), - }), - ]; - - for (const request of requests) { - const response = await POST(request); - expect(response.status).toBe(400); - const payload = await response.json(); - expect(payload).toMatchObject({ - error: "vm_invalid_request", - details: { field: "teamId" }, - }); - expect(payload.message).toContain("non-empty"); - expectNoCloudVmImplementationLeaks(payload); - } - expect(runVmWorkflow).not.toHaveBeenCalled(); }); test("rejects a requested Stack team the caller does not belong to", async () => { @@ -359,16 +264,15 @@ describe("VM REST auth", () => { }); test("uses the single Stack team when personal team auto-create populated listTeams", async () => { - const listTeams = mock(async () => [{ - id: "team-personal", - clientReadOnlyMetadata: { cmuxVmPlan: "free" }, - }]); getUser.mockResolvedValue({ id: "user-1", displayName: null, primaryEmail: "user@example.com", selectedTeam: null, - listTeams, + listTeams: async () => [{ + id: "team-personal", + clientReadOnlyMetadata: { cmuxVmPlan: "free" }, + }], }); runVmWorkflow.mockResolvedValue({ providerVmId: "provider-vm-personal-team", @@ -392,7 +296,6 @@ describe("VM REST auth", () => { billingTeamId: "team-personal", billingPlanId: "free", })); - expect(listTeams).toHaveBeenCalledTimes(1); }); test("rejects VM create when Stack Auth returns no teams", async () => { @@ -455,10 +358,6 @@ describe("VM REST auth", () => { }); test("filters VM list to the requested Stack team", async () => { - const listTeams = mock(async () => [ - { id: "team-1", clientReadOnlyMetadata: { cmuxVmPlan: "free" } }, - { id: "team-2", clientReadOnlyMetadata: { cmuxVmPlan: "pro" } }, - ]); getUser.mockResolvedValue({ id: "user-1", displayName: null, @@ -467,7 +366,10 @@ describe("VM REST auth", () => { id: "team-1", clientReadOnlyMetadata: { cmuxVmPlan: "free" }, }, - listTeams, + listTeams: async () => [ + { id: "team-1", clientReadOnlyMetadata: { cmuxVmPlan: "free" } }, + { id: "team-2", clientReadOnlyMetadata: { cmuxVmPlan: "pro" } }, + ], }); runVmWorkflow.mockResolvedValue([{ providerVmId: "provider-vm-team-2", @@ -489,7 +391,6 @@ describe("VM REST auth", () => { expect(response.status).toBe(200); expect(listUserVms).toHaveBeenCalledWith("user-1", "team-2"); - expect(listTeams).toHaveBeenCalledTimes(1); expect(await response.json()).toMatchObject({ vms: [{ id: "provider-vm-team-2", provider: "e2b" }], }); @@ -514,40 +415,6 @@ describe("VM REST auth", () => { expect(runVmWorkflow).not.toHaveBeenCalled(); }); - test("finalizes route observers with wrapper-mapped workflow statuses", async () => { - getUser.mockResolvedValue(authedStackUser()); - let finalizedStatus: number | null = null; - const originalError = console.error; - console.error = mock(() => {}) as unknown as typeof console.error; - try { - const response = await withAuthedVmApiRoute( - new Request("https://cmux.test/api/vm", { - method: "POST", - headers: { origin: "https://cmux.test" }, - body: "{}", - }), - "/api/vm", - { "cmux.vm.operation": "create" }, - "/api/vm POST failed", - async ({ setResponseFinalizer }) => { - setResponseFinalizer((mappedResponse) => { - finalizedStatus = mappedResponse.status; - }); - throw new VmProviderOperationError({ - provider: "freestyle", - operation: "create", - cause: new Error("provider unavailable"), - }); - }, - ); - - expect(response.status).toBe(502); - expect(finalizedStatus).toBe(502); - } finally { - console.error = originalError; - } - }); - test("requires an Origin header for cookie-authenticated mutations", async () => { getUser.mockResolvedValue(authedStackUser()); diff --git a/web/tests/vm-timings.test.ts b/web/tests/vm-timings.test.ts deleted file mode 100644 index 78d13806a754..000000000000 --- a/web/tests/vm-timings.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Span } from "@opentelemetry/api"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; - -import { measureVmEffect, VmTimingRecorder, type VmTimingStage } from "../services/vms/timings"; - -describe("VM timing helpers", () => { - test("records Effect timings when the fiber is interrupted", async () => { - const recorded: Array<{ stage: VmTimingStage; durationMs: number }> = []; - const timing = { - record: (stage: VmTimingStage, durationMs: number) => { - recorded.push({ stage, durationMs }); - }, - }; - - const fiber = Effect.runFork( - measureVmEffect(timing, "provider_create", Effect.never), - ); - - await Effect.runPromise(Fiber.interrupt(fiber)); - - expect(recorded).toHaveLength(1); - expect(recorded[0]?.stage).toBe("provider_create"); - expect(recorded[0]?.durationMs).toBeGreaterThanOrEqual(0); - }); - - test("finish is idempotent", () => { - const attributes: Array<{ key: string; value: unknown }> = []; - const span = { - setAttribute: (key: string, value: unknown) => { - attributes.push({ key, value }); - }, - } as unknown as Span; - const recorder = new VmTimingRecorder(span, "create", { - startedAt: performance.now(), - }); - - recorder.finish({ status: 200 }); - recorder.finish({ status: 200 }); - - expect(attributes.filter((attribute) => attribute.key === "cmux.vm.timing.total_ms")).toHaveLength(1); - expect(attributes.filter((attribute) => attribute.key === "cmux.vm.timing.total_count")).toHaveLength(1); - }); -}); diff --git a/web/tests/vm-workflows.test.ts b/web/tests/vm-workflows.test.ts index 04bc54023606..30972947ddd3 100644 --- a/web/tests/vm-workflows.test.ts +++ b/web/tests/vm-workflows.test.ts @@ -10,19 +10,12 @@ import { type VmBillingGatewayShape, } from "../services/vms/billingGateway"; import { VmProviderGateway, type VmProviderGatewayShape } from "../services/vms/providerGateway"; -import { - VmRepository, - VmRepositoryLive, - type CloudVmRow, - type VmRepositoryShape, -} from "../services/vms/repository"; +import { VmRepositoryLive } from "../services/vms/repository"; import { VmCreateCreditsInsufficientError, VmCreateInProgressError, - VmDatabaseError, VmLimitExceededError, VmNotFoundError, - VmProviderOperationError, } from "../services/vms/errors"; import { createVm, @@ -67,85 +60,6 @@ afterAll(async () => { }); describe("VM Effect workflows", () => { - dbTest("does not block create when usage event recording fails", async () => { - const requested = testCloudVmRow({ - status: "provisioning", - providerVmId: null, - }); - const running = testCloudVmRow({ - status: "running", - providerVmId: "provider-vm-usage-events", - imageVersion: "test-version", - }); - let providerCreateCalls = 0; - let usageEventAttempts = 0; - const repo: VmRepositoryShape = { - listUserVms: () => Effect.succeed([]), - claimBillingGrant: () => Effect.succeed({ kind: "already_claimed" }), - markBillingGrantApplied: () => Effect.void, - deleteBillingGrant: () => Effect.void, - beginCreate: () => Effect.succeed({ inserted: true, vm: requested }), - activeLimitCandidates: () => Effect.succeed([]), - markProviderObservedStatus: () => Effect.succeed(false), - markCreateRunning: () => Effect.succeed(running), - markCreateFailed: () => Effect.void, - findUserVm: () => Effect.succeed(null), - markDestroyed: () => Effect.void, - recordLease: () => Effect.void, - activeIdentityLeases: () => Effect.succeed([]), - markLeasesRevoked: () => Effect.void, - recordUsageEvent: () => Effect.void, - recordUsageEvents: () => { - usageEventAttempts += 1; - return Effect.fail(new VmDatabaseError({ - operation: "recordUsageEvents", - cause: new Error("usage event table unavailable"), - })); - }, - }; - const provider: VmProviderGatewayShape = { - create: () => - Effect.sync(() => { - providerCreateCalls += 1; - return { - provider: "freestyle" as const, - providerVmId: "provider-vm-usage-events", - status: "running" as const, - image: "snapshot-test", - createdAt: Date.now(), - }; - }), - destroy: () => Effect.void, - exec: () => Effect.succeed({ exitCode: 0, stdout: "", stderr: "" }), - openAttach: () => Effect.fail(new Error("unused") as never), - openSSH: () => Effect.fail(new Error("unused") as never), - revokeSSHIdentity: () => Effect.void, - }; - const layer = Layer.mergeAll( - Layer.succeed(VmRepository, repo), - Layer.succeed(VmProviderGateway, provider), - Layer.succeed(VmBillingGateway, noOpVmBillingGateway()), - ); - - const created = await Effect.runPromise( - createVm({ - userId: "user-workflow-usage-events", - billingCustomerType: "team", - billingTeamId: "team-workflow-usage-events", - billingPlanId: "free", - maxActiveVms: 1, - provider: "freestyle", - image: "snapshot-test", - imageVersion: "test-version", - idempotencyKey: "usage-events", - }).pipe(Effect.provide(layer)), - ); - - expect(created.providerVmId).toBe("provider-vm-usage-events"); - expect(providerCreateCalls).toBe(1); - expect(usageEventAttempts).toBe(2); - }); - dbTest("creates one provider VM per user idempotency key and records usage", async () => { if (!sql) throw new Error("test database not initialized"); await sql`truncate cloud_vm_billing_grants, cloud_vm_usage_events, cloud_vm_leases, cloud_vms restart identity cascade`; @@ -366,58 +280,6 @@ describe("VM Effect workflows", () => { expect(statusCalls).toBe(0); }); - dbTest("skips Freestyle provider refresh when the billing team is below the active limit", async () => { - if (!sql) throw new Error("test database not initialized"); - await sql`truncate cloud_vm_billing_grants, cloud_vm_usage_events, cloud_vm_leases, cloud_vms restart identity cascade`; - await sql` - insert into cloud_vms (user_id, billing_team_id, billing_plan_id, provider, provider_vm_id, image_id, status) - values ('user-workflow-under-limit-old', 'team-workflow-under-limit', 'free', 'freestyle', 'provider-vm-under-limit-old', 'snapshot-test', 'running') - `; - - let createCalls = 0; - let statusCalls = 0; - const provider: VmProviderGatewayShape = { - create: () => - Effect.sync(() => { - createCalls += 1; - return { - provider: "freestyle" as const, - providerVmId: "provider-vm-under-limit-new", - status: "running" as const, - image: "snapshot-test", - createdAt: Date.now(), - }; - }), - destroy: () => Effect.void, - exec: () => Effect.succeed({ exitCode: 0, stdout: "", stderr: "" }), - openAttach: () => Effect.fail(new Error("unused") as never), - openSSH: () => Effect.fail(new Error("unused") as never), - revokeSSHIdentity: () => Effect.void, - getStatus: () => - Effect.sync(() => { - statusCalls += 1; - return "running" as const; - }), - }; - - const created = await Effect.runPromise( - createVm({ - userId: "user-workflow-under-limit-new", - billingCustomerType: "team", - billingTeamId: "team-workflow-under-limit", - billingPlanId: "free", - maxActiveVms: 2, - provider: "freestyle", - image: "snapshot-test", - idempotencyKey: "under-limit-new", - }).pipe(Effect.provide(providerLayer(provider))), - ); - - expect(created.providerVmId).toBe("provider-vm-under-limit-new"); - expect(statusCalls).toBe(0); - expect(createCalls).toBe(1); - }); - dbTest("refreshes Freestyle running rows before active limit enforcement", async () => { if (!sql) throw new Error("test database not initialized"); await sql`truncate cloud_vm_billing_grants, cloud_vm_usage_events, cloud_vm_leases, cloud_vms restart identity cascade`; @@ -476,85 +338,6 @@ describe("VM Effect workflows", () => { expect(oldVm?.status).toBe("paused"); }); - dbTest("marks provider-deleted Freestyle rows destroyed before active limit enforcement", async () => { - if (!sql) throw new Error("test database not initialized"); - await sql`truncate cloud_vm_billing_grants, cloud_vm_usage_events, cloud_vm_leases, cloud_vms restart identity cascade`; - await sql` - insert into cloud_vms (user_id, billing_team_id, billing_plan_id, provider, provider_vm_id, image_id, status) - values ('user-workflow-provider-deleted-old', 'team-workflow-provider-deleted', 'free', 'freestyle', 'provider-vm-provider-deleted-old', 'snapshot-test', 'running') - `; - - let createCalls = 0; - let statusCalls = 0; - const provider: VmProviderGatewayShape = { - create: () => - Effect.sync(() => { - createCalls += 1; - return { - provider: "freestyle" as const, - providerVmId: "provider-vm-provider-deleted-new", - status: "running" as const, - image: "snapshot-test", - createdAt: Date.now(), - }; - }), - destroy: () => Effect.void, - exec: () => Effect.succeed({ exitCode: 0, stdout: "", stderr: "" }), - openAttach: () => Effect.fail(new Error("unused") as never), - openSSH: () => Effect.fail(new Error("unused") as never), - revokeSSHIdentity: () => Effect.void, - getStatus: () => - Effect.suspend(() => { - statusCalls += 1; - const deleted = new Error( - "VM_DELETED: Vm provider-vm-provider-deleted-old is marked as deleted but still exists in the database", - ); - deleted.name = "VmDeletedError"; - return Effect.fail(new VmProviderOperationError({ - provider: "freestyle", - operation: "getStatus", - cause: deleted, - })); - }), - }; - - const created = await Effect.runPromise( - createVm({ - userId: "user-workflow-provider-deleted-new", - billingCustomerType: "team", - billingTeamId: "team-workflow-provider-deleted", - billingPlanId: "free", - maxActiveVms: 1, - provider: "freestyle", - image: "snapshot-test", - idempotencyKey: "provider-deleted-new", - }).pipe(Effect.provide(providerLayer(provider))), - ); - - expect(created.providerVmId).toBe("provider-vm-provider-deleted-new"); - expect(statusCalls).toBe(1); - expect(createCalls).toBe(1); - - const [oldVm] = await sql<{ status: string; destroyedAt: Date | null }[]>` - select status, destroyed_at as "destroyedAt" from cloud_vms - where provider_vm_id = 'provider-vm-provider-deleted-old' - `; - expect(oldVm?.status).toBe("destroyed"); - expect(oldVm?.destroyedAt).toBeInstanceOf(Date); - - const [{ destroyedUsageCount }] = await sql<{ destroyedUsageCount: string }[]>` - select count(*)::text as "destroyedUsageCount" - from cloud_vm_usage_events - where provider = 'freestyle' - and event_type = 'vm.destroyed' - and vm_id in ( - select id from cloud_vms - where provider_vm_id = 'provider-vm-provider-deleted-old' - ) - `; - expect(destroyedUsageCount).toBe("1"); - }); - dbTest("refreshes Freestyle running rows concurrently before active limit enforcement", async () => { if (!sql) throw new Error("test database not initialized"); await sql`truncate cloud_vm_billing_grants, cloud_vm_usage_events, cloud_vm_leases, cloud_vms restart identity cascade`; @@ -679,55 +462,6 @@ describe("VM Effect workflows", () => { expect(oldVm?.status).toBe("running"); }); - dbTest("keeps active limit enforcement when every Freestyle row is still running", async () => { - if (!sql) throw new Error("test database not initialized"); - await sql`truncate cloud_vm_billing_grants, cloud_vm_usage_events, cloud_vm_leases, cloud_vms restart identity cascade`; - await sql` - insert into cloud_vms (user_id, billing_team_id, billing_plan_id, provider, provider_vm_id, image_id, status) - values ('user-workflow-provider-running-old', 'team-workflow-provider-running', 'free', 'freestyle', 'provider-vm-provider-running-old', 'snapshot-test', 'running') - `; - - let createCalls = 0; - let statusCalls = 0; - const provider: VmProviderGatewayShape = { - create: () => - Effect.sync(() => { - createCalls += 1; - throw new Error("provider create should not be called"); - }), - destroy: () => Effect.void, - exec: () => Effect.succeed({ exitCode: 0, stdout: "", stderr: "" }), - openAttach: () => Effect.fail(new Error("unused") as never), - openSSH: () => Effect.fail(new Error("unused") as never), - revokeSSHIdentity: () => Effect.void, - getStatus: () => - Effect.sync(() => { - statusCalls += 1; - return "running" as const; - }), - }; - - const error = await Effect.runPromise( - createVm({ - userId: "user-workflow-provider-running-new", - billingCustomerType: "team", - billingTeamId: "team-workflow-provider-running", - billingPlanId: "free", - maxActiveVms: 1, - provider: "freestyle", - image: "snapshot-test", - idempotencyKey: "provider-running-new", - }).pipe( - Effect.flip, - Effect.provide(providerLayer(provider)), - ), - ); - - expect(error).toBeInstanceOf(VmLimitExceededError); - expect(statusCalls).toBe(1); - expect(createCalls).toBe(0); - }); - dbTest("does not overwrite a VM destroyed during provider status refresh", async () => { if (!sql) throw new Error("test database not initialized"); const testSql = sql; @@ -1207,12 +941,7 @@ describe("VM Effect workflows", () => { await sql`truncate cloud_vm_billing_grants, cloud_vm_usage_events, cloud_vm_leases, cloud_vms restart identity cascade`; const provider: VmProviderGatewayShape = { - create: () => - Effect.fail(new VmProviderOperationError({ - provider: "freestyle", - operation: "create", - cause: new Error("provider unavailable"), - })), + create: () => Effect.fail(new Error("provider unavailable") as never), destroy: () => Effect.void, exec: () => Effect.succeed({ exitCode: 0, stdout: "", stderr: "" }), openAttach: () => Effect.fail(new Error("unused") as never), @@ -1251,17 +980,6 @@ describe("VM Effect workflows", () => { ).rejects.toThrow(); expect(refundCalls).toBe(1); - const usageEvents = await sql<{ eventType: string }[]>` - select event_type as "eventType" from cloud_vm_usage_events - where user_id = 'user-workflow-credit-refund' - order by created_at, event_type - `; - expect(usageEvents.map((event) => event.eventType).sort()).toEqual([ - "vm.create.credit.refunded", - "vm.create.credit.reserved", - "vm.create.failed", - "vm.create.requested", - ]); }); dbTest("does not attach another user's VM", async () => { @@ -1435,28 +1153,6 @@ describe("VM Effect workflows", () => { }); }); -function testCloudVmRow(overrides: Partial = {}): CloudVmRow { - const now = new Date(); - return { - id: "00000000-0000-4000-8000-000000000001", - userId: "user-workflow-usage-events", - billingTeamId: "team-workflow-usage-events", - billingPlanId: "free", - provider: "freestyle", - providerVmId: null, - imageId: "snapshot-test", - imageVersion: null, - status: "provisioning", - idempotencyKey: "usage-events", - createdAt: now, - updatedAt: now, - destroyedAt: null, - failureCode: null, - failureMessage: null, - ...overrides, - }; -} - async function waitForBlockedAdvisoryLock(sql: Sql, billingTeamId: string): Promise { for (let attempt = 0; attempt < 100; attempt += 1) { const [{ blocked }] = await sql<{ blocked: string }[]>`