diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b233d0d..282b59c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,8 +62,15 @@ jobs: - uses: Swatinem/rust-cache@v2 + # `--locked` on the build so a Cargo.lock that disagrees with Cargo.toml + # fails here instead of being silently rewritten. Without it the drift is + # invisible to CI and lands on contributors instead: every local `cargo` + # run rewrites the lock, leaving a permanently dirty working tree that has + # to be re-discarded before every commit. The release workflow locks its + # build too; only nightly stays unlocked, because it stamps Cargo.toml's + # version and relies on cargo refreshing the lock's own root entry. - name: Build - run: cargo build --target ${{ matrix.target }} + run: cargo build --locked --target ${{ matrix.target }} - name: Test - run: cargo test --target ${{ matrix.target }} + run: cargo test --locked --target ${{ matrix.target }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 475df898..4631de9e 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -150,7 +150,7 @@ jobs: # Same glob list as release.yml's Release step: the bundle scripts leave # intermediates in dist/ (tty7.app, entitlements.plist, the Windows # staging dir) that must not reach the release assets. - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: nightly-${{ matrix.os }}-${{ matrix.arch }} path: | @@ -173,7 +173,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: path: dist merge-multiple: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eaf1657e..82c1a8bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,9 +62,13 @@ jobs: with: workspaces: tty7 + # `--locked` because a release must ship the dependency set the tag + # recorded, not whatever cargo would re-resolve at build time. Safe here + # (unlike nightly) precisely because nothing rewrites Cargo.toml: this is + # a plain checkout of the tagged commit. - name: Build working-directory: tty7 - run: cargo build --release --target ${{ matrix.target }} + run: cargo build --release --locked --target ${{ matrix.target }} # ---- Packaging: one step per OS ---------------------------------------- # macOS gets a signed + notarized drag-to-Applications DMG. Windows gets diff --git a/.gitignore b/.gitignore index 2a91e011..2e73c3e6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,9 @@ .brooks-lint-history.json openwiki/.last-update.json -# dev-build throwaway config dir (cargo dev) -.tty7-dev/ +# dev-build throwaway config dirs (`cargo dev`, plus per-branch ones passed as +# --config-dir so a test run can't disturb a real profile) +.tty7-dev*/ # Claude Code agent worktrees (transient) .claude/worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa6f405..3fd170ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,99 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [26.7.2] - 2026-07-21 + +### Added + +- **WSL shell integration** — prompt marks, cwd tracking, and agent + detection now work inside WSL distros. The integration tags the shell + it actually launches, never blocks the spawn path while probing the + distro's shell, and declines untranslatable cwds rather than + misreporting them. (#134) +- **Git Bash shell integration on Windows** — Git Bash panes get the + full integration, reporting proper Windows paths for cwd-dependent + features. (#130) +- **Smart double-click selection** — double-click selects the word under + the cursor with language-aware boundaries, including CJK segmentation + (the OS tokenizer on macOS, jieba elsewhere); angle-bracket pairs must + hug their contents and contraction apostrophes stay out of quote + pairing. (#128) +- **Configurable file-link command** — file links can open with a custom + command instead of the default editor. Contributed by @ayamir. (#143) +- **Agent resume keeps launch flags** — resuming an agent session + carries the flags the agent was originally launched with onto the + resume command. (#144) +- **Sidebar git line follows the agent's cwd** — the branch/status line + under an agent tab tracks the directory the agent is actually working + in, not just the pane's shell. (#127) + +### Changed + +- **Tab close button floats over the label** — the close affordance no + longer reserves a slot in every tab, so labels get the full width + until hovered. (#126) +- **Dependency updates** — resvg 0.47, sha2 0.11, a cargo minor-patch + group bump, and newer CI artifact actions. (#137, #138, #139, #140, + #141) + +### Fixed + +- **Tab completion falls through to the shell** — a Tab tty7 has no + candidates for now hands the line to the shell's own completion + instead of being swallowed (remote panes included); `cd`/`pushd` stop + offering files; and the whole feature can be turned off with + `tab_completion` in config or Settings → Terminal → Keyboard. (#146) +- **Remote panes never leak local filesystem operations** — a remote + pane's cwd is no longer used for local path lookups, and the agent-cwd + bypass validates inherited directories. (#133) +- **macOS text input goes through the IME** — synthesized keystrokes + keep their text (remote/automated input no longer degrades), and Kitty + full mode stays off the IME path. (#132) +- **No more console flashes on Windows git probes** — git status probes + and ssh ProxyCommand children no longer flash console windows. (#129) +- **Windows titlebar menu spacing** — the titlebar "..." aligns with the + window-control rhythm. (#131) +- **Codex avatar and macOS tray sizing** — the Codex tab avatar uses its + black brand field, and the tray icon renders at 22pt instead of the + hardcoded 18pt. (#142) +- **Agents settings layout** — rows stay aligned and notes terse. (#125) + +## [26.7.1] - 2026-07-17 + +### Added + +- **Follow the OS appearance** — a "Sync with system" mode with separate + light and dark theme slots: the theme flips live when the OS switches + appearance, native chrome follows along, and picking a theme while + syncing writes the slot matching the current mode. Old configs are + unchanged (sync defaults off). (#121) +- **Mark as Unread on tabs** — the tab context menu can re-flag a finished + agent result you've already looked at, re-arming the unread badge on the + Done dot. Agent tabs only; disabled while the agent is still working. + (#120) + +### Changed + +- **"Duo" logo refresh** — a new brand mark (two offset session panes, + mint behind ink, with a prompt chevron) replaces the orange window + identity across every icon asset: app icon, logo, tray glyph, favicon, + and social preview. Bare macOS binaries (`cargo run`) now show the icon + in the Dock too. (#124) + +### Fixed + +- **Linked git worktrees group under their main repository** — the sidebar + keys groups on the repository home instead of each worktree's own root, + so a repo and its worktrees share one header while branch status stays + per-worktree. (#118) +- **macOS tray icon stays a template image in every state** — the + attention state no longer swaps in a grey non-template glyph that was + illegible on many menu-bar appearances; agent status lives in the + tooltip and tray menu. (#122) +- **No more console-window flashes from Windows agent hooks** — each hook + emitter frees its throwaway console before it can paint (debug builds + only; release builds were unaffected). (#119) + ## [26.7.0] - 2026-07-16 First CalVer release: versions are now `YY.M.PATCH`, so the number says when diff --git a/Cargo.lock b/Cargo.lock index da8718df..8143651e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "aead" version = "0.6.1" @@ -246,9 +252,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "apple-native-keyring-store" @@ -1007,6 +1013,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -1152,6 +1164,15 @@ dependencies = [ "shlex 2.0.1", ] +[[package]] +name = "cedarwood" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0524a528a6a0288df1863c3c20fe92c301875b4941e7b6c4b394ab08c5a4c55" +dependencies = [ + "smallvec", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -1814,6 +1835,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" + [[package]] name = "dashmap" version = "6.2.1" @@ -4065,6 +4092,39 @@ version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" +[[package]] +name = "include-flate" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f173716febb1ad596c16ea5637b5f1790ea32de8e627493ff82bc73b0876ce" +dependencies = [ + "include-flate-codegen", + "include-flate-compress", +] + +[[package]] +name = "include-flate-codegen" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a7875b62a72ad3f3203cdd8950d4cf9947db036030b974b8b37ceae90c8d8c0" +dependencies = [ + "include-flate-compress", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "include-flate-compress" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fbb9c5ccb9a5b67b4afa2974c27e5507ea1bf6d22828cef418e4dfaeca51dd" +dependencies = [ + "libflate", + "zstd", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -4251,6 +4311,30 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jieba-macros" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38fc0f3831de71556de69643b80a08a5c8cd260a23c6b8dbeb7cd923c779cac5" +dependencies = [ + "phf_codegen 0.13.1", +] + +[[package]] +name = "jieba-rs" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5bdea4dc241d589e179f39d2a778f31490f3370aa2f626223dbd930ebc5c9d" +dependencies = [ + "bytecount", + "cedarwood", + "include-flate", + "jieba-macros", + "phf 0.13.1", + "regex", + "rustc-hash 2.1.2", +] + [[package]] name = "jni" version = "0.21.1" @@ -4349,9 +4433,9 @@ dependencies = [ [[package]] name = "keyring" -version = "4.1.4" +version = "4.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ee8d4dae108d4177d0a0ce241f98acc1ef28e20837bdef43cff4d160cf70fe" +checksum = "0298a59b384c540e408a600c8b375a09b49c3f97debc080e2c30675d79a6368a" dependencies = [ "apple-native-keyring-store", "keyring-core", @@ -4506,6 +4590,30 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libflate" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd96e993e5f3368b0cb8497dae6c860c22af8ff18388c61c6c0b86c58d86b5df" +dependencies = [ + "adler32", + "crc32fast", + "dary_heap", + "libflate_lz77", + "no_std_io2", +] + +[[package]] +name = "libflate_lz77" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" +dependencies = [ + "hashbrown 0.16.1", + "no_std_io2", + "rle-decode-fast", +] + [[package]] name = "libfuzzer-sys" version = "0.4.13" @@ -4788,7 +4896,7 @@ checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" dependencies = [ "log", "phf 0.11.3", - "phf_codegen", + "phf_codegen 0.11.3", "string_cache", "string_cache_codegen", "tendril", @@ -5980,6 +6088,16 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.11.3" @@ -6400,6 +6518,16 @@ dependencies = [ "quote", ] +[[package]] +name = "proc-macro-error-attr3" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e4dd828515431dd6c4a030d26f7eaed7dd4778226e9d2bb968d65ca4ec3d4d" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "proc-macro-error2" version = "2.0.1" @@ -6412,6 +6540,18 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "proc-macro-error3" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee475e440453418ff1335189eddf7101ba502cd818ab7ae04209bc83aa925aa" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -6885,9 +7025,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -6897,9 +7037,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -6986,6 +7126,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + [[package]] name = "ropey" version = "2.0.0-beta.1" @@ -7605,9 +7751,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -7625,22 +7771,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.2", ] [[package]] @@ -7665,9 +7811,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "indexmap", "itoa", @@ -8382,6 +8528,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -8713,9 +8870,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" dependencies = [ "bytes", "libc", @@ -9382,10 +9539,11 @@ dependencies = [ [[package]] name = "tty7" -version = "26.7.0" +version = "26.7.2" dependencies = [ "alacritty_terminal", "anyhow", + "core-foundation 0.10.0", "getrandom 0.3.4", "gpui", "gpui-component", @@ -9393,6 +9551,7 @@ dependencies = [ "gpui_platform", "ignore", "image", + "jieba-rs", "keyring", "ksni", "libc", @@ -9404,8 +9563,10 @@ dependencies = [ "notify-rust", "objc2 0.6.4", "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "plist", "portable-pty", + "regex", "reqwest_client", "resvg", "ropey", @@ -9662,9 +9823,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.23.4" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -11473,6 +11634,34 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "ztracing" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 929936bb..65198a07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tty7" -version = "26.7.0" +version = "26.7.2" edition = "2024" description = "A terminal workbench: shells, persistent sessions, SSH, coding agents — GPU-rendered on Zed's gpui, pure Rust" repository = "https://github.com/l0ng-ai/tty7" @@ -21,6 +21,9 @@ gpui-component-assets = { workspace = true } anyhow.workspace = true log.workspace = true +# Smart double-click selection patterns (URL/email/path). Already in the tree +# transitively, so pinning it here adds no new native code. +regex = "1" smol.workspace = true smallvec.workspace = true serde = { workspace = true } @@ -183,8 +186,25 @@ winresource = "0.1" # app appearance to match the active theme (see `ui::theme::sync_native_appearance`) # so the native traffic-light buttons render in the right light/dark style. [target.'cfg(target_os = "macos")'.dependencies] +# CFStringTokenizer FFI for dictionary-based CJK word segmentation on +# double-click (`terminal::smart_select`). Already in the tree transitively, +# and it makes jieba unnecessary here — see the non-macos section below. +core-foundation = "0.10" objc2 = "0.6" -objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSResponder", "NSAppearance", "NSGraphics"] } +objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSResponder", "NSAppearance", "NSGraphics", "NSImage"] } +# NSData feeds the runtime Dock icon for bare (non-bundled) binaries — see +# `set_dock_icon_for_bare_binary` in main.rs. +objc2-foundation = { version = "0.3", features = ["NSData"] } + +# Dictionary-based Chinese word segmentation for double-click selection +# (`terminal::smart_select`), as a *fallback* where the OS has no tokenizer of +# its own. macOS is excluded on purpose: CFStringTokenizer segments Chinese +# about as well (and Japanese/Korean far better) at zero cost, while jieba's +# table costs ~55 MB resident once built and ~2 MB of binary for the embedded +# dictionary. Keeping the dep off macOS means that dictionary isn't even +# linked into the build that can't use it. +[target.'cfg(not(target_os = "macos"))'.dependencies] +jieba-rs = "0.10" # x11/wayland are the Linux windowing backends; only pull them on Linux. The # Windows backend (`gpui_windows`) and macOS backend are selected by gpui_platform diff --git a/README.md b/README.md index 7352b5cd..15278946 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@
[![CI](https://github.com/l0ng-ai/tty7/actions/workflows/ci.yml/badge.svg)](https://github.com/l0ng-ai/tty7/actions/workflows/ci.yml) -[![Version](https://img.shields.io/github/v/tag/l0ng-ai/tty7?label=version&color=ff8a5c)](https://github.com/l0ng-ai/tty7/releases) +[![Version](https://img.shields.io/github/v/tag/l0ng-ai/tty7?label=version&color=3FDD8C)](https://github.com/l0ng-ai/tty7/releases) [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE) [![Discord](https://img.shields.io/badge/Discord-join%20chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/s3dethqz2V) diff --git a/README.zh-CN.md b/README.zh-CN.md index 4ef795ea..91be1423 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -11,7 +11,7 @@
[![CI](https://github.com/l0ng-ai/tty7/actions/workflows/ci.yml/badge.svg)](https://github.com/l0ng-ai/tty7/actions/workflows/ci.yml) -[![Version](https://img.shields.io/github/v/tag/l0ng-ai/tty7?label=version&color=ff8a5c)](https://github.com/l0ng-ai/tty7/releases) +[![Version](https://img.shields.io/github/v/tag/l0ng-ai/tty7?label=version&color=3FDD8C)](https://github.com/l0ng-ai/tty7/releases) [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE) [![Discord](https://img.shields.io/badge/Discord-%E5%8A%A0%E5%85%A5%E7%BE%A4%E7%BB%84-5865F2?logo=discord&logoColor=white)](https://discord.gg/s3dethqz2V) diff --git a/assets/app-icon.png b/assets/app-icon.png index 322eb4c7..1dfc4225 100644 Binary files a/assets/app-icon.png and b/assets/app-icon.png differ diff --git a/assets/app-icon.svg b/assets/app-icon.svg index 4bccf439..2b53f763 100644 --- a/assets/app-icon.svg +++ b/assets/app-icon.svg @@ -1,6 +1,11 @@ - - - - - + + + + + + + + diff --git a/assets/favicon.ico b/assets/favicon.ico index 0e500f7a..f4cbfc6d 100644 Binary files a/assets/favicon.ico and b/assets/favicon.ico differ diff --git a/assets/logo.png b/assets/logo.png index 29ee58aa..3c68a106 100644 Binary files a/assets/logo.png and b/assets/logo.png differ diff --git a/assets/logo.svg b/assets/logo.svg index a3f1d077..0ec488f2 100644 --- a/assets/logo.svg +++ b/assets/logo.svg @@ -1,5 +1,6 @@ - - - - + + + + + diff --git a/assets/logo@256.png b/assets/logo@256.png index eb844b0d..9f262685 100644 Binary files a/assets/logo@256.png and b/assets/logo@256.png differ diff --git a/assets/social-preview.png b/assets/social-preview.png index 68636648..8a05db4e 100644 Binary files a/assets/social-preview.png and b/assets/social-preview.png differ diff --git a/assets/tray.svg b/assets/tray.svg index 42e060bc..8abede4e 100644 --- a/assets/tray.svg +++ b/assets/tray.svg @@ -1,5 +1,16 @@ - - - - + + + + + + + + + + + + diff --git a/assets/tty7.icns b/assets/tty7.icns index 5ddf6e7e..75988eb0 100644 Binary files a/assets/tty7.icns and b/assets/tty7.icns differ diff --git a/docs/features.md b/docs/features.md index 05330455..f6656f43 100644 --- a/docs/features.md +++ b/docs/features.md @@ -5,7 +5,7 @@ ## Input - **Ghost suggestions** — your history completes the whole line as you type; to accept -- **Explained tab completion** — every flag and subcommand with its description, for ~100 common commands +- **Explained tab completion** — every flag and subcommand with its description, for ~100 common commands; when tty7 has nothing to offer the Tab falls through to your shell's own completion, and the whole feature can be turned off (Settings → Terminal → Keyboard, or `tab_completion` in `config.json`) - **Syntax highlighting** — as you type, nothing to install - **Fuzzy history search** — ⌃ R shows what you ran, where, and whether it failed - **History from day one** — your existing shell history works as-is and carries across sessions @@ -18,7 +18,9 @@ - **Repo-grouped sidebar** — the left tab sidebar groups rows under a header per git repository, non-repo tabs in a trailing *Scratch* section; branch switches and in-repo `cd`s never move a row (`sidebar_grouping` in `config.json`: `repo` default, `none` for a flat list) - **Command palette** ⌘ P · scrollback search ⌘ F - **⌘-click links** · desktop notifications · copy on select (opt-in, Settings → Terminal → Clipboard) +- **Smart double-click selection** — double-click grabs the whole URL, file path, bracket/quote pair, or dictionary-segmented CJK word under the cursor; Shift-click extends a selection (toggle in Settings → Terminal → Mouse; word separators via `word_separators` in `config.json`) - **Eight themes, plus your own** — YAML seed themes with solid, gradient, or image backgrounds; iTerm2 `.itermcolors` import; in-app color editor with a background-image picker +- **Sync with system** — Settings → Appearance; pick separate light and dark themes and tty7 follows the OS appearance live (`theme_follow_system`, `theme_preset_light` / `theme_preset_dark` in `config.json`) - **Window opacity & blur** — Settings → Appearance → Window; applies to every theme, *Follow theme* returns to the theme's own `opacity` / `blur` - **CJK / IME input** @@ -32,7 +34,7 @@ it never wraps or replaces the agent. - **Status dot** — working (blue) / needs your input (amber) / done (green), driven by agent-reported events over an OSC channel; run *Agent: Install Claude Code Hooks* from the palette to wire Claude Code up - **Notifications** — "needs your permission…" the moment an agent blocks on you, and "finished after Ns" per turn, honoring your notification policy - **Branch at a glance** — each sidebar row shows its pane's git branch and working-tree diff (`+N −M`), refreshed on `cd` and when a command finishes -- **Session resume** — panes lost to a reboot re-launch their agent conversation (`claude --resume …`) on restore (`restore_agent_sessions`, on by default) +- **Session resume** — panes lost to a reboot re-launch their agent conversation on restore, carrying the original launch flags (`claude --dangerously-skip-permissions --resume …`) (`restore_agent_sessions`, on by default) - **Context feed** — palette commands send the current selection or the repo's `git diff` to the running agent as a ready-made prompt - **Tray icon** — a system tray / menu bar item that flips to an attention state the moment any agent needs your input; its menu lists every agent pane (brand avatar + status dot, click to reveal), switches the notification policy, and offers *Quit and Stop Daemon* alongside the plain session-keeping quit (`show_tray_icon`, on by default) diff --git a/docs/features.zh-CN.md b/docs/features.zh-CN.md index bba83eae..b9cac2bf 100644 --- a/docs/features.zh-CN.md +++ b/docs/features.zh-CN.md @@ -5,7 +5,7 @@ ## 输入 - **影子建议** —— 边打字边用你的历史补全整条命令, 接受 -- **带说明的 Tab 补全** —— 每个 flag、每个子命令都带说明,覆盖约 100 个常用命令 +- **带说明的 Tab 补全** —— 每个 flag、每个子命令都带说明,覆盖约 100 个常用命令;tty7 没有候选时 Tab 自动交给 shell 自己的补全,整个功能也可关闭(设置 → 终端 → 键盘,或 `config.json` 里的 `tab_completion`) - **语法高亮** —— 边打边亮,什么都不用装 - **模糊历史搜索** —— ⌃ R 看到每条命令在哪跑的、什么时候、有没有失败 - **历史开箱即用** —— 你已有的 shell 历史直接生效,并跨会话延续 @@ -18,7 +18,9 @@ - **侧栏按仓库分组** —— 左侧标签栏按 git 仓库分组、每组一个标题行,不在仓库里的标签归入末尾的 *Scratch* 组;切分支、仓库内 `cd` 都不会挪动行(`config.json` 的 `sidebar_grouping`:默认 `repo`,`none` 恢复扁平列表) - **命令面板** ⌘ P · 回滚搜索 ⌘ F - **⌘ 点击打开链接** · 桌面通知 · 划选即复制(可选,设置 → 终端 → 剪贴板) +- **智能双击选中** —— 双击直接选中整条 URL、文件路径、括号/引号对,中文按词典分词出词;Shift 点击扩展选区(设置 → 终端 → 鼠标可开关;分隔符用 `config.json` 的 `word_separators` 配置) - **8 套主题,也能自定义** — YAML 种子主题,背景支持纯色、渐变或图片;可导入 iTerm2 `.itermcolors`;应用内颜色编辑器带背景图选择 +- **跟随系统外观** — 设置 → Appearance;分别选好浅色和深色主题,tty7 随系统深浅模式实时切换(`config.json` 中的 `theme_follow_system`、`theme_preset_light` / `theme_preset_dark`) - **窗口透明与模糊** — 设置 → Appearance → Window;对所有主题生效,*Follow theme* 恢复主题自带的 `opacity` / `blur` - **CJK / 输入法输入** @@ -31,7 +33,7 @@ Aider、Amp、OpenCode 等约 17 个)并在其外围加功能 —— 绝不包 - **状态点** —— 工作中(蓝)/ 等你输入(琥珀)/ 完成(绿),由 agent 自己上报的 OSC 事件驱动;在命令面板运行 *Agent: Install Claude Code Hooks* 一键接通 Claude Code - **通知** —— agent 卡在等你批准的那一刻弹 "needs your permission…",每轮结束弹 "finished after Ns",遵循你的通知策略 - **一眼看分支** —— 侧栏每行显示该 pane 的 git 分支和工作区改动(`+N −M`),`cd` 或命令跑完时自动刷新 -- **会话恢复** —— 重启后无法重连的 pane 会自动续上 agent 对话(`claude --resume …`;`restore_agent_sessions`,默认开启) +- **会话恢复** —— 重启后无法重连的 pane 会自动续上 agent 对话,并带上原始启动 flags(`claude --dangerously-skip-permissions --resume …`;`restore_agent_sessions`,默认开启) - **上下文回填** —— 面板命令把当前选区或仓库 `git diff` 打包成 prompt 直接喂给正在跑的 agent - **托盘图标** —— 系统托盘 / 菜单栏常驻图标,任何 agent 等你输入时立即切换为提醒态;菜单列出所有 agent pane(品牌头像 + 状态点,点击直达)、可切换通知策略,并在保留会话的普通退出之外提供 *Quit and Stop Daemon*(`show_tray_icon`,默认开启) diff --git a/src/core/agent_hooks.rs b/src/core/agent_hooks.rs index d85f55ca..ddce9d08 100644 --- a/src/core/agent_hooks.rs +++ b/src/core/agent_hooks.rs @@ -33,6 +33,14 @@ const MAX_STDIN: u64 = 64 * 1024; /// the controlling terminal. Always exits quietly — a hook that fails must /// never break the agent's own flow (agents surface nonzero exits). pub fn run_agent_hook(agent: &str, event: &str) { + // Shed our own console before doing anything else. Debug builds are + // console-subsystem (so `println!` logging works while developing the GUI), + // so every hook process Claude Code spawns gets its *own* console window — + // a rash of terminal windows that flash open and vanish as each end-of-turn + // hook fires. We never use this console for I/O (stdin is piped and we write + // to the *agent's* console via AttachConsole), so freeing it now tears the + // window down before it can paint. No-op in release (GUI subsystem) and Unix. + detach_console(); // Not inside tty7 (or a remote shell): stay silent, so globally-installed // hooks don't leak escape sequences into other terminals. if std::env::var_os(TTY7_ENV_MARKER).is_none() { @@ -54,6 +62,26 @@ pub fn run_agent_hook(agent: &str, event: &str) { write_to_controlling_tty(&build_hook_sequence(agent, event, &input)); } +/// Detach from — and, when we're the only process attached, destroy — the +/// calling process's console. On Windows debug builds each `tty7 agent-hook …` +/// process owns a throwaway console whose window would otherwise flash on +/// screen; freeing it before the window paints removes the flash. The emitter +/// re-attaches to the agent's console via `AttachConsole` when it writes, so +/// this doesn't cost us the output path. No-op where there's no console to free. +#[cfg(not(unix))] +fn detach_console() { + use windows_sys::Win32::System::Console::FreeConsole; + // SAFETY: FreeConsole takes no arguments; it simply returns 0 when the + // process has no attached console (release/GUI builds) and is otherwise a + // clean detach. + unsafe { + FreeConsole(); + } +} + +#[cfg(unix)] +fn detach_console() {} + /// The sentinel event one hook invocation maps onto, or `None` to stay silent. /// Most hooks pass their event through; the exception is Copilot's single /// `notification` hook, which fires for *every* notification type — only @@ -82,7 +110,7 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec { "agent": agent, "event": event, }); - for key in ["session_id", "message"] { + for key in ["session_id", "message", "cwd"] { if let Some(v) = payload .get(key) .and_then(|v| v.as_str()) @@ -404,7 +432,9 @@ pub fn hooks_state(agent: HookAgent) -> HooksState { /// Install (or rewrite in place) one agent's tty7 hooks. Idempotent: existing /// tty7 entries/files are replaced, never duplicated, and anything -/// user-authored is left untouched. Returns a short human-readable summary. +/// user-authored is left untouched. Returns a terse summary meant for the +/// settings row's note line — the row already shows the agent and target +/// path, so the summary never repeats them. pub fn install_hooks(agent: HookAgent) -> anyhow::Result { let path = agent .target_path() @@ -412,10 +442,7 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result { match agent { HookAgent::Claude => { hook_map_install(&path, agent, CLAUDE_HOOK_EVENTS)?; - Ok(format!( - "Claude Code hooks installed in {} — restart running claude sessions to pick them up", - path.display() - )) + Ok("Installed".to_string()) } HookAgent::Codex => { hook_map_install(&path, agent, CODEX_HOOK_EVENTS)?; @@ -423,11 +450,10 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result { // Best-effort: the file install above is complete and correct // either way, so a missing codex binary downgrades to advice // instead of failing the install. - let summary = format!("Codex hooks installed in {}", path.display()); Ok(match enable_codex_hooks_feature() { - Ok(()) => summary, + Ok(()) => "Installed".to_string(), Err(e) => format!( - "{summary} — couldn't run `codex features enable hooks` ({e}); run it once manually" + "Installed, but couldn't run `codex features enable hooks` ({e}) — run it once manually" ), }) } @@ -435,11 +461,7 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result { let content = owned_file_content(agent) .ok_or_else(|| anyhow::anyhow!("cannot resolve tty7's own executable path"))?; owned_file_install(&path, &content, &agent.marker())?; - Ok(format!( - "{} integration installed at {}", - agent.display_name(), - path.display() - )) + Ok("Installed".to_string()) } } } @@ -477,7 +499,11 @@ pub fn refresh_hooks_at_launch() -> usize { match install_hooks(agent) { Ok(summary) => { refreshed += 1; - log::info!("refreshed stale agent hooks: {summary}"); + log::info!( + "refreshed stale {} hooks at {}: {summary}", + agent.display_name(), + agent.target_display() + ); } Err(e) => log::warn!( "could not refresh stale {} hooks: {e}", @@ -705,11 +731,7 @@ fn hook_map_uninstall(path: &Path, agent: HookAgent) -> anyhow::Result { return Ok("No tty7 hooks found; nothing to remove".to_string()); } crate::core::config::write_atomic(path, serde_json::to_string_pretty(&root)?.as_bytes())?; - Ok(format!( - "{} hooks removed from {}", - agent.display_name(), - path.display() - )) + Ok("Removed".to_string()) } /// The tty7 hook command inside one matcher entry @@ -740,10 +762,9 @@ fn enable_codex_hooks_feature() -> Result<(), String> { .chain(home_dir().map(|h| h.join(".local/bin/codex"))) .find(|p| p.exists()); let program = candidates.unwrap_or_else(|| PathBuf::from("codex")); - match std::process::Command::new(&program) - .args(["features", "enable", "hooks"]) - .output() - { + let mut cmd = std::process::Command::new(&program); + cmd.args(["features", "enable", "hooks"]); + match crate::core::proc::hide_console(&mut cmd).output() { Ok(out) if out.status.success() => Ok(()), Ok(out) => Err(format!( "codex exited with {}: {}", @@ -828,7 +849,7 @@ fn owned_file_uninstall(path: &Path, marker: &str) -> anyhow::Result { { let _ = std::fs::remove_dir(parent); } - Ok(format!("Removed {}", path.display())) + Ok("Removed".to_string()) } /// Copilot hook file (`~/.copilot/hooks/tty7.json`): Copilot auto-loads every @@ -957,6 +978,7 @@ mod tests { assert_eq!(ev.kind, AgentEventKind::Notification); assert_eq!(ev.session_id.as_deref(), Some("abc-123")); assert!(ev.message.as_deref().unwrap().contains("permission")); + assert_eq!(ev.cwd.as_deref(), Some(std::path::Path::new("/w"))); // Garbage stdin still yields a well-formed bare event. let seq = build_hook_sequence("claude", "stop", "not json at all"); diff --git a/src/core/cli_agent.rs b/src/core/cli_agent.rs index d411fa12..f58fb9a5 100644 --- a/src/core/cli_agent.rs +++ b/src/core/cli_agent.rs @@ -161,7 +161,18 @@ impl CLIAgent { /// native session id, or `None` for agents without a known resume flag. /// The id is what the agent reported in its `session-start` event (see /// [`AgentEvent`]); commands mirror cmux's per-agent resume table. - pub fn resume_command(self, session_id: &str) -> Option { + /// + /// `launch_argv` is the argv the agent was originally launched with, when + /// the daemon observed one. Its flags (`--dangerously-skip-permissions`, + /// `--model …`) are carried onto the resume command so the restored + /// session runs in the same mode the user picked — verbatim only when the + /// whole tail passes the conservative shell-safety gate; otherwise the + /// bare table command still resumes, just without the flags. + pub fn resume_command( + self, + session_id: &str, + launch_argv: Option<&[String]>, + ) -> Option { // Ids come from the agent's own events, but they still land on a shell // command line — refuse anything that isn't a plain token so a // malicious/corrupt id can't smuggle shell syntax. @@ -172,25 +183,143 @@ impl CLIAgent { { return None; } + // The user's launch flags, pre-joined with a leading space so they + // splice into the format strings below; empty when none survive. + let flags = launch_argv + .and_then(|argv| self.replay_flags(argv)) + .map(|flags| { + flags.iter().fold(String::new(), |mut s, f| { + s.push(' '); + s.push_str(f); + s + }) + }) + .unwrap_or_default(); match self { - CLIAgent::Claude => Some(format!("claude --resume {session_id}")), - CLIAgent::Codex => Some(format!("codex resume {session_id}")), - CLIAgent::Gemini => Some(format!("gemini --resume {session_id}")), - CLIAgent::OpenCode => Some(format!("opencode --session {session_id}")), - CLIAgent::Amp => Some(format!("amp threads continue {session_id}")), - CLIAgent::Cursor => Some(format!("cursor-agent --resume {session_id}")), + CLIAgent::Claude => Some(format!("claude{flags} --resume {session_id}")), + // Codex resumes via a subcommand that accepts the interactive + // options after the positional id (`codex resume [OPTIONS] + // [SESSION_ID]`). + CLIAgent::Codex => Some(format!("codex resume {session_id}{flags}")), + CLIAgent::Gemini => Some(format!("gemini{flags} --resume {session_id}")), + CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id}")), + // Amp's global options (`--dangerously-allow-all`, …) are accepted + // by the `threads continue` subcommand (verified: unknown options + // are a parse error, globals pass). + CLIAgent::Amp => Some(format!("amp threads continue {session_id}{flags}")), + CLIAgent::Cursor => Some(format!("cursor-agent{flags} --resume {session_id}")), + // Copilot CLI: `copilot --resume ` (`-r` shorthand) — + // the one hooks-covered agent that was missing from this table. + CLIAgent::Copilot => Some(format!("copilot{flags} --resume {session_id}")), _ => None, } } + /// The launch-flag tail of `argv` worth replaying on a resume command, or + /// `None` to resume bare. Deliberately conservative: anything ambiguous + /// falls back to no flags rather than a corrupted command line. + /// + /// - The tail is everything after the token that names this agent (the + /// launcher itself, or the script path in an interpreter-wrapped argv); + /// leading `VAR=value` env assignments are skipped first so they can't + /// mis-anchor (`CLAUDE_CONFIG_DIR=/opt/claude claude …`). No naming + /// token at all (custom wrapper rules) → no flags. + /// - Stale session-targeting flags (`--resume old-id`, `--continue`, a + /// re-launched `codex resume `) are stripped — the new id must win. + /// - Every surviving token must be a plain shell-safe word, the first must + /// be a `-` flag, and no two bare words may run consecutively — a bare + /// word is only acceptable as the value directly behind a flag; anything + /// else is a positional prompt that must not re-submit itself into the + /// resumed session. Any violation drops the whole tail. + fn replay_flags(self, argv: &[String]) -> Option> { + let names_self = |token: &str| { + token.split(['/', '\\']).any(|seg| { + CLIAgent::match_token(&base_stem(seg).to_ascii_lowercase()) == Some(self) + }) + }; + let argv = &argv[argv.iter().take_while(|t| is_env_assignment(t)).count()..]; + let named = argv.iter().position(|t| names_self(t))?; + let mut tail: Vec<&str> = argv[named + 1..].iter().map(String::as_str).collect(); + + // A relaunched `codex resume `: drop the subcommand and its id + // so they don't replay as a positional prompt. + if self == CLIAgent::Codex && tail.first() == Some(&"resume") { + tail.remove(0); + if tail.first().is_some_and(|t| !t.starts_with('-')) { + tail.remove(0); + } + } + + // Session-targeting flags whose old value must not survive; each is + // stripped together with one following non-flag value token (harmless + // for the value-less ones — anything trailing them is positional). + let stale: &[&str] = match self { + CLIAgent::Claude => &[ + "--resume", + "-r", + "--continue", + "-c", + "--session-id", + "--from-pr", + ], + CLIAgent::Gemini | CLIAgent::Cursor => &["--resume", "-r"], + CLIAgent::Copilot => &["--resume", "-r", "--continue", "-c"], + CLIAgent::OpenCode => &["--session", "-s", "--continue", "-c"], + // `--last` targets "the most recent session" and would contradict + // the explicit id we inject. + CLIAgent::Codex => &["--last"], + _ => &[], + }; + let mut i = 0; + while i < tail.len() { + let t = tail[i]; + if stale.contains(&t) + || stale + .iter() + .any(|f| f.len() > 2 && t.starts_with(&format!("{f}="))) + { + tail.remove(i); + if i < tail.len() && !tail[i].starts_with('-') { + tail.remove(i); + } + } else { + i += 1; + } + } + + // The safety gate: plain tokens only, and a flag-shaped tail — every + // bare word must sit directly behind a `-` flag (its value slot); the + // first token being bare, or two bare words in a row, is a positional + // prompt and drops the whole tail. (A single bare word behind a + // boolean flag is indistinguishable from a flag value and slips + // through — the residual ambiguity of not knowing each flag's arity.) + let safe = |t: &str| { + !t.is_empty() + && t.bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_=./,:@+~".contains(&b)) + }; + if !tail.iter().all(|t| safe(t)) { + return None; + } + let mut prev_was_flag = false; + for t in &tail { + let is_flag = t.starts_with('-'); + if !is_flag && !prev_was_flag { + return None; + } + prev_was_flag = is_flag; + } + Some(tail.into_iter().map(String::from).collect()) + } + /// Brand accent (0xRRGGBB) for the tab chip's agent dot. Chosen for legibility - /// on both light and dark themes rather than exact brand black/white — a pure - /// black or white dot vanishes against one theme, so vendors whose mark is - /// monochrome (Codex/OpenAI, Cursor) get a recognizable mid-tone hue instead. + /// on both light and dark themes rather than exact brand black/white. A pure + /// black or white dot vanishes against one theme, so monochrome vendors get + /// a recognizable mid-tone hue instead; Codex keeps its black field. pub fn accent_rgb(self) -> u32 { match self { CLIAgent::Claude => 0xD97757, // Claude terracotta - CLIAgent::Codex => 0x10A37F, // OpenAI green (black mark reads as this) + CLIAgent::Codex => 0x000000, // Codex black field CLIAgent::Gemini => 0x4285F4, // Google blue CLIAgent::Aider => 0x14B8A6, // teal CLIAgent::Amp => 0xF34E3F, // Amp red @@ -479,12 +608,39 @@ pub struct AgentSessionState { /// key its own `--resume` flag takes — persisted for restore. #[serde(default)] pub session_id: Option, + /// The argv the agent was launched with, as the daemon observed it (the + /// foreground process-table poll on Unix, the shell integration's typed + /// `133;C` capture on Windows). Persisted alongside the session id so + /// restore can carry the user's launch flags + /// (`--dangerously-skip-permissions`, `--model …`) onto the resume + /// command — see [`CLIAgent::resume_command`]. Not touched by + /// [`apply_event`](Self::apply_event); the daemon stamps it from the + /// identity-detection side. + #[serde(default)] + pub launch_argv: Option>, /// Whether this state came from the rich sentinel channel (hooks /// installed) rather than the opaque OSC 9/777 fallback. Rich state drives /// turn-level notifications; fallback state only paints the dot (the /// agent's own notification text was already toasted by the client). #[serde(default)] pub rich: bool, + /// The agent's working directory as its hook payloads report it — the + /// agent's own claim, which tracks internal chdirs the PTY can't show + /// (Claude Code's EnterWorktree moves the session without any shell `cd`). + /// Cleared on `session-end` so a finished session can't pin consumers to + /// a stale path; while absent, consumers fall back to the pane's proc cwd. + #[serde(default)] + pub cwd: Option, + /// Tool completions seen in this session, counted only so consumers can + /// spot *that* the agent did something — a turn's edits land tool by tool, + /// and the status alone can't say so (`ToolComplete` is a no-op transition + /// during normal work, by design). The sidebar's git probe watches this to + /// refresh mid-turn instead of waiting for `stop`; see + /// [`TerminalView::refresh_git_status`](crate::terminal::view::TerminalView). + /// Monotonic within a session and never reset — consumers compare against + /// the value they last saw, so only the *change* means anything. + #[serde(default)] + pub activity: u64, } impl AgentStatus { @@ -512,6 +668,9 @@ impl AgentSessionState { if let Some(id) = &ev.session_id { self.session_id = Some(id.clone()); } + if let Some(cwd) = &ev.cwd { + self.cwd = Some(cwd.clone()); + } match ev.kind { AgentEventKind::SessionStart => { self.status = AgentStatus::Idle; @@ -549,6 +708,10 @@ impl AgentSessionState { // stream of completions during normal work is a no-op and can // never overwrite Done between turns. AgentEventKind::ToolComplete => { + // The count moves even when the status doesn't: a tool call is + // the one signal that the working tree may have just changed + // under a turn that won't end for minutes. + self.activity = self.activity.wrapping_add(1); if self.status == AgentStatus::Waiting { self.status = AgentStatus::Working; self.message = None; @@ -560,9 +723,12 @@ impl AgentSessionState { } // The agent session ended but its id stays: Claude & friends can // resume an *ended* session, which is exactly what restore does. + // Its cwd claim does NOT stay: with no agent running, the pane's + // real (proc-observed) directory is the truth again. AgentEventKind::SessionEnd => { self.status = AgentStatus::Idle; self.message = None; + self.cwd = None; } } } @@ -596,6 +762,9 @@ pub struct AgentEvent { pub kind: AgentEventKind, pub session_id: Option, pub message: Option, + /// The agent's working directory at the moment the hook fired, when the + /// payload carries one (Claude Code sends it on every hook event). + pub cwd: Option, } /// Parse a complete OSC payload (identifier included, e.g. @@ -621,6 +790,8 @@ pub fn parse_agent_event(payload: &[u8]) -> Option { session_id: Option, #[serde(default)] message: Option, + #[serde(default)] + cwd: Option, } let w: Wire = serde_json::from_slice(json).ok()?; @@ -631,6 +802,7 @@ pub fn parse_agent_event(payload: &[u8]) -> Option { kind, session_id: nonempty(w.session_id), message: nonempty(w.message), + cwd: nonempty(w.cwd).map(std::path::PathBuf::from), }) } @@ -744,6 +916,11 @@ mod tests { } } + #[test] + fn codex_avatar_uses_its_black_brand_field() { + assert_eq!(CLIAgent::Codex.accent_rgb(), 0x000000); + } + #[test] fn detects_newer_agents_by_command() { for (cmd, agent) in [ @@ -880,6 +1057,7 @@ mod tests { kind, session_id: id.map(String::from), message: msg.map(String::from), + cwd: None, }; s.apply_event(&ev(AgentEventKind::SessionStart, None, Some("sid-1"))); @@ -939,22 +1117,260 @@ mod tests { assert_eq!(s.session_id.as_deref(), Some("sid-1")); } + /// Tool completions are deliberately a *status* no-op during normal work + /// (the assertions above), which leaves consumers watching the status with + /// no way to tell that an agent mid-turn just wrote a file. `activity` is + /// what makes them observable: it moves on every completion, in every + /// status, and never rewinds — the sidebar's git probe compares it against + /// the value it last saw. + #[test] + fn tool_completions_count_even_when_the_status_holds_still() { + let ev = |kind| AgentEvent { + agent: Some(CLIAgent::Claude), + kind, + session_id: None, + message: None, + cwd: None, + }; + + let mut s = AgentSessionState::default(); + s.apply_event(&ev(AgentEventKind::PromptSubmit)); + assert_eq!(s.activity, 0, "a turn starting is not tool activity"); + + for n in 1..=3 { + s.apply_event(&ev(AgentEventKind::ToolComplete)); + assert_eq!(s.status, AgentStatus::Working, "the status holds still…"); + assert_eq!(s.activity, n, "…while the counter is what moves"); + } + + // A straggler after the turn ended still counts: it may well have + // written a file, and it must not be mistaken for "nothing happened". + s.apply_event(&ev(AgentEventKind::Stop)); + s.apply_event(&ev(AgentEventKind::ToolComplete)); + assert_eq!( + s.status, + AgentStatus::Done, + "and still doesn't resurrect the turn" + ); + assert_eq!(s.activity, 4); + + // Session end resets plenty of state but not this — a rewind to 0 would + // read to a delta-comparing consumer as one more tool call. + s.apply_event(&ev(AgentEventKind::SessionEnd)); + assert_eq!(s.activity, 4); + } + + /// The agent's cwd claim: any event carrying one sets it, later events + /// without one leave it alone (mid-turn events keep the worktree path + /// alive), and session end drops it — an exited agent must not pin the + /// pane's git line to a directory nothing runs in anymore. + #[test] + fn session_state_tracks_and_releases_the_agent_cwd() { + use std::path::PathBuf; + + let ev = |kind, cwd: Option<&str>| AgentEvent { + agent: Some(CLIAgent::Claude), + kind, + session_id: None, + message: None, + cwd: cwd.map(PathBuf::from), + }; + + let mut s = AgentSessionState::default(); + s.apply_event(&ev(AgentEventKind::SessionStart, Some("/repo"))); + assert_eq!(s.cwd.as_deref(), Some(std::path::Path::new("/repo"))); + + // EnterWorktree lands as a tool-complete carrying the new directory. + s.apply_event(&ev( + AgentEventKind::ToolComplete, + Some("/repo/.claude/worktrees/fix-x"), + )); + assert_eq!( + s.cwd.as_deref(), + Some(std::path::Path::new("/repo/.claude/worktrees/fix-x")) + ); + + // An event without a cwd (another agent's sparser payload) keeps it. + s.apply_event(&ev(AgentEventKind::Stop, None)); + assert_eq!( + s.cwd.as_deref(), + Some(std::path::Path::new("/repo/.claude/worktrees/fix-x")) + ); + + s.apply_event(&ev(AgentEventKind::SessionEnd, None)); + assert_eq!(s.cwd, None, "session end releases the cwd claim"); + } + #[test] fn resume_commands_are_shell_safe() { assert_eq!( - CLIAgent::Claude.resume_command("abc-123").as_deref(), + CLIAgent::Claude.resume_command("abc-123", None).as_deref(), Some("claude --resume abc-123") ); assert_eq!( - CLIAgent::Codex.resume_command("th_read.9").as_deref(), + CLIAgent::Codex.resume_command("th_read.9", None).as_deref(), Some("codex resume th_read.9") ); // No resume flag known → None. - assert_eq!(CLIAgent::Aider.resume_command("abc"), None); + assert_eq!(CLIAgent::Aider.resume_command("abc", None), None); // An id carrying shell syntax is refused outright. - assert_eq!(CLIAgent::Claude.resume_command("abc; rm -rf /"), None); - assert_eq!(CLIAgent::Claude.resume_command("$(boom)"), None); - assert_eq!(CLIAgent::Claude.resume_command(""), None); + assert_eq!(CLIAgent::Claude.resume_command("abc; rm -rf /", None), None); + assert_eq!(CLIAgent::Claude.resume_command("$(boom)", None), None); + assert_eq!(CLIAgent::Claude.resume_command("", None), None); + } + + #[test] + fn resume_carries_launch_flags() { + let argv = |parts: &[&str]| parts.iter().map(|s| s.to_string()).collect::>(); + + // The headline case: the user's mode flags survive the restart. + assert_eq!( + CLIAgent::Claude + .resume_command( + "abc-123", + Some(&argv(&["claude", "--dangerously-skip-permissions"])) + ) + .as_deref(), + Some("claude --dangerously-skip-permissions --resume abc-123") + ); + // Value-taking flags ride along whole. + assert_eq!( + CLIAgent::Claude + .resume_command("abc", Some(&argv(&["claude", "--model", "opus"]))) + .as_deref(), + Some("claude --model opus --resume abc") + ); + // Interpreter-wrapped launch: flags start after the token naming the + // agent, and the table's launcher name is what replays. + assert_eq!( + CLIAgent::Claude + .resume_command( + "abc", + Some(&argv(&[ + "node", + "/x/node_modules/@anthropic-ai/claude-code/cli.js", + "--dangerously-skip-permissions", + ])) + ) + .as_deref(), + Some("claude --dangerously-skip-permissions --resume abc") + ); + // A stale session-targeting flag is stripped — the new id must win. + assert_eq!( + CLIAgent::Claude + .resume_command( + "new-id", + Some(&argv(&["claude", "--resume", "old-id", "--model", "opus"])) + ) + .as_deref(), + Some("claude --model opus --resume new-id") + ); + // Codex resumes via its subcommand, flags after the positional id; a + // relaunched `codex resume ` sheds the old subcommand + id. + assert_eq!( + CLIAgent::Codex + .resume_command("id-1", Some(&argv(&["codex", "--yolo"]))) + .as_deref(), + Some("codex resume id-1 --yolo") + ); + assert_eq!( + CLIAgent::Codex + .resume_command("id-2", Some(&argv(&["codex", "resume", "id-1", "--yolo"]))) + .as_deref(), + Some("codex resume id-2 --yolo") + ); + // Anything shell-unsafe or positional-shaped drops the WHOLE tail — + // resume still works, just bare. + assert_eq!( + CLIAgent::Claude + .resume_command( + "abc", + Some(&argv(&["claude", "--allowedTools", "Bash(git:*)"])) + ) + .as_deref(), + Some("claude --resume abc") + ); + assert_eq!( + CLIAgent::Claude + .resume_command("abc", Some(&argv(&["claude", "fix-the-bug"]))) + .as_deref(), + Some("claude --resume abc") + ); + // A leading env assignment doesn't mis-anchor the flag tail. + assert_eq!( + CLIAgent::Claude + .resume_command( + "abc", + Some(&argv(&[ + "CLAUDE_CONFIG_DIR=/opt/claude", + "claude", + "--dangerously-skip-permissions", + ])) + ) + .as_deref(), + Some("claude --dangerously-skip-permissions --resume abc") + ); + // Two consecutive bare words = a positional prompt, not a flag value — + // it must not re-submit itself into the resumed session. + assert_eq!( + CLIAgent::Claude + .resume_command( + "abc", + Some(&argv(&["claude", "--model", "opus", "review", "this"])) + ) + .as_deref(), + Some("claude --resume abc") + ); + // Codex `--last` targets "most recent" and would contradict the + // explicit id → stripped. + assert_eq!( + CLIAgent::Codex + .resume_command( + "id-3", + Some(&argv(&["codex", "resume", "--last", "--yolo"])) + ) + .as_deref(), + Some("codex resume id-3 --yolo") + ); + // No token names the agent (custom wrapper rule) → bare. + assert_eq!( + CLIAgent::Claude + .resume_command( + "abc", + Some(&argv(&["cc", "--dangerously-skip-permissions"])) + ) + .as_deref(), + Some("claude --resume abc") + ); + // Amp: global mode flags ride after the `threads continue` positional. + assert_eq!( + CLIAgent::Amp + .resume_command("t-1", Some(&argv(&["amp", "--dangerously-allow-all"]))) + .as_deref(), + Some("amp threads continue t-1 --dangerously-allow-all") + ); + // A relaunch via `amp threads continue …` is subcommand-shaped, not + // flag-shaped → bare (the gate rejects the leading bare word). + assert_eq!( + CLIAgent::Amp + .resume_command("t-2", Some(&argv(&["amp", "threads", "continue", "t-1"]))) + .as_deref(), + Some("amp threads continue t-2") + ); + // Copilot resumes by flag, stale session targeting stripped. + assert_eq!( + CLIAgent::Copilot + .resume_command( + "s-9", + Some(&argv(&["copilot", "--resume", "s-1", "--allow-all-tools"])) + ) + .as_deref(), + Some("copilot --allow-all-tools --resume s-9") + ); + assert_eq!( + CLIAgent::Copilot.resume_command("s-9", None).as_deref(), + Some("copilot --resume s-9") + ); } #[test] diff --git a/src/core/config.rs b/src/core/config.rs index 6a94b371..897610fa 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -42,6 +42,18 @@ pub struct Config { /// `~/.config/tty7/themes/*`); unknown ids fall back to the default theme. The /// native chrome is forced to match the theme's light/dark brightness. pub theme_preset: String, + /// Follow the OS light/dark appearance. When `true` the active theme is + /// resolved from `theme_preset_light` / `theme_preset_dark` by the current + /// system appearance (switching live when the OS mode flips) and + /// `theme_preset` is ignored; the native chrome follows the OS instead of + /// being pinned to the theme. + pub theme_follow_system: bool, + /// Theme id used while `theme_follow_system` is on and the OS is in light + /// mode. Same registry/fallback rules as `theme_preset`. + pub theme_preset_light: String, + /// Theme id used while `theme_follow_system` is on and the OS is in dark + /// mode. Same registry/fallback rules as `theme_preset`. + pub theme_preset_dark: String, /// Global window-opacity override, 0.2–1.0. `None` (the default) follows the /// active theme's own `opacity`; when set it applies to every theme, so a /// chosen translucency survives theme switches. @@ -75,6 +87,14 @@ pub struct Config { /// Detect URLs (OSC 8 hyperlinks + bare URLs in the text), underline them on /// hover, and open them on ⌘/Ctrl-click. On by default. pub link_url: bool, + /// Optional command template run when ⌘/Ctrl-clicking a detected file-path + /// link, instead of tty7's built-in "open in the default app" behavior. The + /// template is tokenized on whitespace and the placeholders `{path}`, + /// `{line}`, and `{column}` are substituted per argument; an argument that + /// contains a placeholder with no value (e.g. `{line}` on a link that has no + /// line number) is dropped. `None` (the default) keeps the built-in open. + /// Example: `"herdr edit {path} --line {line}"`. + pub link_file_command: Option, /// When a pane is in a detected SSH session, Command-clicking loopback URLs /// opens them through a temporary local SSH port-forward. Off by default /// because it starts background `ssh` processes. @@ -152,6 +172,13 @@ pub struct Config { /// visual flash (the current behavior). #[serde(default, deserialize_with = "de_lenient")] pub bell: BellMode, + /// Tab at the prompt opens tty7's own completion menu (commands, paths, + /// per-command signatures). On by default. When off — or whenever the + /// engine has nothing to offer — the prompt line is handed to the shell + /// and Tab goes to the PTY, so the shell's native completion (compsys, + /// fzf-tab, …) answers instead. + #[serde(default = "default_true")] + pub tab_completion: bool, // ── Appearance ────────────────────────────────────────────────────────── /// The shape drawn for the terminal cursor. @@ -189,6 +216,19 @@ pub struct Config { /// the clipboard is never overwritten by a stray selection unless opted /// into. pub copy_on_select: bool, + /// Double-click smart selection: expand the selection to the whole URL, + /// email address, file path, or matching bracket pair under the cursor + /// when the plain word sits inside one. On by default; off restores the + /// bare word-boundary double-click. + #[serde(default = "default_true")] + pub smart_select: bool, + /// Characters (besides whitespace) that end a double-click word + /// selection, in both the terminal grid and the prompt's command editor. + /// The default mirrors alacritty's semantic escape set — note `/ . - _` + /// are *not* separators, so paths select as one word. JSON-only (no GUI + /// widget yet). + #[serde(default = "default_word_separators")] + pub word_separators: String, /// Window state at launch: normal / maximized / fullscreen. #[serde(default, deserialize_with = "de_lenient")] pub startup_mode: StartupMode, @@ -450,6 +490,11 @@ impl Default for Config { // The default theme id (mirrors `ui::presets::DEFAULT_ID`; core can't // depend on ui). Unknown ids fall back to it anyway. theme_preset: "light".to_string(), + theme_follow_system: false, + // The built-in light/dark pair; each side is user-swappable in + // Settings once "sync with system" is on. + theme_preset_light: "light".to_string(), + theme_preset_dark: "dark".to_string(), window_opacity: None, window_blur: None, keybindings: HashMap::new(), @@ -464,6 +509,7 @@ impl Default for Config { // out: URL detection on, cursor blinking, 10k scrollback, new tabs // after the active one, notify only while unfocused. link_url: true, + link_file_command: None, ssh_loopback_forward: false, cursor_blink: true, scrollback_limit: 10_000, @@ -487,6 +533,7 @@ impl Default for Config { // Visual flash preserves the pre-config behavior (the bell always // flashed); opting into None/Audible is a deliberate change. bell: BellMode::Visual, + tab_completion: true, cursor_style: CursorStyle::Block, // Input/mouse defaults preserve today's behavior: Option composes // characters as macOS ships it (opt into Option-as-Meta); GPUI @@ -500,6 +547,8 @@ impl Default for Config { mouse_reporting: true, clipboard_trim_trailing_spaces: false, copy_on_select: false, + smart_select: true, + word_separators: default_word_separators(), startup_mode: StartupMode::Normal, remember_window_size: true, working_directory: WorkingDirectory::default(), @@ -584,6 +633,14 @@ impl Config { self.right_panel_width = default_right_panel_width(); } self.right_panel_width = self.right_panel_width.clamp(100.0, 2000.0); + // An empty or whitespace-only file-open command means "no override"; the + // settings text field yields `""` when cleared, so fold it back to `None` + // rather than trying to run an empty command. + if let Some(command) = &self.link_file_command + && command.trim().is_empty() + { + self.link_file_command = None; + } } /// Write the current config back to disk, creating the parent directory if @@ -772,6 +829,13 @@ fn default_true() -> bool { true } +/// Serde default for [`Config::word_separators`]: alacritty's stock semantic +/// escape set, the boundary characters double-click word selection used +/// before this was configurable. +fn default_word_separators() -> String { + ",│`|:\"' ()[]{}<>\t".to_string() +} + /// Serde default for [`Config::notify_threshold_secs`]: the 10-second floor a /// command had to cross before this was configurable. fn default_notify_threshold_secs() -> u64 { @@ -882,6 +946,27 @@ mod tests { assert_eq!(back.ssh_profile_frecency.get(&id).unwrap().count, 4); } + #[test] + fn theme_follow_system_defaults_and_round_trips() { + // Old configs (no follow-system keys) must land on off + the built-in + // light/dark pair, so nothing changes until the user opts in. + let cfg: Config = serde_json::from_str(r#"{"theme_preset":"dracula"}"#).unwrap(); + assert!(!cfg.theme_follow_system); + assert_eq!(cfg.theme_preset_light, "light"); + assert_eq!(cfg.theme_preset_dark, "dark"); + assert_eq!(cfg.theme_preset, "dracula"); + + let mut cfg = Config::default(); + cfg.theme_follow_system = true; + cfg.theme_preset_light = "one_light".to_string(); + cfg.theme_preset_dark = "dracula".to_string(); + let json = serde_json::to_string(&cfg).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert!(back.theme_follow_system); + assert_eq!(back.theme_preset_light, "one_light"); + assert_eq!(back.theme_preset_dark, "dracula"); + } + #[test] fn font_features_are_optional_and_parse_as_gpui_features() { let cfg: Config = @@ -1080,6 +1165,7 @@ mod tests { let cfg = Config::default(); assert!(cfg.restore_session); assert!(cfg.mouse_reporting); + assert!(cfg.tab_completion); assert_eq!(cfg.notify_threshold_secs, 10); assert_eq!(cfg.bell, BellMode::Visual); @@ -1088,9 +1174,14 @@ mod tests { let cfg: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); assert!(cfg.restore_session); assert!(cfg.mouse_reporting); + assert!(cfg.tab_completion); assert_eq!(cfg.notify_threshold_secs, 10); assert_eq!(cfg.bell, BellMode::Visual); + // The opt-out round-trips. + let cfg: Config = serde_json::from_str(r#"{"tab_completion": false}"#).unwrap(); + assert!(!cfg.tab_completion); + // Valid values round-trip; a bad bell string falls back without failing // the whole parse. let cfg: Config = serde_json::from_str( diff --git a/src/core/mod.rs b/src/core/mod.rs index 073b26a7..aa190f83 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -18,6 +18,7 @@ pub mod config; #[allow(dead_code)] pub mod keychain; pub mod osc; +pub mod proc; pub mod session; pub mod shells; pub mod ssh_config; diff --git a/src/core/proc.rs b/src/core/proc.rs new file mode 100644 index 00000000..a23bdb0e --- /dev/null +++ b/src/core/proc.rs @@ -0,0 +1,40 @@ +//! One place for the Windows subprocess flag every helper shell-out needs. +//! +//! tty7 is a GUI process with no console of its own, so launching a console +//! subsystem program (`git.exe`, `wsl.exe`, …) makes Windows allocate a fresh +//! console for it — a black window that pops up and vanishes. That is invisible +//! on a one-off invocation and very visible on the git-status probe, which runs +//! four `git` calls every time a pane's cwd changes or a command ends. +//! +//! `CREATE_NO_WINDOW` suppresses the console entirely; stdout/stderr pipes are +//! unaffected, so output capture keeps working. Every non-PTY `Command` in the +//! app should go through [`hide_console`] (or [`hide_console_tokio`] for the +//! async flavor) before it runs. PTY children are not in scope — the daemon +//! owns those and passes its own flags (see [`crate::daemon::spawn`]). + +use std::process::Command; + +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// Suppress the console window Windows would otherwise allocate for a console +/// subsystem child. No-op on Unix, so callers stay `cfg`-free. +pub fn hide_console(cmd: &mut Command) -> &mut Command { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd +} + +/// [`hide_console`] for `tokio::process::Command`. Separate because tokio's +/// builder is a distinct type with its own `creation_flags`, not a `Deref` to +/// the std one. +pub fn hide_console_tokio(cmd: &mut tokio::process::Command) -> &mut tokio::process::Command { + #[cfg(windows)] + { + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd +} diff --git a/src/core/session.rs b/src/core/session.rs index ce6fcaa3..99beaf4e 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -54,6 +54,12 @@ pub enum SessionPane { agent: Option, #[serde(default)] agent_session_id: Option, + /// The argv the agent was launched with, as the daemon observed it — + /// lets the resume command carry the user's launch flags + /// (`--dangerously-skip-permissions`, …) instead of resuming bare. + /// `None` for old sessions or when nothing was captured. + #[serde(default)] + agent_launch_argv: Option>, }, /// A split of two subtrees along `axis`, with `a` taking `ratio` of space. Split { @@ -192,6 +198,7 @@ mod tests { ssh_spec: None, agent: None, agent_session_id: None, + agent_launch_argv: None, }, }, SessionTab { @@ -206,6 +213,7 @@ mod tests { ssh_spec: None, agent: None, agent_session_id: None, + agent_launch_argv: None, }), b: Box::new(SessionPane::Leaf { cwd: Some(PathBuf::from("/tmp")), @@ -213,6 +221,7 @@ mod tests { ssh_spec: None, agent: None, agent_session_id: None, + agent_launch_argv: None, }), }, }, @@ -244,6 +253,10 @@ mod tests { ssh_spec: None, agent: Some(crate::core::cli_agent::CLIAgent::Claude), agent_session_id: Some("abc-123".into()), + agent_launch_argv: Some(vec![ + "claude".into(), + "--dangerously-skip-permissions".into(), + ]), }; let back: SessionPane = serde_json::from_str(&serde_json::to_string(&leaf).unwrap()).unwrap(); @@ -251,10 +264,20 @@ mod tests { SessionPane::Leaf { agent, agent_session_id, + agent_launch_argv, .. } => { assert_eq!(agent, Some(crate::core::cli_agent::CLIAgent::Claude)); assert_eq!(agent_session_id.as_deref(), Some("abc-123")); + assert_eq!( + agent_launch_argv.as_deref(), + Some( + &[ + "claude".to_string(), + "--dangerously-skip-permissions".to_string() + ][..] + ) + ); } _ => panic!("expected leaf"), } @@ -266,6 +289,7 @@ mod tests { SessionPane::Leaf { agent: None, agent_session_id: None, + agent_launch_argv: None, .. } )); @@ -305,6 +329,7 @@ mod tests { ssh_spec: None, agent: None, agent_session_id: None, + agent_launch_argv: None, }, }], }; diff --git a/src/core/shells.rs b/src/core/shells.rs index 97e6bb21..581023b2 100644 --- a/src/core/shells.rs +++ b/src/core/shells.rs @@ -278,7 +278,11 @@ fn detect_windows() -> Vec { out.push(DetectedShell { label: "Git Bash".into(), program: bash.to_string_lossy().into_owned(), - // Interactive login shell — matches Git Bash's own launcher. + // Interactive login shell — matches Git Bash's own launcher. These + // are tty7's args, not the user's, so shell integration may replace + // them with its own spelling of the same thing (see + // `protocol::ShellSpec::args_are_tty7_defaults`); they stand as the + // fallback for when integration doesn't apply or fails to set up. args: vec!["-i".into(), "-l".into()], }); } @@ -296,6 +300,22 @@ fn detect_windows() -> Vec { out } +/// Git Bash's `bash.exe`, if Git for Windows is installed. Exposed only to +/// tests, so `daemon::shell_integration`'s live-PTY check can spawn the same +/// binary the dropdown does (and skip itself when there is none). +#[cfg(all(windows, test))] +pub fn git_bash_path() -> Option { + find_git_bash() +} + +/// Installed WSL distributions. Exposed only to tests, for the same reason as +/// [`git_bash_path`]: the live-PTY check needs a real distro to launch into, +/// and skips itself when there is none. +#[cfg(all(windows, test))] +pub fn wsl_distros() -> Vec { + list_wsl_distros() +} + /// Git Bash from the usual Git-for-Windows install roots (machine-wide x64, /// x86, and the per-user installer's home). #[cfg(windows)] @@ -319,17 +339,13 @@ fn find_git_bash() -> Option { } /// Installed WSL distribution names via `wsl.exe -l -q`, or empty when WSL is -/// absent. `CREATE_NO_WINDOW` keeps the probe from flashing a console window -/// (we're a GUI process). +/// absent. [`hide_console`](crate::core::proc::hide_console) keeps the probe +/// from flashing a console window (we're a GUI process). #[cfg(windows)] fn list_wsl_distros() -> Vec { - use std::os::windows::process::CommandExt as _; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - let Ok(output) = std::process::Command::new("wsl.exe") - .args(["-l", "-q"]) - .creation_flags(CREATE_NO_WINDOW) - .output() - else { + let mut cmd = std::process::Command::new("wsl.exe"); + cmd.args(["-l", "-q"]); + let Ok(output) = crate::core::proc::hide_console(&mut cmd).output() else { return Vec::new(); }; if !output.status.success() { diff --git a/src/core/ssh_profile.rs b/src/core/ssh_profile.rs index dd7430db..272921b2 100644 --- a/src/core/ssh_profile.rs +++ b/src/core/ssh_profile.rs @@ -74,6 +74,12 @@ pub struct SshProfile { pub warn_on_close: Option, /// Suppress the server login banner. pub skip_banner: bool, + /// Bootstrap tty7's shell integration into the remote shell (prompt marks, + /// exit codes, cwd — what the inline line editor runs on). On by default; + /// a remote we can't integrate declines itself, so this is the escape hatch + /// for one we *can* but shouldn't. + #[serde(default = "default_true")] + pub shell_integration: bool, /// Commands sent automatically right after the shell opens. pub login_scripts: Vec, /// Request X11 forwarding. @@ -110,6 +116,7 @@ impl Default for SshProfile { connect_timeout_s: None, warn_on_close: None, skip_banner: false, + shell_integration: true, login_scripts: Vec::new(), x11: false, algorithms: Algorithms::default(), @@ -434,6 +441,22 @@ pub fn expand_tilde(path: &str) -> String { mod tests { use super::*; + /// Profiles written before the shell-integration switch existed must load + /// with it *on*: a plain `#[serde(default)]` would give `false` and quietly + /// opt every existing profile out of the feature it never knew about. + #[test] + fn profiles_saved_before_the_switch_existed_default_to_integrated() { + let profile: SshProfile = + serde_json::from_str(r#"{"name":"prod","host":"h","user":"u"}"#).unwrap(); + assert!(profile.shell_integration); + // …and a profile that explicitly opted out stays opted out. + let off: SshProfile = serde_json::from_str( + r#"{"name":"prod","host":"h","user":"u","shell_integration":false}"#, + ) + .unwrap(); + assert!(!off.shell_integration); + } + #[test] fn quick_connect_parses_basic_forms() { let q = parse_quick_connect("deploy@10.0.0.5").unwrap(); @@ -679,3 +702,11 @@ fn new_id() -> Uuid { fn default_port() -> u16 { 22 } + +/// Serde default for [`SshProfile::shell_integration`]. Named rather than +/// `#[serde(default)]` because the default is `true`, and because profiles +/// written before the field existed must deserialize as opted *in* — the +/// integration is the behavior we want everywhere it works. +fn default_true() -> bool { + true +} diff --git a/src/core/worktree.rs b/src/core/worktree.rs index d88796d7..e908ff37 100644 --- a/src/core/worktree.rs +++ b/src/core/worktree.rs @@ -62,10 +62,9 @@ pub fn is_inside_repo(cwd: &Path) -> bool { /// Run `git -C `, returning trimmed stdout on success and trimmed /// stderr as the error otherwise. fn git(dir: &Path, args: &[&str]) -> Result { - let out = std::process::Command::new("git") - .arg("-C") - .arg(dir) - .args(args) + let mut cmd = std::process::Command::new("git"); + cmd.arg("-C").arg(dir).args(args); + let out = crate::core::proc::hide_console(&mut cmd) .output() .map_err(|e| format!("failed to run git: {e}"))?; if out.status.success() { diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index 1981aa34..0904d6ee 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -102,6 +102,33 @@ fn default_shell_name(cmd: &CommandBuilder) -> String { cmd.get_shell() } +/// The shell a spawn resolved to, plus who authored its args. +#[cfg_attr(test, derive(Debug, PartialEq, Eq))] +struct ChosenShell { + program: String, + args: Vec, + /// True when `args` are tty7's own defaults from shell discovery rather + /// than the user's, so shell integration may replace them. See + /// [`ShellSpec::args_are_tty7_defaults`]. + args_are_tty7_defaults: bool, +} + +/// Whether the chosen shell carries args tty7 must not second-guess, which is +/// what makes `shell_integration::setup` decline bash and PowerShell (their +/// injections replace argv rather than extend it). Two things have to hold for +/// the args to be off-limits: +/// +/// - there are some — an empty `args: []` (just picking the program) leaves +/// nothing for bash's `--rcfile … -i` to conflict with; and +/// - the *user* wrote them. The new-tab dropdown's args are tty7's own +/// (`core::shells::detect_shells`), so integration is free to express the +/// same intent its own way: Git Bash's `-i -l` means "interactive login +/// shell", exactly what `setup_bash` rebuilds out of `--rcfile … -i` plus a +/// replayed login-file chain. See [`ShellSpec::args_are_tty7_defaults`]. +fn has_custom_args(chosen: Option<&ChosenShell>) -> bool { + chosen.is_some_and(|c| !c.args.is_empty() && !c.args_are_tty7_defaults) +} + /// Which shell a spawn launches, by precedence: the explicit per-spawn override /// (the new-tab dropdown) > the configured `shell` in `config.json` > `None`, /// meaning the platform default (`default_prog()`). Kept as a function so the @@ -109,8 +136,21 @@ fn default_shell_name(cmd: &CommandBuilder) -> String { fn choose_shell( spawn_override: Option, configured: Option<(String, Vec)>, -) -> Option<(String, Vec)> { - spawn_override.map(|s| (s.program, s.args)).or(configured) +) -> Option { + spawn_override + .map(|s| ChosenShell { + program: s.program, + args: s.args, + args_are_tty7_defaults: s.args_are_tty7_defaults, + }) + .or_else(|| { + // Straight from `config.json` — the user wrote these. + configured.map(|(program, args)| ChosenShell { + program, + args, + args_are_tty7_defaults: false, + }) + }) } fn apply_shell_integration( @@ -123,7 +163,7 @@ fn apply_shell_integration( // sentinel builder. Integrations that need argv (fish `-C`, bash `--rcfile`, // PowerShell flags) must use an explicit command builder first. Env-only zsh // integration keeps the default login-shell path. - if integration.force_non_login || (cmd.is_default_prog() && !integration.args.is_empty()) { + if integration.replaces_argv || (cmd.is_default_prog() && !integration.args.is_empty()) { *cmd = CommandBuilder::new(resolved_program); } cmd.args(&integration.args); @@ -144,26 +184,70 @@ fn build_spawn_config( shell: Option, ) -> anyhow::Result { let initial_cwd = initial_working_directory(cwd); - let (cmd, integration_dir) = build_shell_command(shell, &initial_cwd)?; + // Resolved here rather than inside `build_shell_command` because the WSL tag + // must be read off the shell we *actually* launch. `shell` is only the + // per-spawn override; `config.json` supplies the program when it is `None`, + // and a `wsl.exe` configured there is just as much a WSL pane as one picked + // from the dropdown. + let configured = choose_shell(shell, crate::core::config::shell_command()); + let remote = wsl_remote_context(configured.as_ref()); + let (cmd, integration_dir) = build_shell_command(configured, &initial_cwd)?; Ok(SpawnConfig { cmd, initial_cwd, integration_dir, - remote: None, + remote, }) } +/// Tag a `wsl.exe` pane as living in another filesystem namespace, from the +/// resolved shell rather than the process table — `wsl.exe` is exactly what tty7 +/// launched, so there is nothing to detect. +/// +/// This is what makes `TerminalView::local_cwd` decline the distro's cwd, and +/// so what keeps the local git probe, path completion, link resolution and cwd +/// inheritance away from a path that means nothing on this side (and that +/// Windows would read as drive-relative). It is set whether or not shell +/// integration succeeded: an unintegrated WSL pane reports no cwd today, but if +/// it ever does the gate must already be in place. +/// +/// Takes the post-[`choose_shell`] program, not the per-spawn override: a +/// `wsl.exe` written into `config.json` reaches the same integration and so must +/// reach the same tag. +fn wsl_remote_context(shell: Option<&ChosenShell>) -> Option { + if !cfg!(windows) { + return None; + } + let chosen = shell?; + let base = std::path::Path::new(&chosen.program) + .file_name()? + .to_str()? + .to_ascii_lowercase(); + if base.strip_suffix(".exe").unwrap_or(&base) != "wsl" { + return None; + } + Some(RemoteContext { + kind: RemoteKind::Wsl, + argv: Vec::new(), + // The distro, when the args name one; otherwise `wsl.exe` picks the + // default and we have no name for it without another probe. Shared with + // the integration so the two can't disagree about which distro an argv + // names — they are handed the very same args. + target: shell_integration::wsl_distro(&chosen.args).unwrap_or_default(), + }) +} + +/// Build the argv for a spawn from an already-resolved shell (see +/// [`choose_shell`]); `None` means the platform default (the login shell on +/// Unix, PowerShell on Windows). fn build_shell_command( - shell: Option, + configured: Option, initial_cwd: &Option, ) -> anyhow::Result<(CommandBuilder, Option)> { - // Build the shell command; `None` means the platform default (the login - // shell on Unix, PowerShell on Windows). - let configured = choose_shell(shell, crate::core::config::shell_command()); let mut cmd = match &configured { - Some((program, args)) => { - let mut c = CommandBuilder::new(program); - c.args(args); + Some(chosen) => { + let mut c = CommandBuilder::new(&chosen.program); + c.args(&chosen.args); c } None => default_prog(), @@ -174,20 +258,20 @@ fn build_shell_command( // it's whatever `default_prog()` resolved (passwd/`$SHELL` on Unix, // `powershell.exe` on Windows — see `default_shell_name`). let resolved_program = match &configured { - Some((program, _)) => program.clone(), + Some(chosen) => chosen.program.clone(), None => default_shell_name(&cmd), }; - // Shell integration: inject OSC 7 / OSC 133 hooks (zsh/fish/bash/PowerShell - // — see `daemon::shell_integration`). Best effort — `None` (an unsupported - // shell, or a bash/PowerShell with unpreservable custom args) means we launch - // bare. A configured shell only counts as having "custom args" to preserve - // when it actually specifies any — an empty `args: []` (just picking the - // program) leaves nothing for bash's `--rcfile -i` to conflict with. - let has_custom_args = configured - .as_ref() - .is_some_and(|(_, args)| !args.is_empty()); - let integration = shell_integration::setup(Some(&resolved_program), has_custom_args); + // Shell integration: inject OSC 7 / OSC 133 hooks (zsh/fish/bash/PowerShell, + // and through `wsl.exe` into a distro — see `daemon::shell_integration`). + // Best effort — `None` (an unsupported shell, or one with unpreservable + // custom args) means we launch bare. The args go in because the WSL path + // reads the distro out of them. + let integration = shell_integration::setup( + Some(&resolved_program), + configured.as_ref().map_or(&[][..], |c| c.args.as_slice()), + has_custom_args(configured.as_ref()), + ); if let Some(integration) = &integration { apply_shell_integration(&mut cmd, &resolved_program, integration); } @@ -212,7 +296,17 @@ fn initial_working_directory(cwd: Option) -> Option { // client didn't pass an explicit cwd (tab-inherit / session restore still // win). Inherit -> `forced` is `None`, so we keep the fallback as before. let forced = crate::core::config::working_directory_base(); - cwd.or(forced).or(fallback) + // Whatever wins must actually be a directory *here*. A client cwd is only + // as good as the OSC 7 that produced it, and a shell that reports a path + // this machine cannot resolve — a remote namespace, or an msys path like + // `/c/Users/x` that Windows reads as drive-relative — would otherwise turn + // a new tab or split into a hard spawn failure ("The directory name is + // invalid") instead of quietly falling back. Cheap to check, and it bounds + // the whole class rather than one shell's spelling at a time. + [cwd, forced, fallback] + .into_iter() + .flatten() + .find(|d| d.is_dir()) } fn apply_common_command_setup(cmd: &mut CommandBuilder, initial_cwd: &Option) { @@ -374,6 +468,11 @@ struct PaneState { /// the foreground `argv` (same process-table poll as `remote`). `None` when /// no known agent runs — see [`crate::core::cli_agent`]. agent: Option, + /// The argv the detected agent was launched with, held here until a rich + /// session exists to stamp it into (the sentinel events that create the + /// session can land before the first argv poll, and vice versa). Cleared + /// with the chip. See [`stamp_launch_argv`]. + agent_argv: Option>, /// The rich agent-session status (idle/working/waiting/done + native /// session id), folded from the sentinel OSC events the agent's hooks emit /// (with an opaque OSC 9/777 fallback). Cleared when the agent exits. @@ -409,8 +508,9 @@ struct ForegroundProbes { /// process group) — "no opinion", never applied, so it can't wipe an agent /// identified another way (sentinel events, the Windows `133;C;` /// mark). `Some(answer)` is a real poll result; its inner `None` ("polled, - /// no agent") clears the chip. - agent: Box Option> + Send>, + /// no agent") clears the chip. A detected agent travels with the `argv` it + /// was identified from, kept for flag carry-over on session resume. + agent: Box Option)>> + Send>, } /// The local-PTY backend: the same handles `DaemonPane` has always owned. @@ -575,6 +675,7 @@ impl DaemonPane { remote: spawn.remote.clone(), agent: None, agent_session: None, + agent_argv: None, alive: true, })); let shutting_down = Arc::new(AtomicBool::new(false)); @@ -683,6 +784,7 @@ impl DaemonPane { // agent detection never runs for it. agent: None, agent_session: None, + agent_argv: None, alive: true, })); let shutting_down = Arc::new(AtomicBool::new(false)); @@ -890,15 +992,23 @@ impl DaemonPane { std::time::Instant::now() + REMOTE_CONTEXT_POLL_INTERVAL; } let remote = if poll_now { - // A native-SSH pane already carries its own remote - // context; process-table detection must not clobber - // it (this pane *is* SSH). Only a plain PTY pane gets - // foreground `ssh` detection. + // A pane tty7 itself spawned as remote (native SSH, + // or WSL) already carries its own context from the + // spawn spec; process-table detection must not + // clobber it. Only `Ssh` — the kind this very probe + // produces — may be replaced, so a pane that has + // since left a foreground `ssh` clears correctly. + // + // Testing `!= Ssh` rather than `== NativeSsh` is + // load-bearing for WSL: `wsl.exe` is not `ssh`, so + // the probe returns `None` and would blank the + // context on the very next poll — twice a second, + // each time also clearing the pane's cwd. let managed = { let st = state.lock().unwrap(); st.remote .as_ref() - .is_some_and(|remote| remote.kind == RemoteKind::NativeSsh) + .is_some_and(|remote| remote.kind != RemoteKind::Ssh) }; (!managed).then(&foreground_remote) } else { @@ -1648,7 +1758,8 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) { if shell_mark_capture_changed(&st.shell, &shell) { apply_agent( st, - agent_from_shell_mark(&shell, crate::core::config::agent_commands_cached()), + agent_from_shell_mark(&shell, crate::core::config::agent_commands_cached()) + .map(|agent| (agent, Vec::new())), ); } st.shell = shell.clone(); @@ -1686,6 +1797,12 @@ fn agent_from_shell_mark( shell: &ShellState, custom: &std::collections::HashMap, ) -> Option { + // Identity only — deliberately NOT a launch-argv source for resume flag + // carry-over. The `133;C` capture is terminal *output* (any program can + // print the OSC and forge it), and forged flags would ride an + // auto-executed resume command on the next restore; the Unix argv comes + // from the process table and has no such problem. Windows sessions + // therefore resume bare. shell .command .as_deref() @@ -1739,6 +1856,16 @@ fn apply_agent_signals( sess.message = Some(body); } + // A session the events just created starts without the launch argv the + // identity poll captured — seed it so resume gets the flags no matter + // which side observed the pane first. Updates keep flowing through + // [`stamp_launch_argv`]. + if let (Some(sess), Some(argv)) = (&mut st.agent_session, &st.agent_argv) + && sess.launch_argv.is_none() + { + sess.launch_argv = Some(argv.clone()); + } + if st.agent_session != before && let Some(sub) = &st.subscriber { @@ -1750,14 +1877,35 @@ fn apply_remote_context(st: &mut PaneState, remote: Option) { if st.remote == remote { return; } + // The cwd belonged to whichever side we are leaving, and it does not + // survive the crossing: a remote path is meaningless locally, and a local + // one is meaningless on the remote. Drop it so the pane reports no cwd + // until the new shell's OSC 7 lands, rather than attributing the old + // namespace's directory to the new one — otherwise a local shell without + // shell integration keeps serving the remote's last path to the local + // `git` probe for the rest of its life. + // `DaemonMsg::Cwd` carries a bare path with no "cleared" form, so the + // client mirrors this on its own when it sees the `RemoteContext` below. + st.cwd = None; if let Some(sub) = &st.subscriber { let _ = sub.send(DaemonMsg::RemoteContext(remote.clone())); } st.remote = remote; } -fn apply_agent(st: &mut PaneState, agent: Option) { +fn apply_agent( + st: &mut PaneState, + detected: Option<(crate::core::cli_agent::CLIAgent, Vec)>, +) { + let (agent, argv) = match detected { + Some((agent, argv)) => (Some(agent), Some(argv)), + None => (None, None), + }; if st.agent == agent { + // Same chip, but the observed argv can still be news: the sentinel + // events may have branded the pane before the first argv poll, or the + // user relaunched the agent with different flags. + stamp_launch_argv(st, argv); return; } // The agent leaving the foreground ends its session: clear the rich state @@ -1770,10 +1918,38 @@ fn apply_agent(st: &mut PaneState, agent: Option>) { + let Some(argv) = argv else { return }; + if argv.is_empty() { + return; + } + if st.agent_argv.as_ref() == Some(&argv) { + return; + } + st.agent_argv = Some(argv.clone()); + if let Some(sess) = &mut st.agent_session + && sess.launch_argv.as_ref() != Some(&argv) + { + sess.launch_argv = Some(argv); + if let Some(sub) = &st.subscriber { + let _ = sub.send(DaemonMsg::AgentStatus(st.agent_session.clone())); + } + } } /// Whether a foreground command — not the shell itself — currently owns the @@ -1840,14 +2016,16 @@ fn foreground_remote_context(_master: &Mutex>) -> Opti #[cfg(any(target_os = "macos", target_os = "linux"))] fn foreground_agent( master: &Mutex>, -) -> Option> { +) -> Option)>> { let detect = || { let pid = master.lock().ok().and_then(|m| m.process_group_leader())?; let argv = crate::daemon::remote::foreground_argv(pid)?; - crate::core::cli_agent::CLIAgent::detect_from_argv_with( + let agent = crate::core::cli_agent::CLIAgent::detect_from_argv_with( &argv, crate::core::config::agent_commands_cached(), - ) + )?; + // The argv rides along: resume-after-restart replays its flags. + Some((agent, argv)) }; Some(detect()) } @@ -1859,7 +2037,7 @@ fn foreground_agent( #[cfg(not(any(target_os = "macos", target_os = "linux")))] fn foreground_agent( _master: &Mutex>, -) -> Option> { +) -> Option)>> { None } @@ -2027,7 +2205,9 @@ fn strip_uri_drive_slash(path: &str) -> &str { } /// Parse an OSC 7 `file://HOST/PATH` (or bare absolute path) payload. -fn parse_osc7(payload: &[u8]) -> Option { +/// `pub(crate)` so the shell-integration tests can round-trip what the snippets +/// actually emit through the parser that consumes it. +pub(crate) fn parse_osc7(payload: &[u8]) -> Option { let rest = payload.strip_prefix(b"7;")?; let path_bytes: &[u8] = if let Some(after) = rest.strip_prefix(b"file://") { let idx = after.iter().position(|&c| c == b'/')?; @@ -2116,6 +2296,48 @@ fn proc_name(pid: i32) -> Option { #[cfg(test)] mod tests { use super::*; + use std::path::Path; + + /// A cwd the client reports is only as trustworthy as the OSC 7 behind it. + /// Passing one this machine cannot resolve straight to `cmd.cwd()` turns a + /// new tab or split into a hard spawn failure, so anything that isn't a + /// real directory here must fall through to the next candidate instead. + #[test] + fn initial_working_directory_skips_paths_that_are_not_directories() { + let real = std::env::temp_dir(); + assert!(real.is_dir(), "temp dir should exist"); + + // A usable client cwd still wins outright. + assert_eq!( + initial_working_directory(Some(real.clone())), + Some(real.clone()) + ); + + // A remote-namespace path, and the msys shape Windows reads as + // drive-relative: neither resolves here, so neither may be used. + for bogus in [ + "/home/someone/definitely-not-here", + "/c/Users/definitely-not-here", + ] { + let got = initial_working_directory(Some(PathBuf::from(bogus))); + assert_ne!( + got.as_deref(), + Some(Path::new(bogus)), + "{bogus} is not a directory here and must not be handed to spawn" + ); + // Whatever we fall back to must itself be usable. + if let Some(d) = got { + assert!(d.is_dir(), "fallback {d:?} must be a real directory"); + } + } + + // A file is not a directory either. + let file = real.join("tty7-iwd-probe"); + std::fs::write(&file, b"x").expect("write probe file"); + let got = initial_working_directory(Some(file.clone())); + assert_ne!(got.as_deref(), Some(file.as_path())); + let _ = std::fs::remove_file(&file); + } /// End-to-end check of the *live* agent-detection chain this feature rides /// on macOS/Linux: spawn a real PTY child whose `argv[0]` names a coding @@ -2163,11 +2385,17 @@ mod tests { let _ = child.kill(); let _ = child.wait(); + let (agent, argv) = detected.expect("agent detected from live PTY child"); assert_eq!( - detected, - Some(crate::core::cli_agent::CLIAgent::Codex), + agent, + crate::core::cli_agent::CLIAgent::Codex, "a live PTY child with argv[0]=codex must be detected as Codex" ); + assert_eq!( + argv.first().map(String::as_str), + Some("codex"), + "the observed argv rides along with the detection" + ); } /// Spawn shell precedence: explicit override > configured > platform @@ -2177,27 +2405,138 @@ mod tests { let over = ShellSpec { program: "fish".into(), args: vec!["-l".into()], + args_are_tty7_defaults: true, }; let cfg = ("zsh".to_string(), vec!["-i".to_string()]); - // Override wins even when a shell is configured. + // Override wins even when a shell is configured, carrying its + // arg-ownership flag through. assert_eq!( choose_shell(Some(over.clone()), Some(cfg.clone())), - Some(("fish".to_string(), vec!["-l".to_string()])) + Some(ChosenShell { + program: "fish".to_string(), + args: vec!["-l".to_string()], + args_are_tty7_defaults: true, + }) + ); + // No override → the configured shell, whose args are the user's and so + // are never tty7 defaults. + assert_eq!( + choose_shell(None, Some(cfg.clone())), + Some(ChosenShell { + program: "zsh".to_string(), + args: vec!["-i".to_string()], + args_are_tty7_defaults: false, + }) ); - // No override → the configured shell. - assert_eq!(choose_shell(None, Some(cfg.clone())), Some(cfg)); // Neither → platform default. assert_eq!(choose_shell(None, None), None); } + /// Only *user*-authored args block integration. Locks the contract stated + /// on [`has_custom_args`] — in particular that the Git Bash dropdown row's + /// `-i -l` does not, which is what lets it get shell integration at all. + #[test] + fn only_user_authored_args_block_shell_integration() { + let chosen = |args: Vec<&str>, tty7: bool| ChosenShell { + program: r"C:\Program Files\Git\bin\bash.exe".to_string(), + args: args.into_iter().map(str::to_string).collect(), + args_are_tty7_defaults: tty7, + }; + + // The Git Bash dropdown row: tty7 wrote `-i -l`, so `setup_bash` may + // replace them. + assert!(!has_custom_args(Some(&chosen(vec!["-i", "-l"], true)))); + // The same args from the user's config.json are theirs to keep. + assert!(has_custom_args(Some(&chosen(vec!["-i", "-l"], false)))); + // No args at all: nothing to preserve either way. + assert!(!has_custom_args(Some(&chosen(vec![], false)))); + assert!(!has_custom_args(Some(&chosen(vec![], true)))); + // Platform default — no configured shell at all. + assert!(!has_custom_args(None)); + } + + /// A WSL pane must be tagged as living in another filesystem namespace, so + /// `TerminalView::local_cwd` declines the distro's cwd and the local git + /// probe / completion / link resolution / cwd inheritance never see a path + /// that means nothing here — and that Windows would read as drive-relative + /// (`/home/me` -> `C:\home\me`) rather than reject. + #[cfg(windows)] + #[test] + fn wsl_panes_are_tagged_as_a_foreign_filesystem() { + let spec = |program: &str, args: Vec<&str>| ChosenShell { + program: program.to_string(), + args: args.into_iter().map(str::to_string).collect(), + args_are_tty7_defaults: true, + }; + + // The dropdown's WSL row. + let ctx = wsl_remote_context(Some(&spec( + "wsl.exe", + vec!["--distribution", "Ubuntu-24.04", "--cd", "~"], + ))) + .expect("wsl.exe must be tagged"); + assert_eq!(ctx.kind, RemoteKind::Wsl); + // The distro rides along as the target so the UI has a name for it. + assert_eq!(ctx.target, "Ubuntu-24.04"); + // Nothing reads `argv` for this kind; it is not an ssh invocation. + assert!(ctx.argv.is_empty()); + + // Short flag, and no flag at all (wsl.exe then picks the default + // distro — still a WSL pane, just one we have no name for). + assert_eq!( + wsl_remote_context(Some(&spec("wsl.exe", vec!["-d", "Debian"]))) + .expect("short flag") + .target, + "Debian" + ); + assert_eq!( + wsl_remote_context(Some(&spec("wsl.exe", vec![]))) + .expect("default distro is still WSL") + .target, + "" + ); + // The `--distribution=NAME` spelling too — the tag reads the distro with + // the integration's own parser, so the two cannot disagree about an argv + // they are both handed. + assert_eq!( + wsl_remote_context(Some(&spec("wsl.exe", vec!["--distribution=Arch"]))) + .expect("joined flag") + .target, + "Arch" + ); + // Case- and suffix-insensitive, like every other Windows program name. + assert!(wsl_remote_context(Some(&spec(r"C:\Windows\System32\WSL.EXE", vec![]))).is_some()); + + // Regression: the tag is read off the *resolved* shell, not the + // per-spawn override. A `wsl.exe` written into `config.json` reaches + // `setup_wsl` with no override in play (empty args, so nothing custom to + // preserve), so the distro starts reporting its own cwd — and an + // untagged pane would hand `/home/me/proj` straight to the local git + // probe, which Windows resolves drive-relative to `C:\home\me\proj`. + let from_config = choose_shell(None, Some(("wsl.exe".to_string(), Vec::new()))); + assert_eq!( + wsl_remote_context(from_config.as_ref()).map(|c| c.kind), + Some(RemoteKind::Wsl), + "a configured wsl.exe is as much a WSL pane as a dropdown one" + ); + + // Everything else is a local pane and must not be tagged — tagging it + // would silently disable its git status, completion and cwd inheritance. + assert!(wsl_remote_context(Some(&spec("powershell.exe", vec![]))).is_none()); + assert!( + wsl_remote_context(Some(&spec(r"C:\Program Files\Git\bin\bash.exe", vec![]))).is_none() + ); + assert!(wsl_remote_context(None).is_none()); + } + #[test] fn arg_based_integration_rebuilds_default_shell_builder() { let mut cmd = CommandBuilder::new_default_prog(); let injection = shell_integration::Injection { env: std::collections::HashMap::new(), args: vec!["-C".to_string(), "echo ready".to_string()], - force_non_login: false, + replaces_argv: false, dir: None, }; @@ -2220,7 +2559,7 @@ mod tests { let injection = shell_integration::Injection { env, args: Vec::new(), - force_non_login: false, + replaces_argv: false, dir: None, }; @@ -2897,6 +3236,7 @@ mod tests { remote: None, agent: None, agent_session: None, + agent_argv: None, alive, } } @@ -2993,7 +3333,10 @@ mod tests { status: AgentStatus::Working, message: None, session_id: Some("sid".into()), + launch_argv: None, rich: true, + cwd: None, + activity: 0, }); apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07")); assert_eq!( diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index 19cb4e81..f6f3707f 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -76,6 +76,18 @@ pub struct ShellSpec { pub program: String, #[serde(default)] pub args: Vec, + /// True when `args` were authored by tty7's own shell discovery + /// (`core::shells`) rather than by the user, and so may be replaced by + /// shell integration — Git Bash's `-i -l` is tty7's way of saying "an + /// interactive login shell", which `setup_bash`'s `--rcfile … -i` plus its + /// replayed login-file chain expresses differently but equivalently. + /// User-configured args get no such liberty; see + /// `daemon::shell_integration::setup`'s `has_custom_args`. + /// + /// Defaults to `false` on the wire so an older client's frame — which can + /// only carry user-configured args — keeps them untouched. + #[serde(default)] + pub args_are_tty7_defaults: bool, } /// Whether a short `ssh` option flag consumes the following argument as its @@ -120,14 +132,21 @@ pub struct PaneInfo { pub alive: bool, } -/// A foreground remote session the daemon can prove from the local process table. +/// A pane whose filesystem is not the host's — either a remote session, or a +/// local one behind a boundary the host's own tools can't follow (WSL). +/// +/// The common consequence, whatever the kind, is that the pane's cwd names a +/// path in *that* namespace: see `TerminalView::local_cwd`, which is what keeps +/// a local `git` / `read_dir` / spawn away from it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RemoteContext { pub kind: RemoteKind, /// Original foreground argv. Kept so follow-up operations can preserve ssh - /// config flags such as `-F`, `-p`, and `-J` rather than guessing. + /// config flags such as `-F`, `-p`, and `-J` rather than guessing. Empty + /// for kinds that aren't detected from a foreground process. pub argv: Vec, - /// The destination token (`host`, `user@host`, or ssh config alias). + /// The destination token: `host`, `user@host`, or ssh config alias for the + /// ssh kinds; the distro name for [`RemoteKind::Wsl`]. pub target: String, } @@ -142,6 +161,15 @@ pub enum RemoteKind { /// (`daemon::ssh`). Forwarding / SFTP reach the connection through the /// in-memory registry. NativeSsh, + /// A `wsl.exe` pane: not remote in the network sense, but its shell lives + /// inside a distro with its own filesystem namespace, so a cwd it reports + /// (`/home/me/proj`) means nothing to the Windows-side host — and on + /// Windows is *drive-relative* rather than invalid, so it silently resolves + /// to `C:\home\me\proj`. Set at spawn time from the `ShellSpec`, not + /// detected from the process table. Nothing SSH-specific applies to it: + /// callers that mean "an SSH pane" must test the kind, not merely that a + /// `RemoteContext` is present. + Wsl, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -542,6 +570,19 @@ pub struct NativeSshSpec { pub verify_host_keys: bool, #[serde(default)] pub skip_banner: bool, + /// Bootstrap tty7's shell integration (OSC 133 prompt marks + cwd + /// reporting) into the remote shell — what powers the inline line editor, + /// exit-code marks and cwd tracking for this pane. See + /// [`crate::daemon::shell_integration::remote`]. + /// + /// On by default, and a remote we can't integrate declines itself (the + /// probe answers "unknown shell" and the session starts bare), so this is + /// for the case the probe can't detect: a remote where the bootstrap *would* + /// work but the user would rather it didn't — a bash host where the + /// login-shell → `--rcfile` swap upsets something, or simply a host they + /// want left exactly as stock ssh leaves it. + #[serde(default = "default_true")] + pub shell_integration: bool, /// Lines sent verbatim (each + `\n`) to the shell channel after it starts, /// sequentially, with no expect-logic. #[serde(default)] @@ -598,6 +639,7 @@ impl std::fmt::Debug for NativeSshSpec { .field("term", &self.term) .field("verify_host_keys", &self.verify_host_keys) .field("skip_banner", &self.skip_banner) + .field("shell_integration", &self.shell_integration) .field("login_script", &self.login_script) .field("display_name", &self.display_name) .field("profile_id", &self.profile_id) @@ -1454,6 +1496,7 @@ mod tests { shell: Some(ShellSpec { program: "wsl.exe".into(), args: vec!["--distribution".into(), "Ubuntu".into()], + args_are_tty7_defaults: true, }), }, ClientMsg::Attach { @@ -1589,6 +1632,13 @@ mod tests { argv: vec!["ssh".into(), "-p".into(), "2222".into(), "dev".into()], target: "dev".into(), })), + // A WSL pane's context rides the same wire; `kind` is serialized + // kebab-case, so this pins the encoding of the new variant. + DaemonMsg::RemoteContext(Some(RemoteContext { + kind: RemoteKind::Wsl, + argv: Vec::new(), + target: "Ubuntu-24.04".into(), + })), DaemonMsg::RemoteContext(None), DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Claude)), DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Codex)), @@ -1597,7 +1647,13 @@ mod tests { status: crate::core::cli_agent::AgentStatus::Waiting, message: Some("Claude needs your permission to use Bash".into()), session_id: Some("abc-123".into()), + launch_argv: Some(vec![ + "claude".into(), + "--dangerously-skip-permissions".into(), + ]), rich: true, + cwd: Some("/repo/.claude/worktrees/fix-x".into()), + activity: 12, })), DaemonMsg::AgentStatus(None), DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }), @@ -1748,6 +1804,8 @@ mod tests { let shell = ShellSpec { program: "fish".to_string(), args: vec!["-l".to_string()], + // Set so the round-trip covers the flag, not just program + args. + args_are_tty7_defaults: true, }; let msg = ClientMsg::Spawn { cwd: Some(PathBuf::from("/work")), @@ -1933,6 +1991,7 @@ mod tests { term: "xterm-256color".into(), verify_host_keys: true, skip_banner: false, + shell_integration: true, login_script: vec![], display_name: None, profile_id: None, @@ -1956,6 +2015,7 @@ mod tests { term: "xterm-256color".into(), verify_host_keys: true, skip_banner: false, + shell_integration: true, login_script: vec!["tmux attach".into()], display_name: Some("prod-web".into()), profile_id: Some("uuid-1".into()), @@ -1979,6 +2039,10 @@ mod tests { .unwrap(); assert_eq!(spec.term, "xterm-256color"); // defaulted assert!(spec.verify_host_keys); // defaulted true + // A spec persisted before shell integration existed must come back + // opted *in* — `#[serde(default)]` on a bool would silently turn it off + // for every reconnect to a pane saved by an older build. + assert!(spec.shell_integration); assert_eq!(spec.password, None); assert!(spec.jump.is_none()); } diff --git a/src/daemon/shell_integration.rs b/src/daemon/shell_integration.rs index abc4f101..f260c7f2 100644 --- a/src/daemon/shell_integration.rs +++ b/src/daemon/shell_integration.rs @@ -30,10 +30,18 @@ //! plain non-login shell instead and have our rcfile manually replay the //! login-shell startup-file chain (`/etc/profile`, `~/.bash_profile` & //! co.) before layering hooks on top — see [`setup_bash`] and -//! [`Injection::force_non_login`]. Bash also has no native precmd/preexec, +//! [`Injection::replaces_argv`]. Bash also has no native precmd/preexec, //! so the hook body vendors the relevant parts of //! [bash-preexec](https://github.com/rcaloras/bash-preexec) (MIT), the -//! same shim VS Code relies on for this. +//! same shim VS Code relies on for this. This path covers Git Bash too — +//! the msys2 bash Git for Windows ships is spawned as `bash.exe` by +//! absolute path, and needs only its rcfile path spelled with forward +//! slashes (see [`bash_path`]). +//! - **WSL** is not a shell but a launcher: `wsl.exe` starts a shell *inside* +//! a distro, so the integration has to reach through it. We probe the +//! distro's login shell, write the matching rcfile on the Windows side, and +//! pass its path in via `WSLENV`, which translates it to the distro's view +//! of the filesystem. See [`setup_wsl`]. Only bash is wired up so far. //! - **PowerShell** (the Windows default, and any `pwsh`) has no dotfile //! redirect either, but `-EncodedCommand` runs a script *after* its own //! profiles load — like fish's `-C`, no file on disk. It has no @@ -42,8 +50,28 @@ //! `PSConsoleHostReadLine`, PSReadLine's line reader (the closest thing to //! a preexec, for the C mark). See [`setup_powershell`]. //! -//! Across all four: **the user's own dotfiles are never modified** — the +//! Across all of them: **the user's own dotfiles are never modified** — the //! mechanisms above only affect shells tty7 itself launches. +//! +//! All of the above configure a *local* process spawn. Native-SSH panes have no +//! spawn to configure — only the command string an `exec` channel request +//! carries — so they take a different route to the same place: probe the remote +//! for its login shell, then send a script that recreates these very files on +//! the remote side and `exec`s through them. See [`remote`]. +//! +//! **cmd** stays unintegrated by design, not omission. It exposes exactly one +//! hook, the `PROMPT` env var, which can emit the `A`/`B` marks but not `C` or +//! `D`: it has no preexec/postexec, and `PROMPT` is expanded when it is *set*, +//! so even `%ERRORLEVEL%` is out of reach. Since only `C` clears `at_prompt` +//! (see `pane::handle_osc133`), an A/B-only shell would leave the line editor +//! owning the keyboard for the whole of every command — worse than no +//! integration at all. +//! +//! The install-guard sentinel (`TTY7_SHELL_INTEGRATION`, see [`setup`]) does +//! not cross into WSL, and deliberately isn't listed in `WSLENV`: only vars +//! named there cross, so a distro shell always starts with it unset — which is +//! correct, since it *is* a fresh top-level interactive shell. Its own +//! descendants inside the distro then see the `1` it exports, as on any Linux. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -260,7 +288,36 @@ if [[ $- == *i* ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then fi } # Escape literal `%` as %25 — the daemon percent-decodes the OSC 7 payload. - __tty7_report_cwd() { builtin printf '\e]7;file://%s%s\a' "${HOSTNAME:-localhost}" "${PWD//\%/%25}"; } + # + # Under Git Bash (msys) `$PWD` is an msys path — `/c/Users/x`, and `/tmp` for + # mounts with no drive at all. The daemon runs Windows-side, where `/c/Users/x` + # is not absolute but *drive-relative*, so it resolves to a bogus `C:\c\Users\x` + # (see `pane::strip_uri_drive_slash`, which only un-prefixes `/C:/…`). `pwd -W` + # is msys's own translation to the real Windows path, and it resolves mounts + # that have a real backing directory (`/tmp` -> `C:/Users/x/AppData/Local/Temp`). + # It has no leading slash, so add one to make it the absolute-path shape a + # file: URI expects. + # + # For msys-only virtual mounts (`/proc`, `/dev`) there *is* no Windows path + # and `pwd -W` is the identity, so require a drive letter and stay silent + # otherwise — a `/proc` payload would land as drive-relative `C:\proc` and + # fail the next spawn, whereas reporting nothing leaves the daemon holding + # the last usable cwd. Testing the shape beats testing for a leading slash, + # which cannot tell a translated path from an untranslated one. + # + # The branch is resolved once at install time; the `$(…)` inside still forks + # per prompt (~6.8 ms under msys, vs ~0.3 ms for the plain `$PWD` path), which + # is the price of a correct path and only paid by Git Bash panes. + if [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* ]]; then + __tty7_report_cwd() { + local d + d="$(builtin pwd -W 2>/dev/null)" || return 0 + [[ "$d" == ?:* ]] || return 0 + builtin printf '\e]7;file://%s/%s\a' "${HOSTNAME:-localhost}" "${d//\%/%25}" + } + else + __tty7_report_cwd() { builtin printf '\e]7;file://%s%s\a' "${HOSTNAME:-localhost}" "${PWD//\%/%25}"; } + fi # Own hook for D, prepended to precmd_functions (same rationale as the zsh # path): the app flips back to prompt-editing on D, so it must fire the @@ -666,13 +723,20 @@ pub struct Injection { pub env: HashMap, /// Extra argv entries to append after the program (e.g. bash's /// `--rcfile `, fish's `-C ;[;]u`, and the IME path terminates in +/// `write_gap_text`, which writes raw UTF-8 with no Kitty awareness. Under that +/// mode text keys must stay on the [`keystroke_to_bytes`] path so they get +/// encoded. Disambiguate-only sessions are unaffected — [`encode_kitty`] +/// declines unmodified text keys there, so the IME route is equivalent. +/// +/// Compiled under `test` on every platform so the routing rule is covered by +/// CI everywhere, not just on the macOS runner. +#[cfg(any(target_os = "macos", test))] +pub(super) fn defer_to_ime(ks: &gpui::Keystroke, kitty: KittyFlags) -> bool { + if kitty.report_all_keys { + return false; + } + let m = &ks.modifiers; + if m.control || m.platform || m.function || m.alt { + return false; + } + ks.key_char + .as_deref() + .is_some_and(|ch| !ch.is_empty() && ch.chars().all(|c| c >= '\u{20}' && c != '\u{7f}')) +} + /// Translate a GPUI keystroke into the bytes a PTY expects. /// /// When the app has enabled the Kitty keyboard protocol (`kitty.active()`) we try @@ -522,7 +567,26 @@ impl InputHandler for TerminalInputHandler { None } - fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, _cx: &mut App) -> bool { + fn apple_press_and_hold_enabled(&mut self) -> bool { + // A terminal wants auto-repeat, not the accent palette: holding `j` in + // vim scrolls, it does not offer `ĵ`. This used to be moot because + // `on_key_down` consumed printable keys before gpui consulted it; now + // that text defers to the IME (see `defer_to_ime`), gpui reaches its + // held-key branch, and answering `false` there makes it repeat the + // character instead of handing the key to press-and-hold. + false + } + + fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, cx: &mut App) -> bool { + // REPORT_ALL_KEYS_AS_ESC wants every key as `CSI ;[;]u`, + // which only `keystroke_to_bytes` produces — the IME path commits raw + // UTF-8. Keep printable keys on the dispatch path so they get encoded, + // matching the same gate in `on_key_down`. CJK composition and "escape + // every key" are mutually exclusive by construction; an app that asks + // for the latter gets it. + if self.view.read(cx).kitty_flags().report_all_keys { + return false; + } // While a multi-key keybinding is mid-sequence — e.g. the tmux preset's // `ctrl-b` prefix is held pending — the next key belongs to the keymap, // not the IME. macOS otherwise diverts printable keys straight to the IME @@ -554,9 +618,29 @@ impl InputHandler for TerminalInputHandler { #[cfg(test)] mod tests { - use super::{KittyFlags, keystroke_to_bytes, reshape_option_keystroke, tab_bytes}; + use super::{ + KittyFlags, defer_to_ime, keystroke_to_bytes, reshape_option_keystroke, tab_bytes, + }; use gpui::{Keystroke, Modifiers}; + /// Kitty full mode: every key escaped, with the produced text attached. + fn full_mode() -> KittyFlags { + KittyFlags { + disambiguate: true, + report_all_keys: true, + report_text: true, + } + } + + /// Level 1 only — the mode a shell leaves on after a TUI exits. + fn disambiguate_only() -> KittyFlags { + KittyFlags { + disambiguate: true, + report_all_keys: false, + report_text: false, + } + } + /// The legacy call shape used by the pre-existing tests: encode with the Kitty /// protocol off, exercising exactly the byte output shells see by default. fn legacy(ks: &Keystroke) -> Option> { @@ -571,6 +655,73 @@ mod tests { } } + #[test] + fn plain_text_defers_to_the_ime_unless_kitty_wants_every_key() { + let plain = Modifiers::default(); + let a = ks(plain, "a", Some("a")); + + // Default and disambiguate-only: text belongs to the IME, which is the + // only path that carries a synthesized event's real Unicode payload. + assert!(defer_to_ime(&a, KittyFlags::default())); + assert!(defer_to_ime(&a, disambiguate_only())); + + // Full mode: the IME commits raw UTF-8, so deferring would drop the + // `CSI 97;1;97u` the app negotiated for. Stay on the encoder path. + assert!(!defer_to_ime(&a, full_mode())); + assert_eq!( + keystroke_to_bytes(&a, full_mode()), + Some(b"\x1b[97;1;97u".to_vec()), + ); + + // Space is text too, and follows the same rule. + let space = ks(plain, "space", Some(" ")); + assert!(defer_to_ime(&space, KittyFlags::default())); + assert!(!defer_to_ime(&space, full_mode())); + } + + #[test] + fn shifted_text_follows_the_same_ime_rule() { + let shift = Modifiers { + shift: true, + ..Default::default() + }; + let upper = ks(shift, "a", Some("A")); + assert!(defer_to_ime(&upper, KittyFlags::default())); + assert!(!defer_to_ime(&upper, full_mode())); + } + + #[test] + fn non_text_keys_never_defer_to_the_ime() { + let plain = Modifiers::default(); + // No `key_char` at all — arrows, F-keys, backspace, escape. + assert!(!defer_to_ime( + &ks(plain, "left", None), + KittyFlags::default() + )); + assert!(!defer_to_ime( + &ks(plain, "backspace", None), + KittyFlags::default() + )); + // Control chars are filtered even when a `key_char` is present. + assert!(!defer_to_ime( + &ks(plain, "enter", Some("\n")), + KittyFlags::default() + )); + assert!(!defer_to_ime( + &ks(plain, "tab", Some("\t")), + KittyFlags::default() + )); + // Chords belong to the encoders, not the IME. + let ctrl = Modifiers { + control: true, + ..Default::default() + }; + assert!(!defer_to_ime( + &ks(ctrl, "c", Some("c")), + KittyFlags::default() + )); + } + #[test] fn keystroke_to_bytes_maps_control_letters() { let ctrl = Modifiers { diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index db252d5a..94bda0fe 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -35,6 +35,7 @@ mod reverse_search; pub mod search; mod signature; mod size; +mod smart_select; mod typeahead; pub mod view; diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index ea79dc93..2fd1d2d3 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -100,6 +100,14 @@ struct ShellState { /// back) from the stale pre-submit state — even when 1 Hz polling misses /// the intermediate not-at-prompt window of a fast command. seq: u64, + /// Monotonic count of *entered-prompt edges*: bumped only when a report + /// flips `at_prompt` false → true. Unlike `seq` it ignores same-prompt + /// redraws — prompt frameworks re-emit the PS1-embedded `133;B` on every + /// `reset-prompt` / completion-list reprint, and each re-emission is + /// another `Prompt` frame. The Tab handoff keys its release off this + /// (see `TerminalView::editor_handoff`): only a command actually running + /// (`133;C` → not-at-prompt) starts a new cycle. + cycle: u64, } /// The shared handles the reader thread writes into as daemon frames arrive; @@ -670,6 +678,8 @@ impl RemoteTerminal { at_prompt, last_exit, seq: guard.seq + 1, + cycle: guard.cycle + + u64::from(at_prompt && !guard.at_prompt), }; } // The shell just reported a fresh prompt, so at @@ -702,6 +712,17 @@ impl RemoteTerminal { } DaemonMsg::RemoteContext(ctx) => { flush_batch!(); + // Crossing the local/remote boundary invalidates + // the cwd: it names a directory in the namespace + // we just left. Drop it so the pane reports none + // until the new shell's OSC 7 lands — otherwise + // an `exit` from `ssh` leaves the remote's last + // path in place, and a local shell without shell + // integration never overwrites it, so the local + // `git` probe keeps running against it. + if let Ok(mut guard) = cwd.lock() { + *guard = None; + } if let Ok(mut guard) = remote.lock() { *guard = ctx; } @@ -933,6 +954,13 @@ impl RemoteTerminal { self.shell_state.lock().map(|s| s.seq).unwrap_or(0) } + /// Monotonic count of entered-prompt edges — see [`ShellState::cycle`]. + /// Stable across same-prompt redraws (which bump `seq` but not this); + /// only leaving the prompt for a command and coming back advances it. + pub fn prompt_cycle(&self) -> u64 { + self.shell_state.lock().map(|s| s.cycle).unwrap_or(0) + } + /// Exit code of the most recently completed foreground command, as sniffed /// from OSC 133;D daemon-side. `None` before any command has finished. pub fn last_exit_code(&self) -> Option { @@ -1587,6 +1615,7 @@ fn terminal_config_from_user(user_config: &crate::core::config::Config) -> Confi Config { scrolling_history: user_config.scrollback_limit, default_cursor_style: alacritty_cursor_style(user_config.cursor_style), + semantic_escape_chars: user_config.word_separators.clone(), ..Config::default() } } @@ -2404,7 +2433,10 @@ mod tests { status: AgentStatus::Waiting, message: Some("Claude needs your permission".into()), session_id: Some("sid-1".into()), + launch_argv: None, rich: true, + cwd: None, + activity: 0, })) .encode(&mut daemon_side) .unwrap(); diff --git a/src/terminal/signature.rs b/src/terminal/signature.rs index 3c346ddb..86b77bef 100644 --- a/src/terminal/signature.rs +++ b/src/terminal/signature.rs @@ -125,6 +125,13 @@ impl Arg { .iter() .any(|t| t == "filepaths" || t == "folders") } + + /// Whether this arg's filesystem completion is directories only — a Fig + /// `folders` template with no `filepaths` alongside it. + pub fn wants_dirs_only(&self) -> bool { + self.template.iter().any(|t| t == "folders") + && !self.template.iter().any(|t| t == "filepaths") + } } /// A static value suggestion for an argument. diff --git a/src/terminal/smart_select.rs b/src/terminal/smart_select.rs new file mode 100644 index 00000000..d71cbf84 --- /dev/null +++ b/src/terminal/smart_select.rs @@ -0,0 +1,1117 @@ +//! Double-click smart selection (à la iTerm2): when a double-click's +//! plain word selection sits inside a larger semantic object — a URL, an +//! email address, a file path, a matching bracket pair, or an OSC 8 +//! hyperlink — expand the selection to cover the whole object. +//! +//! The expansion is strictly additive: a candidate is only applied when it +//! *contains* the plain word the double-click would have selected, so the +//! feature can never shrink a selection below what alacritty's semantic +//! (word) selection yields. With no candidate the caller falls back to the +//! stock `SelectionType::Semantic` behavior unchanged. + +use std::sync::OnceLock; + +use alacritty_terminal::event::EventListener; +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::index::{Column, Line, Point}; +use alacritty_terminal::term::Term; +use alacritty_terminal::term::cell::Flags; +use regex::Regex; + +/// How many soft-wrapped rows to join on each side of the clicked row when +/// reconstructing the logical line. Caps the text a pathological fully-wrapped +/// scrollback line (minified JS piped to `cat`) can feed the regexes. +const MAX_WRAP_ROWS: usize = 32; + +/// How many chars around the click offset the regex window covers on each +/// side. Matches never straddle real whitespace anyway, so a bounded window +/// only drops matches on absurdly long unbroken runs. +const MATCH_WINDOW: usize = 2000; + +/// Bracket pairs a double-click on either half expands across (with +/// nesting): the ASCII pairs plus the full-width/CJK ones. +const BRACKET_PAIRS: [(char, char); 15] = [ + ('(', ')'), + ('[', ']'), + ('{', '}'), + ('<', '>'), + ('(', ')'), + ('[', ']'), + ('{', '}'), + ('〈', '〉'), + ('《', '》'), + ('「', '」'), + ('『', '』'), + ('【', '】'), + ('〔', '〕'), + ('“', '”'), + ('‘', '’'), +]; + +/// Symmetric quotes: open and close are the same char, so pairing needs the +/// parity heuristic in [`quote_range`] instead of the bracket scan. +const SYMMETRIC_QUOTES: [char; 3] = ['\'', '"', '`']; + +/// A resolved smart selection: an inclusive grid-point span, plus whether the +/// span is `exact`. Exact spans have endpoints that may sit mid-word-run (CJK +/// prose, a candidate glued to non-separator text), so the caller must select +/// them with `SelectionType::Simple` — a `Semantic` anchor would re-expand the +/// endpoints across the very boundary the smart range established. Non-exact +/// spans end on run boundaries and can keep `Semantic` for word-wise dragging. +pub(super) struct SmartRange { + pub start: Point, + pub end: Point, + pub exact: bool, +} + +/// Resolve a smart selection range for a double-click at `click` (grid +/// coordinates). `None` means "no candidate beats the plain word" and the +/// caller should keep the stock semantic selection. +pub(super) fn grid_smart_range( + term: &Term, + click: Point, +) -> Option { + // 1) An explicit OSC 8 hyperlink run wins outright — the program told us + // the exact extent, no guessing needed. + if let Some((start, end)) = hyperlink_run(term, click) { + return Some(SmartRange { + start, + end, + exact: true, + }); + } + + let (text, points, click_idx) = logical_line_at(term, click)?; + let chars: Vec = text.chars().collect(); + let separators = term.semantic_escape_chars(); + // A span whose flanks are separator chars ends exactly where alacritty's + // semantic re-expansion would stop anyway; anything else must stay exact. + // Only the separator set counts here — alacritty stops at nothing else, + // so a flank of e.g. U+3000 ideographic space would still re-expand. + let resolved = |s: usize, e: usize| SmartRange { + start: points[s], + end: points[e], + exact: !(s == 0 || separators.contains(chars[s - 1])) + || !(e + 1 == chars.len() || separators.contains(chars[e + 1])), + }; + + // 2) Double-click on a bracket or quote selects through its match. + if let Some((s, e)) = pair_range(&chars, click_idx) { + return Some(resolved(s, e)); + } + + // 3) CJK prose has no separators to walk — the whole clause is one run — + // so segment it with a dictionary instead of selecting the entire + // unbroken run. No segmenter available means the run stands as-is. + if is_cjk(chars[click_idx]) + && let Some((s, e)) = cjk_word_range(&text, click_idx) + { + return Some(resolved(s, e)); + } + + // 4) URL / email / path / identifier patterns around the click. + let (s, e) = smart_range(&text, &chars, click_idx, separators)?; + Some(resolved(s, e)) +} + +/// Whether a char belongs to a CJK script (Han, Kana, Hangul, or the +/// full-width/CJK punctuation blocks) — text whose words aren't delimited by +/// whitespace or the separator set. +pub(super) fn is_cjk(c: char) -> bool { + matches!( + u32::from(c), + 0x1100..=0x11FF // Hangul Jamo + | 0x2E80..=0x9FFF // CJK radicals, punctuation, Kana, ideographs + | 0xAC00..=0xD7AF // Hangul syllables + | 0xF900..=0xFAFF // CJK compatibility ideographs + | 0xFF00..=0xFFEF // full-width forms + | 0x20000..=0x3134F // ideograph extensions + ) +} + +/// Kana or Hangul — the scripts jieba has no dictionary for. A run holding +/// either is left unsegmented rather than handed to jieba, which shreds it +/// into single characters (`です` → `で` `す`); selecting the whole run is the +/// friendlier failure. +#[cfg(not(target_os = "macos"))] +fn is_kana_or_hangul(c: char) -> bool { + matches!( + u32::from(c), + 0x1100..=0x11FF // Hangul Jamo + | 0x3040..=0x30FF // Hiragana + Katakana + | 0x31F0..=0x31FF // Katakana phonetic extensions + | 0xA960..=0xA97F // Hangul Jamo Extended-A + | 0xAC00..=0xD7FF // Hangul syllables + Jamo Extended-B + | 0xFF66..=0xFF9F // half-width Katakana + ) +} + +/// The jieba segmenter, built once on a background thread. The table costs +/// ~55 MB resident and ~130 ms to build, so it is constructed only if a CJK +/// double-click actually happens — see [`jieba_word_range`]. +#[cfg(not(target_os = "macos"))] +static JIEBA: OnceLock = OnceLock::new(); + +/// Kick off dictionary construction on a background thread (idempotent). +/// Never called eagerly: the first CJK double-click triggers it and settles +/// for the unsegmented run, so the UI thread never blocks on the build. +#[cfg(not(target_os = "macos"))] +fn warm() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::thread::spawn(|| { + let _ = JIEBA.get_or_init(jieba_rs::Jieba::new); + }); + }); +} + +/// Dictionary-based word bounds for CJK text: the inclusive char range of the +/// word containing char index `click`, or `None` to keep the whole run. +/// +/// The OS tokenizer wins wherever there is one. macOS's CFStringTokenizer +/// carries a Chinese lexicon that matches jieba on most prose, is locale- +/// independent, handles Japanese and Korean properly, and costs nothing — +/// jieba is only worth its ~55 MB on platforms with no such API. +pub(super) fn cjk_word_range(text: &str, click: usize) -> Option<(usize, usize)> { + #[cfg(target_os = "macos")] + { + tokenizer::word_range(text, click) + } + #[cfg(not(target_os = "macos"))] + { + let chars: Vec = text.chars().collect(); + chars.get(click)?; + jieba_word_range(&chars, click) + } +} + +/// Segment the contiguous CJK run around `click` with jieba and return the +/// token containing it. `None` — meaning "select the whole run" — when the +/// dictionary isn't built yet or the run isn't Chinese. +#[cfg(not(target_os = "macos"))] +fn jieba_word_range(chars: &[char], click: usize) -> Option<(usize, usize)> { + let mut rs = click; + while rs > 0 && is_cjk(chars[rs - 1]) { + rs -= 1; + } + let mut re = click; + while re + 1 < chars.len() && is_cjk(chars[re + 1]) { + re += 1; + } + // Japanese/Korean: jieba's Chinese dictionary would cut the run into + // single characters, which is worse than not segmenting at all. + if chars[rs..=re].iter().copied().any(is_kana_or_hangul) { + return None; + } + // Building the table takes ~130 ms — far too long to hold the UI thread + // on a click. Start it in the background and let this one click select + // the whole run; every later click finds the table ready. + let Some(jieba) = JIEBA.get() else { + warm(); + return None; + }; + let run: String = chars[rs..=re].iter().collect(); + // Token start/end are Unicode char offsets into `run`. + let rel = click - rs; + jieba + .cut(&run, true) + .iter() + .find(|tok| rel < tok.end) + .map(|tok| (rs + tok.start, rs + tok.end - 1)) +} + +/// CFStringTokenizer FFI. The tokenizer functions aren't wrapped by the +/// `core-foundation` crate, so declare them directly against its types. +#[cfg(target_os = "macos")] +mod tokenizer { + use core_foundation::base::{CFIndex, CFRange, TCFType}; + use core_foundation::string::{CFString, CFStringRef}; + use std::os::raw::c_void; + + type CFStringTokenizerRef = *mut c_void; + type CFLocaleRef = *const c_void; + + /// `kCFStringTokenizerUnitWordBoundary`: every position belongs to a + /// token (words, punctuation runs, whitespace runs alike), which is the + /// double-click contract. + const UNIT_WORD_BOUNDARY: u64 = 4; + + unsafe extern "C" { + fn CFStringTokenizerCreate( + alloc: *const c_void, + string: CFStringRef, + range: CFRange, + options: u64, + locale: CFLocaleRef, + ) -> CFStringTokenizerRef; + fn CFStringTokenizerGoToTokenAtIndex( + tokenizer: CFStringTokenizerRef, + index: CFIndex, + ) -> u64; + fn CFStringTokenizerGetCurrentTokenRange(tokenizer: CFStringTokenizerRef) -> CFRange; + fn CFLocaleCopyCurrent() -> CFLocaleRef; + fn CFRelease(cf: *const c_void); + } + + /// Inclusive char range of the token containing char index `click`. + /// CFString ranges are UTF-16 code-unit offsets, so map through a + /// per-char offset table both ways. + pub(super) fn word_range(text: &str, click: usize) -> Option<(usize, usize)> { + let mut u16_of: Vec = Vec::new(); + let mut total: CFIndex = 0; + for c in text.chars() { + u16_of.push(total); + total += c.len_utf16() as CFIndex; + } + let click_u16 = *u16_of.get(click)?; + + let cf = CFString::new(text); + let range = unsafe { + let locale = CFLocaleCopyCurrent(); + let tok = CFStringTokenizerCreate( + std::ptr::null(), + cf.as_concrete_TypeRef(), + CFRange::init(0, total), + UNIT_WORD_BOUNDARY, + locale, + ); + let token_type = CFStringTokenizerGoToTokenAtIndex(tok, click_u16); + let range = (token_type != 0).then(|| CFStringTokenizerGetCurrentTokenRange(tok)); + CFRelease(tok); + if !locale.is_null() { + CFRelease(locale); + } + range? + }; + if range.location < 0 || range.length <= 0 { + return None; + } + let start = u16_of.binary_search(&range.location).ok()?; + let end = u16_of.partition_point(|&v| v < range.location + range.length) - 1; + (start <= click && click <= end).then_some((start, end)) + } +} + +/// The contiguous run of cells carrying the same OSC 8 hyperlink URI as the +/// clicked cell, following soft wraps in both directions (a long link wraps +/// across rows; stopping at the row edge would truncate the selection). +fn hyperlink_run(term: &Term, click: Point) -> Option<(Point, Point)> { + let grid = term.grid(); + let cols = term.columns(); + if click.column.0 >= cols { + return None; + } + let uri = grid[click.line][click.column] + .hyperlink()? + .uri() + .to_string(); + let same = |p: Point| { + grid[p.line][p.column] + .hyperlink() + .is_some_and(|h| h.uri() == uri) + }; + let wraps = |line: Line| grid[line][Column(cols - 1)].flags.contains(Flags::WRAPLINE); + let top = term.topmost_line(); + let bottom = term.bottommost_line(); + + let mut start = click; + let mut rows = 0; + loop { + let prev = if start.column.0 > 0 { + Point::new(start.line, Column(start.column.0 - 1)) + } else if start.line > top && rows < MAX_WRAP_ROWS && wraps(start.line - 1) { + rows += 1; + Point::new(start.line - 1, Column(cols - 1)) + } else { + break; + }; + if !same(prev) { + break; + } + start = prev; + } + let mut end = click; + rows = 0; + loop { + let next = if end.column.0 + 1 < cols { + Point::new(end.line, Column(end.column.0 + 1)) + } else if end.line < bottom && rows < MAX_WRAP_ROWS && wraps(end.line) { + rows += 1; + Point::new(end.line + 1, Column(0)) + } else { + break; + }; + if !same(next) { + break; + } + end = next; + } + Some((start, end)) +} + +/// Reconstruct the logical (soft-wrap-joined) line containing `click`: +/// the text with wide-char spacers dropped, a per-char grid point, and the +/// char index the click landed on. `None` when the click maps to no char +/// (out-of-bounds column). +fn logical_line_at( + term: &Term, + click: Point, +) -> Option<(String, Vec, usize)> { + let cols = term.columns(); + if click.column.0 >= cols { + return None; + } + let grid = term.grid(); + let last_col = Column(cols - 1); + let wraps = |line: Line| grid[line][last_col].flags.contains(Flags::WRAPLINE); + + let mut start_line = click.line; + let top = term.topmost_line(); + let mut guard = 0; + while start_line > top && guard < MAX_WRAP_ROWS && wraps(start_line - 1) { + start_line -= 1; + guard += 1; + } + let mut end_line = click.line; + let bottom = term.bottommost_line(); + guard = 0; + while end_line < bottom && guard < MAX_WRAP_ROWS && wraps(end_line) { + end_line += 1; + guard += 1; + } + + let mut text = String::new(); + let mut points = Vec::new(); + let mut click_idx = None; + let mut line = start_line; + while line <= end_line { + for col in 0..cols { + let cell = &grid[line][Column(col)]; + let p = Point::new(line, Column(col)); + // Spacer cells pad wide (CJK/emoji) glyphs. A trailing spacer + // follows its wide char; a leading spacer pads the end of a row + // whose wide char wrapped to the next row, so it belongs to the + // *next* pushed char. + if cell.flags.contains(Flags::LEADING_WIDE_CHAR_SPACER) { + if p == click { + click_idx = Some(points.len()); + } + continue; + } + if cell.flags.contains(Flags::WIDE_CHAR_SPACER) { + if p == click && !points.is_empty() { + click_idx = Some(points.len() - 1); + } + continue; + } + if p == click { + click_idx = Some(points.len()); + } + text.push(cell.c); + points.push(p); + } + line += 1; + } + // A leading spacer at the very end of the collected range can point one + // past the last char; treat that as no hit. + let click_idx = click_idx.filter(|&i| i < points.len())?; + Some((text, points, click_idx)) +} + +/// Double-click on a paired delimiter — bracket or quote — selects through +/// its match. `None` when the clicked char is neither, or has no match on +/// the logical line. +pub(super) fn pair_range(chars: &[char], click: usize) -> Option<(usize, usize)> { + bracket_range(chars, click).or_else(|| quote_range(chars, click)) +} + +/// Whether the `'` at `i` is a contraction apostrophe rather than a quote. +/// +/// A delimiter has whitespace, punctuation, or a line edge on at least one +/// side; a contraction is welded into a word on both (`it's`, `isn't`, +/// `won't`). Only `'` needs this — `"` and `` ` `` don't appear inside words. +fn is_contraction(chars: &[char], i: usize) -> bool { + if chars[i] != '\'' { + return false; + } + let flanked = |j: Option| { + j.and_then(|j| chars.get(j)) + .is_some_and(|c| c.is_alphanumeric()) + }; + flanked(i.checked_sub(1)) && flanked(Some(i + 1)) +} + +/// Select through a matching symmetric quote (`'`, `"`, `` ` ``). Open and +/// close are the same char, so direction comes from parity: an even count of +/// that quote before the click means it opens (match forward), odd means it +/// closes (match backward). +/// +/// Contraction apostrophes are excluded throughout — clicking one falls +/// through to the stock word, and they count neither toward the parity nor as +/// a candidate match. Without that, `it's a test, isn't it` pairs the two +/// contractions and a double-click on either selects `'s a test, isn'`. This +/// path returns before the `extends` guard that keeps other candidates +/// additive (see [`pair_is_plausible`]), so a bad match here has no safety net. +pub(super) fn quote_range(chars: &[char], click: usize) -> Option<(usize, usize)> { + let q = *chars.get(click)?; + if !SYMMETRIC_QUOTES.contains(&q) || is_contraction(chars, click) { + return None; + } + let quote_at = |i: usize| chars[i] == q && !is_contraction(chars, i); + let before = (0..click).filter(|&i| quote_at(i)).count(); + if before % 2 == 0 { + let close = (click + 1..chars.len()).find(|&i| quote_at(i))?; + Some((click, close)) + } else { + let open = (0..click).rev().find(|&i| quote_at(i))?; + Some((open, click)) + } +} + +/// Whether a candidate span is an acceptable match for its bracket pair. +/// +/// Every pair but `<>` is accepted outright — `( a )` is a legitimate subshell, +/// `[ 1 ]` a legitimate index. `<` and `>` are different: they are comparison +/// and redirection operators at least as often as delimiters, and the bracket +/// path returns before the `extends` guard that keeps every other candidate +/// additive, so a bad match here has no safety net. Require the span to hug its +/// contents, which real delimiters do (`Vec`, `
`, ``, +/// ``) and a comparison doesn't (`a < b > c`, `x <= 0 || y > 9`). +/// +/// Redirections need no special handling: `2>&1` or `cmd > out` have no +/// partner to match, so the scan already fails. +fn pair_is_plausible(chars: &[char], open: char, s: usize, e: usize) -> bool { + if open != '<' { + return true; + } + e > s + 1 && !chars[s + 1].is_whitespace() && !chars[e - 1].is_whitespace() +} + +/// Select through a matching bracket: `click` on an opener scans forward, +/// on a closer scans backward, nesting-aware. Inclusive char range covering +/// both brackets, or `None` when the clicked char isn't a bracket, the match +/// isn't on the logical line, or the span fails [`pair_is_plausible`]. +pub(super) fn bracket_range(chars: &[char], click: usize) -> Option<(usize, usize)> { + let c = *chars.get(click)?; + if let Some((open, close)) = BRACKET_PAIRS.iter().find(|(o, _)| *o == c) { + let mut depth = 0usize; + for (i, &ch) in chars.iter().enumerate().skip(click) { + if ch == *open { + depth += 1; + } else if ch == *close { + depth -= 1; + if depth == 0 { + return pair_is_plausible(chars, *open, click, i).then_some((click, i)); + } + } + } + return None; + } + if let Some((open, close)) = BRACKET_PAIRS.iter().find(|(_, c2)| *c2 == c) { + let mut depth = 0usize; + for i in (0..=click).rev() { + let ch = chars[i]; + if ch == *close { + depth += 1; + } else if ch == *open { + depth -= 1; + if depth == 0 { + return pair_is_plausible(chars, *open, i, click).then_some((i, click)); + } + } + } + } + None +} + +/// Patterns tried in specificity order after the URL detector: email, +/// scientific-notation number, file path, dotted/hyphenated identifier. +/// (URLs go through `search::url_span_at` first — it handles scheme +/// detection, wrapper stripping and trailing-punctuation trimming better +/// than a lone regex.) +fn regexes() -> &'static [Regex] { + static RE: OnceLock> = OnceLock::new(); + RE.get_or_init(|| { + [ + // Email address. + r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + // Scientific notation (6.02e+23). + r"\b[0-9]+(?:\.[0-9]+)?[eE][+-]?[0-9]+\b", + // File path: at least two segments, or ~/.-anchored. + r"[A-Za-z0-9._+@%~-]*(?:/[A-Za-z0-9._+@%~-]+)+/?", + // Identifier chained with `.`/`-` (foo-bar.baz, 10.0.0.1). + // ASCII classes only: the regex crate's `\w` matches Han + // ideographs, which would swallow CJK text glued to a Latin + // word and defeat the script narrowing. + r"[0-9A-Za-z_]+(?:[.-][0-9A-Za-z_]+)*", + ] + .iter() + .map(|p| Regex::new(p).expect("static smart-select regex")) + .collect() + }) +} + +/// Find a semantic object containing char index `click` in `text` that +/// strictly extends the plain word selection the configured `separators` +/// would produce. Inclusive char range, or `None` to keep the stock word. +pub(super) fn smart_range( + text: &str, + chars: &[char], + click: usize, + separators: &str, +) -> Option<(usize, usize)> { + if click >= chars.len() || chars[click].is_whitespace() { + return None; + } + + // The plain word the double-click would select: the run of chars around + // the click that are neither whitespace nor configured separators. + // (Mirrors alacritty's semantic expansion over the same separator set.) + let boundary = |c: char| c.is_whitespace() || separators.contains(c); + let (mut pws, mut pwe) = (click, click); + if !boundary(chars[click]) { + while pws > 0 && !boundary(chars[pws - 1]) { + pws -= 1; + } + while pwe + 1 < chars.len() && !boundary(chars[pwe + 1]) { + pwe += 1; + } + } + // CJK chars/punctuation glue onto Latin runs (`分支name,已` is one + // separator-free run), so the word the user *means* is the same-script + // sub-run around the click. Candidates are judged against that; if + // nothing beats it, the narrowed run itself is the answer. + let (ws, we) = narrow_to_script(chars, click, pws, pwe); + // Applied only when the candidate strictly contains the (narrowed) word, + // so smart select can grow the meant word but never shrink it. + let extends = |s: usize, e: usize| s <= ws && e >= we && (s < ws || e > we); + + if let Some((s, e, _url)) = super::search::url_span_at(text, click) + && extends(s, e) + { + return Some((s, e)); + } + + // Regexes run over a bounded byte window around the click. + let byte_of: Vec = text.char_indices().map(|(b, _)| b).collect(); + let w_start = click.saturating_sub(MATCH_WINDOW); + let w_end = (click + MATCH_WINDOW).min(chars.len() - 1); + let wb_start = byte_of[w_start]; + let wb_end = byte_of[w_end] + chars[w_end].len_utf8(); + let window = &text[wb_start..wb_end]; + let click_byte = byte_of[click] - wb_start; + + for re in regexes() { + let Some(m) = re + .find_iter(window) + .find(|m| m.range().contains(&click_byte)) + else { + continue; + }; + let s = text[..wb_start + m.start()].chars().count(); + let e = text[..wb_start + m.end()].chars().count() - 1; + if extends(s, e) { + return Some((s, e)); + } + } + // No pattern beat the meant word — but if script narrowing shrank the + // raw run (Latin word glued to CJK text), that narrowed word *is* the + // correction. + ((ws, we) != (pws, pwe)).then_some((ws, we)) +} + +/// Shrink the inclusive run `[lo, hi]` to the chars sharing `click`'s script +/// class (CJK vs not) — the sub-run a double-click on mixed-script text +/// means. A no-op on single-script runs. +pub(super) fn narrow_to_script( + chars: &[char], + click: usize, + lo: usize, + hi: usize, +) -> (usize, usize) { + let class = is_cjk(chars[click]); + let mut s = click; + while s > lo && is_cjk(chars[s - 1]) == class { + s -= 1; + } + let mut e = click; + while e < hi && is_cjk(chars[e + 1]) == class { + e += 1; + } + (s, e) +} + +#[cfg(test)] +mod tests { + use super::*; + use alacritty_terminal::event::VoidListener; + + /// alacritty's stock separator set, which is also the config default. + const SEPS: &str = ",│`|:\"' ()[]{}<>\t"; + + /// In production the jieba table builds lazily off-thread and the racing + /// click settles for the whole run; tests want it ready up front. No-op on + /// macOS, where CFStringTokenizer needs no warm-up. + fn ensure_segmenter() { + #[cfg(not(target_os = "macos"))] + let _ = JIEBA.get_or_init(jieba_rs::Jieba::new); + } + + fn range(text: &str, click: usize) -> Option<(usize, usize)> { + let chars: Vec = text.chars().collect(); + smart_range(text, &chars, click, SEPS) + } + + // ---- Grid-level tests ---- + // + // The functions above operate on a plain `&str`; everything below drives a + // real `Term` through the VT parser instead, because the grid is where the + // index arithmetic actually gets hard: wide CJK glyphs occupy two cells + // (the second a spacer), soft-wrapped rows have to be stitched back into + // one logical line, and OSC 8 runs can straddle both. + + /// A `cols`×`rows` terminal with `input` fed through the VT parser, so the + /// grid holds exactly what a PTY would have produced. + fn term_with(cols: usize, rows: usize, input: &str) -> Term { + let config = alacritty_terminal::term::Config { + semantic_escape_chars: SEPS.to_string(), + ..Default::default() + }; + let mut term = Term::new( + config, + &crate::terminal::size::TermSize::new(cols, rows), + VoidListener, + ); + let mut parser: alacritty_terminal::vte::ansi::Processor = + alacritty_terminal::vte::ansi::Processor::new(); + parser.advance(&mut term, input.as_bytes()); + term + } + + /// The text a double-click at `(line, col)` would select, or `None` when + /// no smart candidate applies and the caller keeps the stock word. + fn grid_select(term: &Term, line: i32, col: usize) -> Option { + let r = grid_smart_range(term, Point::new(Line(line), Column(col)))?; + Some(term.bounds_to_string(r.start, r.end)) + } + + /// Column of the first occurrence of `needle` on row 0 — keeps the tests + /// from hard-coding offsets that shift when the fixture text changes. + fn col_of(row: &str, needle: &str) -> usize { + row.find(needle).expect("needle in fixture") + } + + #[test] + fn osc8_hyperlink_selects_the_declared_extent_not_the_visible_word() { + // The link text has a space in it: only the OSC 8 run knows where the + // link really ends, which is the whole point of checking it first. + let term = term_with( + 40, + 3, + "go \x1b]8;;https://example.com/x\x1b\\click here\x1b]8;;\x1b\\ now", + ); + let line = "go click here now"; + assert_eq!( + grid_select(&term, 0, col_of(line, "here")).as_deref(), + Some("click here"), + ); + // A cell outside the run must not pick the link up. + assert_ne!( + grid_select(&term, 0, col_of(line, "now")).as_deref(), + Some("click here"), + ); + } + + #[test] + fn osc8_hyperlink_follows_a_soft_wrap() { + // 30 chars of link text in 20 columns: the run fills row 0 and spills + // 10 cells onto row 1. Stopping at the row edge would truncate the + // selection to the visible first half. + let term = term_with( + 20, + 4, + "\x1b]8;;https://e.com\x1b\\aaaaaaaaaabbbbbbbbbbcccccccccc\x1b]8;;\x1b\\", + ); + let whole = "aaaaaaaaaabbbbbbbbbbcccccccccc"; + // Click on the wrapped remainder (row 1) — walks backwards over the wrap. + assert_eq!(grid_select(&term, 1, 2).as_deref(), Some(whole)); + // ...and from the first row, walking forwards over it. + assert_eq!(grid_select(&term, 0, 3).as_deref(), Some(whole)); + } + + #[test] + fn soft_wrapped_url_is_stitched_back_into_one_selection() { + // No OSC 8 here — the URL is recovered from the joined logical line, + // so this exercises `logical_line_at`'s wrap walk rather than the + // hyperlink path. + let term = term_with(20, 4, "see https://example.com/deep/path here"); + let whole = "https://example.com/deep/path"; + // Row 0 holds "see https://example.", row 1 the "com/deep/path here" + // remainder. Clicking the head joins forwards over the wrap... + assert_eq!( + grid_select(&term, 0, col_of("see https://example", "example")).as_deref(), + Some(whole), + ); + // ...and clicking the tail joins backwards, which is the direction a + // click on a continuation row depends on entirely. + assert_eq!( + grid_select(&term, 1, col_of("com/deep/path here", "deep")).as_deref(), + Some(whole), + ); + } + + #[test] + fn wide_glyph_and_its_spacer_resolve_to_the_same_word() { + // Each Han char occupies two cells; the second carries WIDE_CHAR_SPACER + // and has no `c` of its own. Clicking either half must select the same + // segmented word — an off-by-one in the spacer branch shows up here. + ensure_segmenter(); + let term = term_with(40, 3, "run 北京欢迎你 done"); + // "run " is 4 cells, then 北 at col 4 (spacer at 5), 京 at 6 (spacer 7). + let expected = grid_select(&term, 0, 4); + assert_eq!(expected.as_deref(), Some("北京"), "click on 北"); + assert_eq!( + grid_select(&term, 0, 5).as_deref(), + expected.as_deref(), + "spacer of 北" + ); + assert_eq!( + grid_select(&term, 0, 6).as_deref(), + Some("北京"), + "click on 京" + ); + assert_eq!( + grid_select(&term, 0, 7).as_deref(), + Some("北京"), + "spacer of 京" + ); + } + + #[test] + fn wide_glyph_wrapping_to_the_next_row_keeps_its_word_intact() { + // An odd column count leaves one cell at the end of the row: the wide + // char can't fit, so alacritty pads with LEADING_WIDE_CHAR_SPACER and + // moves the glyph to the next row. The logical line must still join. + ensure_segmenter(); + let term = term_with(9, 4, "abcdefgh北京欢迎你"); + // 北 is pushed to row 1 col 0 by the leading spacer at row 0 col 8. + assert_eq!(grid_select(&term, 1, 0).as_deref(), Some("北京")); + } + + #[test] + fn click_past_the_last_column_yields_no_range() { + let term = term_with(10, 2, "hello"); + assert!(grid_smart_range(&term, Point::new(Line(0), Column(10))).is_none()); + assert!(grid_smart_range(&term, Point::new(Line(0), Column(99))).is_none()); + } + + fn selected(text: &str, click: usize) -> Option { + let chars: Vec = text.chars().collect(); + range(text, click).map(|(s, e)| chars[s..=e].iter().collect()) + } + + #[test] + fn url_expands_past_scheme_colon() { + let text = "fetch https://example.com/a/b?q=1 done"; + // Click inside "example" — the plain word starts after the `:` + // separator; smart select recovers the whole URL. + let click = text.find("example").unwrap(); + assert_eq!( + selected(text, click).as_deref(), + Some("https://example.com/a/b?q=1") + ); + } + + #[test] + fn url_trailing_comma_excluded() { + let text = "see https://a.com/x, then"; + let click = text.find("a.com").unwrap(); + assert_eq!(selected(text, click).as_deref(), Some("https://a.com/x")); + } + + #[test] + fn email_only_fires_when_it_extends_the_word() { + // With the default separators the plain word already covers the whole + // address (`@` and `.` are word chars) — the candidate equals the word + // and must be rejected, keeping the stock selection. + let text = "author:dev@example.com pushed"; + let click = text.find("example").unwrap(); + assert_eq!(range(text, click), None); + // With `@` configured as a separator, the email regex reassembles the + // full address across it. + let chars: Vec = text.chars().collect(); + let got = smart_range(text, &chars, click, ",@:() "); + let (s, e) = got.expect("email should match"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "dev@example.com"); + } + + #[test] + fn plain_word_yields_none() { + let text = "just some words"; + let click = text.find("some").unwrap(); + assert_eq!(range(text, click), None); + } + + #[test] + fn path_across_quote_boundary_stays_plain() { + // The whole path is one plain word already (no separators inside); + // candidates equal to the word are rejected → stock selection. + let text = "cat /usr/local/bin/tool"; + let click = text.find("local").unwrap(); + assert_eq!(range(text, click), None); + } + + #[test] + fn path_glued_to_colon_expands() { + // `error:/tmp/x/y` — the word starts after `:`; the path regex + // must not leak left past the colon but the URL/identifier ones + // must not shrink it either. Path candidate is `/tmp/x/y`, equal + // to the plain word → None. Click on `error` side: word `error`. + let text = "error:/tmp/x/y"; + let click = text.find("tmp").unwrap(); + assert_eq!(range(text, click), None); + } + + #[test] + fn whitespace_click_yields_none() { + assert_eq!(range("a b", 1), None); + } + + #[test] + fn scientific_notation_with_custom_separators() { + // With `.` and `+` configured as separators (finer-grained + // boundaries), the sci-notation regex reassembles the number. + let text = "n = 6.02e+23 mol"; + let chars: Vec = text.chars().collect(); + let click = text.find("02").unwrap(); + let got = smart_range(text, &chars, click, ",.+():"); + let (s, e) = got.expect("sci notation should match"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "6.02e+23"); + } + + #[test] + fn identifier_with_custom_separators() { + // Fine-grained separators split `foo-bar.baz`; the identifier + // regex restores the full dotted chain. + let text = "run foo-bar.baz now"; + let chars: Vec = text.chars().collect(); + let click = text.find("bar").unwrap(); + let got = smart_range(text, &chars, click, ",.-():"); + let (s, e) = got.expect("identifier should match"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "foo-bar.baz"); + } + + #[test] + fn latin_word_glued_directly_to_han_narrows_without_punctuation() { + // No separator or punctuation between the scripts at all — the + // identifier regex must not reassemble the mixed run (`\w` would). + let text = "已合并到main分支"; + let chars: Vec = text.chars().collect(); + let click = chars.iter().position(|&c| c == 'm').unwrap(); + let (s, e) = smart_range(text, &chars, click, SEPS).expect("narrowed word"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "main"); + } + + #[test] + fn latin_word_glued_to_cjk_narrows_to_the_latin_run() { + // `,` and `已` are not separators, so the raw run is + // `worktree-feat-smart-select,已`; the meant word is the Latin part. + let text = "分支 worktree-feat-smart-select,已 rebase"; + let chars: Vec = text.chars().collect(); + let click = chars.iter().position(|&c| c == 'w').unwrap() + 10; + let (s, e) = smart_range(text, &chars, click, SEPS).expect("narrowed word"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "worktree-feat-smart-select"); + } + + #[test] + fn symmetric_quotes_pair_by_parity() { + let chars: Vec = r#"echo 'a,b' "c d" x"#.chars().collect(); + // First ' opens (0 quotes before), second closes. + assert_eq!(quote_range(&chars, 5), Some((5, 9))); + assert_eq!(quote_range(&chars, 9), Some((5, 9))); + // Double quotes pair independently of the single ones. + assert_eq!(quote_range(&chars, 11), Some((11, 15))); + assert_eq!(quote_range(&chars, 15), Some((11, 15))); + // An unmatched opener finds nothing. + let chars: Vec = "say 'oops".chars().collect(); + assert_eq!(quote_range(&chars, 4), None); + // Non-quote chars never match. + assert_eq!(quote_range(&chars, 1), None); + } + + #[test] + fn contraction_apostrophes_do_not_pair() { + let chars: Vec = "it's a test, isn't it".chars().collect(); + // Clicking either contraction falls through to the stock word. + assert_eq!(quote_range(&chars, 2), None); + assert_eq!(quote_range(&chars, 16), None); + // And the whole line yields no smart candidate at all, so the + // double-click keeps alacritty's `it's`. + let text = "it's a test, isn't it"; + assert_eq!(range(text, 2), None); + } + + #[test] + fn contractions_do_not_skew_a_real_quote() { + // The apostrophes inside the quoted span must not flip the parity or + // steal the match from the genuine delimiters. + let chars: Vec = "echo 'it isn't so' done".chars().collect(); + let open = 5; + let close = chars.iter().rposition(|&c| c == '\'').unwrap(); + assert_eq!(quote_range(&chars, open), Some((open, close))); + assert_eq!(quote_range(&chars, close), Some((open, close))); + } + + #[test] + fn trailing_apostrophe_still_closes() { + // `dogs'` — the apostrophe has a word char only on its left, so it is + // a delimiter, not a contraction. + let chars: Vec = "the 'dogs' bark".chars().collect(); + assert_eq!(quote_range(&chars, 4), Some((4, 9))); + assert_eq!(quote_range(&chars, 9), Some((4, 9))); + } + + #[test] + fn directional_cjk_quotes_pair_like_brackets() { + let chars: Vec = "他说“你好”了".chars().collect(); + assert_eq!(bracket_range(&chars, 2), Some((2, 5))); + assert_eq!(bracket_range(&chars, 5), Some((2, 5))); + } + + #[test] + fn fullwidth_brackets_pair() { + let chars: Vec = "说(worktree 分支)好".chars().collect(); + let open = chars.iter().position(|&c| c == '(').unwrap(); + let close = chars.iter().position(|&c| c == ')').unwrap(); + assert_eq!(bracket_range(&chars, open), Some((open, close))); + assert_eq!(bracket_range(&chars, close), Some((open, close))); + let chars: Vec = "书名《三体》完".chars().collect(); + assert_eq!(bracket_range(&chars, 2), Some((2, 5))); + } + + #[test] + fn bracket_forward_and_backward_with_nesting() { + let chars: Vec = "f(a(b)c) x".chars().collect(); + assert_eq!(bracket_range(&chars, 1), Some((1, 7))); + assert_eq!(bracket_range(&chars, 7), Some((1, 7))); + assert_eq!(bracket_range(&chars, 3), Some((3, 5))); + assert_eq!(bracket_range(&chars, 0), None); + } + + #[test] + fn angle_brackets_pair_only_when_they_hug_their_contents() { + // Real delimiters: generics, tags, placeholders, bracketed addresses. + for (text, want) in [ + ("let v: Vec = x", ""), + ("
hi", "
"), + ("usage: tty7 [opts]", ""), + ("From: Jo ok", ""), + ("map: HashMap here", ""), + ] { + let chars: Vec = text.chars().collect(); + let click = chars.iter().position(|&c| c == '<').unwrap(); + let (s, e) = bracket_range(&chars, click).unwrap_or_else(|| panic!("{text}")); + let got: String = chars[s..=e].iter().collect(); + assert_eq!(got, want, "{text}"); + } + // Comparison operators must not pair across half a line — a space just + // inside either end is the tell. + for text in [ + "if a < b then c > d", + "awk '{ if ($1 > 100 && $2 < 5) print }'", + "WHERE a < 10 AND b > 20", + "empty <> pair", + ] { + let chars: Vec = text.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if c == '<' || c == '>' { + assert_eq!(bracket_range(&chars, i), None, "{text} at {i}"); + } + } + } + // Redirections never had a partner to match in the first place. + for text in ["cargo build 2>&1 | tee out", "grep -rn foo src/ > /tmp/o"] { + let chars: Vec = text.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if c == '<' || c == '>' { + assert_eq!(bracket_range(&chars, i), None, "{text} at {i}"); + } + } + } + } + + #[test] + fn unmatched_bracket_yields_none() { + let chars: Vec = "f(a".chars().collect(); + assert_eq!(bracket_range(&chars, 1), None); + } + + #[test] + fn cjk_segmentation_selects_a_dictionary_word_not_the_whole_run() { + ensure_segmenter(); + let text = "run 北京欢迎你 done"; + let chars: Vec = text.chars().collect(); + let click = chars.iter().position(|&c| c == '京').unwrap(); + let (s, e) = cjk_word_range(text, click).expect("segmented range"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "北京"); + } + + #[test] + fn cjk_segmentation_survives_surrogate_pairs_before_the_click() { + // The emoji is two UTF-16 units: a tokenizer offset table that counted + // chars instead would shift every index after it. Both backends agree + // on `世界`, so a skewed mapping shows up as a different token. + ensure_segmenter(); + for text in ["你好世界", "🙂 你好世界", "🙂🙂🙂 你好世界"] { + let chars: Vec = text.chars().collect(); + let click = chars.iter().position(|&c| c == '世').unwrap(); + let (s, e) = cjk_word_range(text, click).expect("segmented range"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "世界", "{text:?} segmented wrong"); + } + } + + #[test] + fn cjk_punctuation_is_its_own_token() { + ensure_segmenter(); + let text = "比赛,天气"; + let chars: Vec = text.chars().collect(); + let click = chars.iter().position(|&c| c == ',').unwrap(); + let (s, e) = cjk_word_range(text, click).expect("segmented range"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, ","); + } + + /// Japanese must not be run through jieba's Chinese dictionary — it cuts + /// kana into single characters, which is worse than leaving the run whole. + /// macOS hands it to CFStringTokenizer, which segments it properly. + #[test] + fn japanese_is_not_shredded_into_single_kana() { + ensure_segmenter(); + let text = "日本語の文章です"; + let chars: Vec = text.chars().collect(); + let click = chars.iter().position(|&c| c == 'で').unwrap(); + // macOS yields a real token, never a lone kana; elsewhere the run + // comes back unsegmented and the caller selects all of it. + if let Some((s, e)) = cjk_word_range(text, click) { + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "です"); + } + } + + #[test] + fn is_cjk_covers_han_kana_hangul_fullwidth() { + for c in ['中', 'あ', 'ア', '한', ',', '('] { + assert!(is_cjk(c), "{c} should be CJK"); + } + for c in ['a', '1', '-', '/', 'é'] { + assert!(!is_cjk(c), "{c} should not be CJK"); + } + } +} diff --git a/src/terminal/view.rs b/src/terminal/view.rs index dc1d018a..c7baa40b 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -247,6 +247,13 @@ pub struct TerminalView { /// while this is true, so a result you've already seen stops nagging. Blue /// (working) / amber (waiting) are unaffected — they track live state. agent_result_unread: bool, + /// One-shot guard for a manual "Mark as Unread" on the pane the dismissed + /// context menu is about to refocus: closing the menu returns window focus + /// to that pane, and the resulting focus-in would instantly clear the mark + /// the user just made. Armed by [`mark_agent_result_unread`] + /// (Self::mark_agent_result_unread); the next focus-in consumes it instead + /// of clearing, so the mark survives until the user genuinely comes back. + keep_unread_on_focus: bool, /// The cwd this pane's git line reads from (and last scheduled a probe /// for), so the poll loop only reprobes when the working directory /// actually changes. The snapshot itself lives in the process-wide @@ -254,6 +261,11 @@ pub struct TerminalView { /// by work-tree root — panes in one repo share one entry instead of each /// computing (and staling) its own. git_status_cwd: Option, + /// The agent session's tool-completion count as of the last poll, so a + /// change means "the agent ran a tool since we looked" — the cue to refresh + /// the git line mid-turn rather than at the end of one. Reset to 0 when no + /// session is present, so a new session's first tool call reads as activity. + last_agent_activity: u64, /// The inline command line editor. Live only while the shell sits idle /// at its prompt (`input_active`): there the terminal keeps keyboard focus and /// we run our own line editor (so we own Tab / ↑ / ↓ for completion and @@ -321,6 +333,18 @@ pub struct TerminalView { /// its result is dropped unless it still matches — so output from a session /// the user has since closed (or replaced) can never leak into a later menu. completion_generation: u64, + /// While equal to the terminal's current `prompt_cycle`, the local line + /// editor has handed this prompt's line over to the shell (Tab fell + /// through to shell-native completion — see + /// [`Self::handoff_tab_to_shell`]): the shell's own editor now holds the + /// text, so keys go raw to the PTY exactly as on a shell-vi-mode prompt. + /// Keyed to the entered-prompt *cycle*, not the raw report seq — a + /// same-prompt redraw (completion list, `reset-prompt`) re-emits the + /// PS1-embedded `133;B` and would bump the seq while zle still holds the + /// handed-off text; re-engaging there would fork the two line buffers. + /// Only a command actually running starts a new cycle and re-engages the + /// editor. + editor_handoff: Option, /// Active Ctrl+R history search, if any. While set, the editor shows a /// `(reverse-i-search)` prompt instead of the line and a menu of the ranked /// matches floats beside it: typing edits the query (fuzzy, blended with @@ -441,6 +465,27 @@ const INTEGRATION_GRACE: std::time::Duration = std::time::Duration::from_secs(8) /// How long the integration notice stays up when no keystroke dismisses it. const INTEGRATION_NOTICE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +/// Floor on how often an *opportunistic* git probe may run for one cwd (see +/// [`GitRefresh::Opportunistic`]). Short enough that the sidebar's counts feel +/// live while an agent works, long enough that a burst of tool calls — or an +/// alt-tab into a window holding a dozen panes — collapses into one `git` +/// shell-out per repo instead of a dozen. +const OPPORTUNISTIC_GIT_GAP: std::time::Duration = std::time::Duration::from_millis(1500); + +/// Why a git-status probe is being asked for — the two classes get opposite +/// treatment when one is already in flight. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum GitRefresh { + /// A rare state change that must not be missed: the pane changed + /// directory, a command finished, an agent turn ended. Queues behind an + /// in-flight probe (which then reruns) rather than being dropped. + Edge, + /// A cheap signal that repeats on its own: the window regained focus, the + /// agent finished a tool call. Dropped outright when a probe is in flight + /// or one ran within [`OPPORTUNISTIC_GIT_GAP`] — the next one will come. + Opportunistic, +} + /// Fig-descended PTY shims known to exec over the shell we spawned and re-host /// it on a nested PTY without forwarding OSC 133 — which starves shell /// integration and silently kills the whole command-editor overlay (#46). @@ -875,8 +920,14 @@ impl TerminalView { view.focused = true; view.cursor_visible = true; // Looking at the pane marks its finished turn read, so the tab - // avatar's green Done dot clears. - view.agent_result_unread = false; + // avatar's green Done dot clears — unless this focus-in is + // just the context menu handing focus back after a manual + // "Mark as Unread" (the one-shot guard eats it). + if view.keep_unread_on_focus { + view.keep_unread_on_focus = false; + } else { + view.agent_result_unread = false; + } view.report_focus_change(true); cx.notify(); }), @@ -995,7 +1046,9 @@ impl TerminalView { agent_turn_started: None, agent_was_rich: false, agent_result_unread: false, + keep_unread_on_focus: false, git_status_cwd: None, + last_agent_activity: 0, cmd: CmdEditor::new(), typeahead: Typeahead::new(), hold: GapHold::new(), @@ -1011,6 +1064,7 @@ impl TerminalView { pending_history: None, completion: None, completion_generation: 0, + editor_handoff: None, reverse_search: None, integration_notice: None, integration_notice_shown: false, @@ -1051,6 +1105,26 @@ impl TerminalView { self.terminal.remote_context() } + /// The pane's cwd *only when it names a directory on this machine* — the + /// accessor every local filesystem or `Command` use must go through. + /// + /// A remote pane's OSC 7 reports a path in the remote's namespace + /// (`/home/me/proj` from an SSH host). Feeding that to a local `git` or + /// `read_dir` is meaningless, and on Windows it is worse than meaningless: + /// `/home/me/proj` is not an absolute path there but a *drive-relative* + /// one, so it silently resolves to `C:\home\me\proj`. That usually just + /// fails an `exists()` check — but if such a directory happens to exist, + /// the pane reports an unrelated local repo's branch and diff as its own. + /// Correctness must not rest on that collision never happening. + /// + /// Note this gates on the pane being remote, not on the shape of the path: + /// a local shell may legitimately sit in a directory whose name looks + /// remote, and Git Bash reports genuinely local paths (via `pwd -W`) that + /// merely originate from a POSIX-looking shell. + pub fn local_cwd(&self) -> Option { + self.remote_context().is_none().then(|| self.cwd())? + } + /// The coding agent running in this pane's foreground, or `None` when none /// is. Identity comes from the daemon's foreground-`argv` detection (plus /// the sentinel event channel, which can brand wrappers argv can't see @@ -1079,6 +1153,16 @@ impl TerminalView { self.agent_result_unread } + /// Re-flag this pane's finished turn as unread — the tab context menu's + /// "Mark as Unread". `refocus_incoming` is true for the pane the dismissed + /// menu is about to hand window focus back to (the active tab's focused + /// leaf): that focus-in is the menu closing, not the user reading the + /// result, so it must not clear the mark it just made. + pub fn mark_agent_result_unread(&mut self, refocus_incoming: bool) { + self.agent_result_unread = true; + self.keep_unread_on_focus = refocus_incoming; + } + /// The git snapshot for this pane's cwd (branch + working-tree diff), for /// the sidebar row's branch line — read from the shared per-repo /// [`GitStatusCache`](crate::terminal::git_status::GitStatusCache), so @@ -1100,6 +1184,24 @@ impl TerminalView { self.git_status_cwd.as_deref() } + /// Re-probe this pane's git status opportunistically — for callers holding + /// a reason to suspect the tree moved without the pane seeing it. The one + /// that matters is the window regaining focus: edits made in an editor, or + /// by a `git` command run in another app entirely, produce no event here at + /// all, so without this the counts would sit stale until the user happened + /// to run something in the pane. + /// + /// Throttled and in-flight-deduped (see [`GitRefresh::Opportunistic`]), so + /// calling it for every pane on every activation is cheap. A pane with no + /// resolved cwd yet is skipped rather than being pinned to `None` — its + /// first real probe is the poll loop's job. + pub fn refresh_git_status_now(&mut self, cx: &mut Context) { + let cwd = self.git_status_cwd.clone(); + if cwd.is_some() { + self.refresh_git_status(cwd, GitRefresh::Opportunistic, cx); + } + } + /// The current grid selection as text, if any non-blank one exists — the /// source for "Agent: Send Selection". pub fn selection_text(&self) -> Option { @@ -1343,6 +1445,26 @@ impl TerminalView { return; } + // On macOS all ordinary text goes out through the IME, never through + // `key_char` — see `input::defer_to_ime` for why (gpui reconstructs + // `key_char` from the virtual keycode, which is a lie for synthesized + // events). Decline the key without consuming it and gpui hands the + // native event to the input context, which delivers the real text via + // `commit_text`. + // + // Kitty's REPORT_ALL_KEYS_AS_ESC is the exception — `defer_to_ime` + // declines under it so the key reaches the encoder below. + // + // A pending multi-key chord is already handled before this point: a key + // that completes a sequence is dispatched as an action and never + // reaches `on_key_down`. The check below is belt-and-braces (gpui takes + // `pending_input` earlier in `dispatch_key_event`, so it never fires + // here) and mirrors `prefers_ime_for_printable_keys`, which *is* live. + #[cfg(target_os = "macos")] + if !window.has_pending_keystrokes() && super::input::defer_to_ime(ks, self.kitty_flags()) { + return; + } + // While idle at the prompt, our local command editor owns the keyboard: // editing keys act on the in-memory line and Enter ships it to the PTY. // Printable text is delivered through the IME path (`commit_text`), so we @@ -1367,7 +1489,7 @@ impl TerminalView { let kitty = self.kitty_flags(); if let Some(bytes) = super::input::keystroke_to_bytes(ks, kitty) { let plain = !m.control && !m.alt && !m.platform; - let shell_vi_prompt = self.shell_vi_prompt(); + let shell_owns_prompt = self.shell_owns_prompt(); // A plain Backspace is reconstructable gap input: offer it to the // hold, so a fast command's typeahead never touches the PTY (see // `hold`). Anything else releases the hold first — FIFO order on @@ -1375,7 +1497,7 @@ impl TerminalView { // for the deferred wipe. let held = plain && ks.key == "backspace" - && !shell_vi_prompt + && !shell_owns_prompt && self.gap_holdable() && match self.hold.hold_backspace(&bytes) { Verdict::Held(arm) => { @@ -1389,7 +1511,7 @@ impl TerminalView { if !held { self.release_hold(); self.terminal.write(bytes); - if !shell_vi_prompt { + if !shell_owns_prompt { self.typeahead.observe( RawInput::Key { key: ks.key.as_str(), @@ -2034,7 +2156,7 @@ impl TerminalView { /// local `Term`'s mode bits (the reader thread keeps them current by advancing /// the emulator over all child output). Consulted by the key encoder so TUIs /// that opt into the protocol get `CSI u` reports. - fn kitty_flags(&self) -> super::input::KittyFlags { + pub(super) fn kitty_flags(&self) -> super::input::KittyFlags { super::input::KittyFlags::from_mode(self.terminal.term.lock().mode()) } @@ -2553,9 +2675,51 @@ impl TerminalView { // an agent's session is one long foreground command, so its edits would // otherwise stay invisible until it exits. All rare edges, so the // off-thread `git` shell-out runs seldom, not every 300ms tick. - let cwd_now = self.cwd(); + // + // Those edges alone left the counts badly stale during the case they + // matter most: a long agent turn writes file after file for minutes + // with nothing to show for it. A tool completion is the one signal that + // the tree may have just moved mid-turn, so it refreshes too — through + // the throttled path, since a busy agent emits them several a second + // and each one would otherwise cost a `git diff` across the repo. + // + // An agent that reports its own cwd through the hook channel wins over + // the proc probe: it tracks internal chdirs the PTY can't observe + // (Claude Code's EnterWorktree) and works where the proc fallback + // doesn't (Windows). The claim dies with the session (`session-end` + // clears it, and the agent leaving the foreground drops the whole + // state), so an exited agent falls back to the pane's real directory. + // The agent's report goes through the same remote gate as the pane's own + // cwd. A native-SSH pane keeps sentinel-sourced agent state on purpose + // (`spawn_native_ssh`), so an agent running *on the remote host* reports + // a remote path — and being first in the chain it would win over + // `local_cwd` unconditionally and hand that path straight to the local + // `git`, which is the collision `local_cwd` exists to prevent. + let session = self.terminal.agent_session(); + // A count that moved means at least one tool finished since the last + // tick. With no session the counter resets, so a fresh agent's very + // first tool call still reads as activity. + let tool_activity = match session.as_ref().map(|s| s.activity) { + Some(n) => std::mem::replace(&mut self.last_agent_activity, n) != n, + None => { + self.last_agent_activity = 0; + false + } + }; + let cwd_now = self + .remote_context() + .is_none() + .then(|| { + session + .as_ref() + .and_then(|s| s.cwd.clone()) + .or_else(|| self.cwd()) + }) + .flatten(); if cwd_now.as_ref() != self.git_status_cwd.as_ref() || cmd_finished || turn_finished { - self.refresh_git_status(cwd_now, cx); + self.refresh_git_status(cwd_now, GitRefresh::Edge, cx); + } else if tool_activity { + self.refresh_git_status(cwd_now, GitRefresh::Opportunistic, cx); } } @@ -2564,11 +2728,19 @@ impl TerminalView { /// brackets the flight (`begin_probe`/`finish_probe`): a probe already in /// flight for the same cwd absorbs this trigger instead of spawning a /// duplicate `git` shell-out, and reruns once when it lands. With no cwd - /// (e.g. a native-SSH pane pre-OSC-7, where a local `git` would be - /// meaningless) the pane simply stops reading a status. + /// (e.g. a remote pane, where a local `git` would be meaningless) the pane + /// simply stops reading a status. Callers must source the cwd from + /// [`local_cwd`](Self::local_cwd): a remote pane *does* get a cwd once its + /// OSC 7 lands, so "remote panes have no cwd" holds only before that and + /// cannot be what keeps the local probe away from a remote path. /// /// [`GitStatusCache`]: crate::terminal::git_status::GitStatusCache - fn refresh_git_status(&mut self, cwd: Option, cx: &mut Context) { + fn refresh_git_status( + &mut self, + cwd: Option, + trigger: GitRefresh, + cx: &mut Context, + ) { use crate::terminal::git_status::GitStatusCache; let changed = self.git_status_cwd != cwd; @@ -2580,7 +2752,11 @@ impl TerminalView { return; }; cx.default_global::(); // first probe of the process creates it - if !cx.update_global::(|cache, _| cache.begin_probe(&cwd)) { + let claimed = cx.update_global::(|cache, _| match trigger { + GitRefresh::Edge => cache.begin_probe(&cwd), + GitRefresh::Opportunistic => cache.begin_probe_throttled(&cwd, OPPORTUNISTIC_GIT_GAP), + }); + if !claimed { return; } cx.spawn(async move |this, cx| { @@ -2591,19 +2767,27 @@ impl TerminalView { async move { crate::terminal::git_status::probe(&cwd) } }) .await; - let _ = this.update(cx, |view, cx| { - // Landing through `update_global` wakes the sidebar's - // `observe_global`, so every pane in the repo repaints — not - // just this one. - let rerun = cx.update_global::(|cache, _| { - cache.finish_probe(&cwd, result) + // Land the result in the shared cache before touching the pane, + // and whether or not the pane still exists: the in-flight claim is + // keyed by *cwd*, so a pane closed mid-probe that never released + // its claim would wedge the git line of every other pane in that + // directory — permanently, since nothing else ever clears it. + // + // Landing through `update_global` wakes the sidebar's + // `observe_global`, so every pane in the repo repaints — not just + // this one. + let rerun = + cx.update_global::(|cache, _| cache.finish_probe(&cwd, result)); + // A trigger arrived while we flew; go once more so its state is + // observed — unless this pane has since left that cwd. Only edge + // triggers set that flag, so the rerun is an edge too. + if rerun { + let _ = this.update(cx, |view, cx| { + if view.git_status_cwd.as_deref() == Some(&cwd) { + view.refresh_git_status(Some(cwd), GitRefresh::Edge, cx); + } }); - // A trigger arrived while we flew; go once more so its state - // is observed — unless this pane has since left that cwd. - if rerun && view.git_status_cwd.as_deref() == Some(&cwd) { - view.refresh_git_status(Some(cwd), cx); - } - }); + } }) .detach(); } @@ -2647,9 +2831,13 @@ impl TerminalView { match status { Some(AgentStatus::Done) if prev != Some(AgentStatus::Done) => { self.agent_result_unread = !self.focused; + self.keep_unread_on_focus = false; } Some(AgentStatus::Done) => {} - _ => self.agent_result_unread = false, + _ => { + self.agent_result_unread = false; + self.keep_unread_on_focus = false; + } } let rich = session.as_ref().is_some_and(|s| s.rich); @@ -2814,7 +3002,9 @@ impl TerminalView { self.editor_drag_word = None; } 2 => { - self.cmd.select_word_at(idx); + let cfg = cx.global::(); + let (seps, smart) = (cfg.word_separators.clone(), cfg.smart_select); + self.cmd.select_word_at(idx, &seps, smart); // Drag now grows the selection by whole words around this one. self.editor_selecting = true; self.editor_drag_word = self.cmd.selection(); @@ -2845,7 +3035,9 @@ impl TerminalView { }; // A drag begun on a double-click extends by whole words; otherwise by char. if let Some((s, e)) = self.editor_drag_word { - self.cmd.extend_word_to(s, e, idx); + let cfg = cx.global::(); + let (seps, smart) = (cfg.word_separators.clone(), cfg.smart_select); + self.cmd.extend_word_to(s, e, idx, &seps, smart); } else { self.cmd.extend_to(idx); } @@ -2872,6 +3064,12 @@ impl TerminalView { if self.shell_vi_prompt() { return false; } + // A Tab handoff gave this prompt's line to the shell; until a command + // runs and a fresh prompt cycle starts, the shell's editor owns it, + // and re-engaging ours would fork the two line buffers. + if self.editor_handoff == Some(self.terminal.prompt_cycle()) { + return false; + } self.at_shell_prompt() } @@ -2879,6 +3077,25 @@ impl TerminalView { self.terminal.shell_vi_mode() && self.terminal.at_prompt() && !self.on_alt_screen() } + /// True while a Tab handoff has given the current prompt's line to the + /// shell (see [`Self::handoff_tab_to_shell`]) and the shell is still in + /// that prompt cycle. Over once a command runs and the next prompt + /// arrives (a false→true `at_prompt` edge bumps the cycle). + fn handoff_active(&self) -> bool { + self.editor_handoff == Some(self.terminal.prompt_cycle()) + && self.terminal.at_prompt() + && !self.on_alt_screen() + } + + /// True while the shell's own line editor owns the prompt line — a + /// vi-mode prompt, or one whose line a Tab handoff shipped over. Raw + /// input then goes to the PTY with no hold and no typeahead record: + /// those bytes land on zle's line and are the shell's to keep, so a + /// deferred `^U` wipe would erase text the user can see. + fn shell_owns_prompt(&self) -> bool { + self.shell_vi_prompt() || self.handoff_active() + } + /// True while the emulator is on the alternate screen — a full-screen TUI /// owns the pane, so raw input belongs to that program, not the shell's /// next command line. @@ -2928,7 +3145,7 @@ impl TerminalView { /// pane. Only consulted on the raw path, so "the editor is disengaged" is /// already implied. fn gap_holdable(&self) -> bool { - self.terminal.shell_active() && !self.on_alt_screen() && !self.shell_vi_prompt() + self.terminal.shell_active() && !self.on_alt_screen() && !self.shell_owns_prompt() } /// Write printable gap text (IME commit, paste) toward the shell: offered @@ -2936,7 +3153,7 @@ impl TerminalView { /// written raw and recorded for the deferred wipe. `bytes` is the exact /// PTY encoding (paste may be bracketed-wrapped). fn write_gap_text(&mut self, text: &str, bytes: Vec, cx: &mut Context) { - if self.shell_vi_prompt() { + if self.shell_owns_prompt() { self.release_hold(); self.terminal.write(bytes); return; @@ -3271,6 +3488,47 @@ impl TerminalView { cx.notify(); } + /// Hand the prompt line over to the shell so its native completion + /// (compsys, fzf-tab, …) answers the Tab tty7 has nothing for: ship the + /// locally edited text to the PTY (no newline), clear the editor, send + /// the Tab / Shift-Tab bytes, and suspend the local editor until the + /// shell's next report. From here the shell's own editor holds the text — + /// re-engaging ours mid-line would fork the two buffers (its Enter would + /// submit an empty local line on top of zle's populated one). + fn handoff_tab_to_shell(&mut self, shift: bool, cx: &mut Context) { + // Fold in any gap input still held, so the shipped line is what the + // user actually typed. + if let Some(net) = self.hold.engage() { + self.cmd.prepend_str(&net); + } + let line = self.cmd.text(); + // An embedded newline would submit on the shell side (zle runs the + // line on `\r`), so a multi-line draft can't be handed over losslessly + // — keep it local and swallow the Tab as before. + if line.contains('\n') { + cx.notify(); + return; + } + self.close_completion(); + // A pending typeahead wipe's deferred `^U` would erase the very text + // we're about to ship; flush it first (FIFO keeps it ahead). + self.wipe_pending_typeahead(); + // Chars right of the caret: after the shipped text lands, walk zle's + // cursor back over them so the shell completes the word the caret was + // on, not the line's tail. + let tail = line.chars().count().saturating_sub(self.cmd.cursor()); + if !line.is_empty() { + self.terminal.write(line.into_bytes()); + if tail > 0 { + self.terminal.write(b"\x1b[D".repeat(tail)); + } + } + self.cmd.clear(); + self.editor_handoff = Some(self.terminal.prompt_cycle()); + let bytes = self.tab_bytes(shift); + self.send_to_pty(&bytes, cx); + } + /// Tab completion over our own engine (command names in command /// position, filesystem paths elsewhere — history is deliberately absent: /// whole-line recall is ghost text's and Ctrl+R's job). A fresh Tab applies a @@ -3280,18 +3538,37 @@ impl TerminalView { /// With the menu open, Tab fills any further common prefix, else moves the /// highlight (`forward` reverses for Shift-Tab). fn complete_tab(&mut self, forward: bool, cx: &mut Context) { + // Ctrl+R search owns the keyboard: `self.cmd` still holds the stale + // pre-search line, so neither completing it nor shipping it to the + // shell makes sense here. + if self.reverse_search.is_some() { + return; + } + // tty7 completion switched off: every Tab goes to the shell. + if !cx.global::().tab_completion { + self.handoff_tab_to_shell(!forward, cx); + return; + } if self.completion.is_some() { self.completion_tab_step(forward, cx); return; } - // Fresh completion. - let Some(cwd) = self.cwd().or_else(|| std::env::current_dir().ok()) else { - return; + // Fresh completion. Path candidates come off the local filesystem, so + // they need a local cwd — a remote pane passes `None` and gets command + // completion only. Falling back to tty7's own directory there would + // offer *this* machine's filenames for insertion into a remote command + // line, where they don't exist. + let cwd = match self.remote_context() { + Some(_) => None, + None => self.local_cwd().or_else(|| std::env::current_dir().ok()), }; let line = self.cmd.text(); let cursor = self.cmd.cursor(); - let Some(comp) = super::completion::complete(&line, cursor, &cwd) else { + let Some(comp) = super::completion::complete(&line, cursor, cwd.as_deref()) else { + // Nothing to offer. Don't swallow the keypress (#136) — hand the + // line to the shell and let its completion have the Tab. + self.handoff_tab_to_shell(!forward, cx); return; }; @@ -3341,6 +3618,9 @@ impl TerminalView { // Kick off each generator on the background executor and merge results // back on the main thread, tagged with this session's generation. + // Generators are local shell-outs and only ever come from `complete`'s + // `Some(cwd)` branch, so a remote pane has none to run. + let Some(cwd) = cwd else { return }; for pending in comp.pending { let script = pending.script; let cwd = cwd.clone(); @@ -3602,18 +3882,51 @@ impl TerminalView { row: usize, left: bool, clicks: usize, + shift: bool, cx: &mut Context, ) { + let smart = cx.global::().smart_select; let mut term = self.terminal.term.lock(); let display_offset = term.grid().display_offset() as i32; let point = Point::new(Line(row as i32 - display_offset), Column(col)); let side = if left { Side::Left } else { Side::Right }; + // Shift+click extends the existing selection to the click instead of + // starting over (à la iTerm2). A plain click always leaves a + // collapsed Simple selection behind, so the anchor is wherever the + // last gesture ended. + if shift && clicks == 1 && term.selection.is_some() { + if let Some(sel) = term.selection.as_mut() { + sel.update(point, side); + } + drop(term); + self.selecting = true; + cx.notify(); + return; + } let ty = match clicks { 2 => SelectionType::Semantic, // word n if n >= 3 => SelectionType::Lines, _ => SelectionType::Simple, }; - term.selection = Some(Selection::new(ty, point, side)); + let mut selection = Selection::new(ty, point, side); + // Double-click smart selection: a URL / path / email / bracket pair / + // CJK word containing the clicked word replaces the plain word span. + // Boundary-flanked candidates anchor a Semantic selection (keeping + // the drag gesture word-wise); exact ones use Simple so alacritty + // can't re-expand the endpoints past the smart boundary. + if clicks == 2 + && smart + && let Some(r) = super::smart_select::grid_smart_range(&term, point) + { + let ty = if r.exact { + SelectionType::Simple + } else { + SelectionType::Semantic + }; + selection = Selection::new(ty, r.start, Side::Left); + selection.update(r.end, Side::Right); + } + term.selection = Some(selection); drop(term); self.selecting = true; cx.notify(); @@ -3867,11 +4180,22 @@ impl TerminalView { text.push(term.grid()[line][Column(c)].c); } drop(term); - let cwd = self.cwd(); + // A relative path in the output is resolved against the cwd and + // stat-checked, then handed to the local file opener — so a remote + // pane's cwd must not be used. There, only absolute-looking local hits + // and URLs remain clickable. + let cwd = self.local_cwd(); if let Some(link) = super::search::link_at(&text, col, cwd.as_deref(), true) { match link.target { LinkTarget::Url(url) => self.open_url(&url, cx), - LinkTarget::File { path, .. } => open_file_path(&path), + LinkTarget::File { path, line, column } => { + // A configured template (e.g. opening the file in an editor) + // takes precedence; otherwise fall back to the OS opener. + match cx.global::().link_file_command.as_deref() { + Some(template) => run_file_command(template, &path, line, column), + None => open_file_path(&path), + } + } } true } else if self.can_forward_loopback(cx) @@ -4019,7 +4343,9 @@ impl TerminalView { text.push(term.grid()[line][Column(c)].c); } drop(term); - let cwd = self.cwd(); + // Same gate as the click path above — hover must not underline a link + // the click cannot open. + let cwd = self.local_cwd(); let link = super::search::link_at(&text, col, cwd.as_deref(), include_files).or_else(|| { include_loopback.then(|| { @@ -4675,14 +5001,14 @@ impl Render for TerminalView { // that arms the flag arrives as pane output, so a render always // follows it (Output → Wakeup → notify). Both prepend: they were // typed before any post-engage keys already sitting in the editor. - if self.shell_vi_prompt() { - // A vi prompt never engages the editor: release held gap input to - // the shell's own line editor (raw, no typeahead record — there is - // no local adoption to reconcile against) and drop any pending - // record without its `^U`. Those bytes land on zle's line and are - // the shell's to keep; a record surviving the vi prompt would - // flush at the next emacs-mode prompt and resurrect long-consumed - // text into the editor. + if self.shell_owns_prompt() { + // A vi-mode (or handed-off) prompt never engages the editor: + // release held gap input to the shell's own line editor (raw, no + // typeahead record — there is no local adoption to reconcile + // against) and drop any pending record without its `^U`. Those + // bytes land on zle's line and are the shell's to keep; a record + // surviving this prompt would flush at the next editor-engaged + // prompt and resurrect long-consumed text into the editor. if let Some((_net, bytes)) = self.hold.release() { self.terminal.write(bytes); } @@ -5006,6 +5332,81 @@ fn open_file_path(path: &std::path::Path) { } } +/// Run a user-configured file-open command for a clicked file link. The template +/// is expanded by [`expand_file_command_template`] and the first token is the +/// program; the rest are its arguments. Spawned detached — tty7 doesn't wait for +/// or read from the editor it launches. +fn run_file_command( + template: &str, + path: &std::path::Path, + line: Option, + column: Option, +) { + let argv = expand_file_command_template(template, path, line, column); + let Some((program, args)) = argv.split_first() else { + log::warn!("link_file_command is empty; ignoring file link"); + return; + }; + if let Err(e) = std::process::Command::new(program).args(args).spawn() { + log::warn!("failed to run link_file_command {template:?}: {e}"); + } +} + +/// Expand a file-open command template into an argv vector. +/// +/// The template is split on whitespace into tokens. Within a token the +/// placeholders `{path}`, `{line}`, and `{column}` are replaced with their +/// values. If a token references a placeholder whose value is absent (e.g. +/// `{line}` for a link with no line number), the whole token is dropped — this +/// lets a combined token like `--line={line}` disappear cleanly rather than +/// leaving a dangling flag. `{path}` is always present, so a token that only +/// references `{path}` is never dropped. +fn expand_file_command_template( + template: &str, + path: &std::path::Path, + line: Option, + column: Option, +) -> Vec { + let path = path.to_string_lossy(); + template + .split_whitespace() + .filter_map(|token| expand_file_command_token(token, &path, line, column)) + .collect() +} + +/// Substitute placeholders in a single template token, or return `None` if the +/// token references a placeholder with no value (so the caller drops it). +fn expand_file_command_token( + token: &str, + path: &str, + line: Option, + column: Option, +) -> Option { + let mut out = String::with_capacity(token.len()); + let mut rest = token; + while let Some(open) = rest.find('{') { + let Some(close_rel) = rest[open..].find('}') else { + // No closing brace: the remainder is literal text. + break; + }; + let close = open + close_rel; + out.push_str(&rest[..open]); + let value = match &rest[open + 1..close] { + "path" => Some(path.to_string()), + "line" => line.map(|l| l.to_string()), + "column" => column.map(|c| c.to_string()), + // An unknown placeholder is left verbatim rather than dropping the + // token, so a stray brace doesn't silently swallow an argument. + other => Some(format!("{{{other}}}")), + }; + // A recognized-but-absent placeholder drops the entire token. + out.push_str(&value?); + rest = &rest[close + 1..]; + } + out.push_str(rest); + Some(out) +} + /// One mouse report, encoded for the protocol the app negotiated. SGR (1006) /// prints decimal 1-based coordinates and keeps the button in the final /// letter (`M` press / `m` release); X10 packs everything into three bytes, @@ -5360,15 +5761,65 @@ fn drag_scroll_step(overshoot: f32) -> i32 { mod tests { use super::{ SelectEndCopy, WheelRoute, clipboard_paste_text, display_width, drag_scroll_step, - encode_mouse, fallback_chain, fig_icon_emoji, fig_icon_glyph, focus_report_bytes, - input_overflow_shift, input_overlay_rows, menu_layout, paste_bytes, select_end_copy, - shell_escape_path, smooth_scroll_step, trim_trailing_spaces, wheel_route, + encode_mouse, expand_file_command_template, fallback_chain, fig_icon_emoji, fig_icon_glyph, + focus_report_bytes, input_overflow_shift, input_overlay_rows, menu_layout, paste_bytes, + select_end_copy, shell_escape_path, smooth_scroll_step, trim_trailing_spaces, wheel_route, wrapped_click_index, }; use alacritty_terminal::term::TermMode; use gpui::{ClipboardEntry, ClipboardItem, ExternalPaths, Modifiers}; use gpui_component::IconName; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; + + #[test] + fn file_command_template_substitutes_path_line_and_column() { + let argv = expand_file_command_template( + "herdr edit {path} --line={line} --column={column}", + Path::new("/tmp/foo.rs"), + Some(42), + Some(7), + ); + assert_eq!( + argv, + vec!["herdr", "edit", "/tmp/foo.rs", "--line=42", "--column=7",] + ); + } + + #[test] + fn file_command_template_drops_tokens_for_absent_values() { + // No line/column: the combined flag tokens vanish entirely, leaving no + // dangling `--line` for the downstream parser. + let argv = expand_file_command_template( + "herdr edit {path} --line={line} --column={column}", + Path::new("/tmp/foo.rs"), + None, + None, + ); + assert_eq!(argv, vec!["herdr", "edit", "/tmp/foo.rs"]); + + // Column absent but line present: only the column flag drops. + let argv = expand_file_command_template( + "herdr edit {path} --line={line} --column={column}", + Path::new("/tmp/foo.rs"), + Some(42), + None, + ); + assert_eq!(argv, vec!["herdr", "edit", "/tmp/foo.rs", "--line=42"]); + } + + #[test] + fn file_command_template_keeps_path_only_token_and_unknown_placeholder() { + // A path-only program still runs; an unknown placeholder is left verbatim + // rather than dropping its token. + let argv = expand_file_command_template( + "code --goto {path}:{line} {other}", + Path::new("/tmp/foo.rs"), + None, + None, + ); + // `{path}:{line}` drops (line absent); `{other}` stays literal. + assert_eq!(argv, vec!["code", "--goto", "{other}"]); + } #[cfg(not(target_os = "macos"))] #[test] @@ -6059,6 +6510,34 @@ mod gpui_tests { } } + /// Deliver one printable character the way the running platform actually + /// does. macOS hands all text to the input context, which arrives as + /// `commit_text` (see `input::defer_to_ime`); elsewhere it travels the + /// `on_key_down` / `key_char` path. Tests that assert on *text* input must + /// go through here, or they exercise a path the platform never takes. + fn type_char( + view: &mut TerminalView, + ch: &str, + window: &mut Window, + cx: &mut Context, + ) { + if cfg!(target_os = "macos") { + let _ = window; + view.commit_text(ch, cx); + } else { + let ev = KeyDownEvent { + keystroke: gpui::Keystroke { + modifiers: gpui::Modifiers::default(), + key: ch.to_string(), + key_char: Some(ch.to_string()), + }, + is_held: false, + prefer_character_input: false, + }; + view.on_key_down(&ev, window, cx); + } + } + fn next_input_until_timeout(daemon: &mut UnixStream) -> Option> { use std::io::ErrorKind; @@ -6130,16 +6609,7 @@ mod gpui_tests { !view.input_active(), "shell vi-mode lets the shell line editor own prompt input" ); - let a = KeyDownEvent { - keystroke: gpui::Keystroke { - modifiers: gpui::Modifiers::default(), - key: "a".to_string(), - key_char: Some("a".to_string()), - }, - is_held: false, - prefer_character_input: false, - }; - view.on_key_down(&a, window, cx); + type_char(view, "a", window, cx); assert_eq!( view.cmd.text(), "", @@ -6174,6 +6644,146 @@ mod gpui_tests { panic!("an emacs-mode prompt should re-enable tty7's local editor"); } + /// Wait until the daemon-fed prompt state makes the local editor live. + fn wait_for_input_active(window: &gpui::WindowHandle, cx: &mut TestAppContext) { + for _ in 0..200 { + cx.run_until_parked(); + let active = window.update(cx, |view, _, _| view.input_active()).unwrap(); + if active { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("the local editor never engaged at the prompt"); + } + + /// Tab the engine has nothing for must not be swallowed (#136): the + /// locally edited line is shipped to the shell followed by the Tab + /// itself, and the local editor stays out of the way until the shell + /// reports its next prompt — from there the shell's own completion owns + /// the line. + #[gpui::test] + fn tab_with_no_candidates_hands_the_line_to_the_shell(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for_input_active(&window, cx); + + window + .update(cx, |view, window, cx| { + // A command-position word matching no builtin or $PATH entry, + // so the completion engine returns `None`. + for ch in ["z", "z", "q", "q", "x"] { + type_char(view, ch, window, cx); + } + assert_eq!(view.cmd.text(), "zzqqx"); + view.complete_tab(true, cx); + assert_eq!(view.cmd.text(), "", "the line moved to the shell"); + assert!( + !view.input_active(), + "the shell owns the prompt after the handoff" + ); + }) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"zzqqx".to_vec()), + "the edited line ships ahead of the Tab" + ); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"\t".to_vec()), + "the Tab reaches the PTY instead of being swallowed" + ); + + // A same-prompt redraw (a prompt framework re-emitting the + // PS1-embedded `133;B` on reset-prompt / a completion list reprint) + // must NOT re-engage the editor — zle still holds the handed-off + // text, and an engaged-empty editor would fork the two buffers. + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + for _ in 0..200 { + cx.run_until_parked(); + let applied = window + .update(cx, |view, _, _| view.terminal.prompt_seq() >= 2) + .unwrap(); + if applied { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + window + .update(cx, |view, _, _| { + assert!( + !view.input_active(), + "a same-prompt redraw must not re-engage the editor" + ); + }) + .unwrap(); + + // A real command cycle — the shell leaves the prompt and comes back — + // re-engages the local editor. + DaemonMsg::Prompt { + active: true, + at_prompt: false, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: Some(0), + } + .encode(&mut daemon) + .unwrap(); + wait_for_input_active(&window, cx); + } + + /// With `tab_completion` off, Tab never opens tty7's menu — even when the + /// engine would have candidates, the line and the Tab go to the shell. + #[gpui::test] + fn tab_completion_off_sends_every_tab_to_the_shell(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + cx.update(|cx| { + let mut cfg = cx.global::().clone(); + cfg.tab_completion = false; + cx.set_global(cfg); + }); + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for_input_active(&window, cx); + + window + .update(cx, |view, window, cx| { + // "cd " would offer path candidates were the engine consulted. + for ch in ["c", "d", " "] { + type_char(view, ch, window, cx); + } + view.complete_tab(true, cx); + assert!(view.completion.is_none(), "no tty7 menu while opted out"); + assert_eq!(view.cmd.text(), ""); + }) + .unwrap(); + assert_eq!(next_input_until_timeout(&mut daemon), Some(b"cd ".to_vec())); + assert_eq!(next_input_until_timeout(&mut daemon), Some(b"\t".to_vec())); + } + #[gpui::test] fn shell_vi_mode_prompt_input_is_not_typeahead(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -6205,16 +6815,7 @@ mod gpui_tests { window .update(cx, |view, window, cx| { - let i = KeyDownEvent { - keystroke: gpui::Keystroke { - modifiers: gpui::Modifiers::default(), - key: "i".to_string(), - key_char: Some("i".to_string()), - }, - is_held: false, - prefer_character_input: false, - }; - view.on_key_down(&i, window, cx); + type_char(view, "i", window, cx); }) .unwrap(); assert_eq!( @@ -6907,7 +7508,7 @@ mod gpui_tests { let drag_hello = |cx: &mut TestAppContext| { window .update(cx, |view, _, cx| { - view.on_select_start(0, 0, true, 1, cx); + view.on_select_start(0, 0, true, 1, false, cx); view.on_select_update(4, 0, false, cx); view.on_select_end(cx); }) @@ -6972,7 +7573,7 @@ mod gpui_tests { .update(cx, |view, window, cx| { // Mouse-select "hello" (copy-on-select is off by default, so // the selection survives mouse-up). - view.on_select_start(0, 0, true, 1, cx); + view.on_select_start(0, 0, true, 1, false, cx); view.on_select_update(4, 0, false, cx); view.on_select_end(cx); assert!(view.has_selection(), "the drag must leave a selection"); diff --git a/src/ui/app.rs b/src/ui/app.rs index c0fd2536..58aac0d6 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -10,7 +10,7 @@ use gpui_component::input::{InputEvent, InputState}; use gpui_component::select::{SearchableVec, SelectEvent, SelectState}; use gpui_component::slider::{SliderEvent, SliderState}; use gpui_component::{ActiveTheme as _, IndexPath, TitleBar, WindowExt as _}; -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use std::sync::Arc; @@ -383,6 +383,10 @@ pub struct Tty7App { /// the same repo shows the just-refreshed branch/diff line, not a stale /// per-row copy. Never read. _git_status_watch: Subscription, + /// Keeps the window-appearance observer alive: while + /// `Config::theme_follow_system` is on, an OS light/dark flip re-resolves + /// the theme slot and repaints. Never read. + _appearance_watch: Subscription, /// `Some` while the command palette overlay is open; `None` when closed. /// The view owns its search input, filtered list and keyboard handling and /// emits a `PaletteEvent`; we build the catalog and run the chosen command. @@ -446,6 +450,12 @@ pub struct Tty7App { /// Scroll handle for the sidebar's row list, so activating a tab scrolls its /// row into view. pub(crate) sidebar_scroll: gpui::ScrollHandle, + /// `Some` while a tab / group is being dragged to a new position, in either + /// the strip or the rail: the frozen geometry the live preview reflow is + /// computed against (see [`crate::ui::reorder`]). Shared by `Rc` because the + /// `on_drag` that opens it only gets `&mut App`, not the entity. Cleared on + /// the first frame after gpui ends the drag. + pub(crate) reorder: Rc>>, /// Filter box in the sidebar's top control bar ("Search tabs…"); its text /// narrows the visible rows by fuzzy-ish substring match on the tab label. pub(crate) sidebar_search: Entity, @@ -627,13 +637,40 @@ impl Tty7App { // so treat it like a release. Dismissing on *both* flips also keeps a // reveal scheduled just before the switch from popping the badges up // in a window the user already left. - let activation_watch = cx.observe_window_activation(window, |this, _window, cx| { + let activation_watch = cx.observe_window_activation(window, |this, window, cx| { this.dismiss_mod_hint(cx); // The panes' link-modifier tracking loses the release the same // way, and a stale "⌘ held" is worse than missing badges: a // plain unmodified click would open links. Treat the flip as a // release; holding ⌘ again re-arms it via `on_modifiers_changed`. this.set_link_modifier(false, cx); + // Coming back is the only cue we get that the working tree may + // have moved while the user was elsewhere: an edit in another + // editor, a `git` command in another app, an agent in another + // window. None of those reach a pane's poll loop, so without this + // the sidebar's `+N −N` would keep showing pre-alt-tab numbers + // until the user happened to run a command in the pane. + if window.is_window_active() { + this.refresh_git_status_all(cx); + } + }); + // Follow OS light/dark flips live: while "sync with system" is on, an + // appearance change re-resolves the theme slot and repaints. While it's + // off the appearance only ever changes because `apply_theme` pinned it + // to the theme — skip, or the pin would re-trigger a redundant apply. + let this = cx.weak_entity(); + let appearance_watch = window.observe_window_appearance(move |window, cx| { + if !cx.global::().theme_follow_system { + return; + } + apply_theme(Some(window), cx); + let _ = this.update(cx, |this, cx| { + // The editor targets the on-screen theme, and with no global + // override the opacity slider follows it — keep both in step. + this.rebuild_theme_editor(window, cx); + this.sync_window_opacity_slider(window, cx); + cx.notify(); + }); }); // Paint the configured color theme (defaults to a light one) and build // the menu bar. @@ -685,6 +722,7 @@ impl Tty7App { _keystroke_watch: keystroke_watch, _activation_watch: activation_watch, _git_status_watch: git_status_watch, + _appearance_watch: appearance_watch, palette: None, palette_sub: None, closed: Vec::new(), @@ -716,6 +754,7 @@ impl Tty7App { right_panel_width: Rc::new(Cell::new(right_panel_width)), right_panel_dragging: Rc::new(Cell::new(false)), sidebar_scroll: gpui::ScrollHandle::new(), + reorder: Rc::new(RefCell::new(None)), sidebar_search, _sidebar_search_sub: sidebar_search_sub, file_search, @@ -789,11 +828,20 @@ impl Tty7App { return true; } // From the home page (zero tabs) there are no running sessions to - // reassure about — prompting would be pure friction. Close directly. + // reassure about — prompting would be pure friction. Close directly, + // but still quit with the window: closing our only window without + // quitting leaves a windowless process sitting in the Dock that no + // longer responds to being clicked (#147). Deferred onto the next + // tick so the close itself completes first, same as the confirmed + // path below. if weak_app .upgrade() .is_some_and(|app| app.read(cx).tabs.is_empty()) { + cx.spawn(async move |cx| { + let _ = cx.update(|cx| cx.quit()); + }) + .detach(); return true; } let answer = window.prompt( @@ -826,7 +874,7 @@ impl Tty7App { /// Snapshot the current tabs/active index into a `Session` and persist it. /// Called after every structural change; the write is a small synchronous /// JSON dump and any error is swallowed inside `Session::save`. - fn save_session(&self, cx: &App) { + pub(crate) fn save_session(&self, cx: &App) { let tabs: Vec = self .tabs .iter() @@ -1195,8 +1243,90 @@ impl Tty7App { /// Switch the active color theme by id, repaint, and persist the choice so /// it survives a restart. The theme carries its own dark/light brightness. + /// While the system is being followed, the choice lands in the slot for the + /// *current* OS appearance (the theme visibly on screen changes either way). pub(crate) fn set_preset(&mut self, id: &str, window: &mut Window, cx: &mut Context) { - cx.global_mut::().theme_preset = id.to_string(); + let dark_now = crate::ui::theme::system_dark(cx); + let cfg = cx.global_mut::(); + if !cfg.theme_follow_system { + cfg.theme_preset = id.to_string(); + } else if dark_now { + cfg.theme_preset_dark = id.to_string(); + } else { + cfg.theme_preset_light = id.to_string(); + } + self.after_theme_change(window, cx); + } + + /// Set the theme for one follow-system slot explicitly (the Light / Dark + /// cards in Settings). Only visibly changes anything when that slot is the + /// one currently on screen; either way the choice is persisted. + pub(crate) fn set_slot_preset( + &mut self, + dark_slot: bool, + id: &str, + window: &mut Window, + cx: &mut Context, + ) { + let cfg = cx.global_mut::(); + if dark_slot { + cfg.theme_preset_dark = id.to_string(); + } else { + cfg.theme_preset_light = id.to_string(); + } + self.after_theme_change(window, cx); + } + + /// Turn "sync with system appearance" on/off (the Appearance switch). + /// Turning it off never visibly changes the theme: whatever is on screen + /// is adopted as the manual choice. Turning it on seeds the slot matching + /// the manual theme's own brightness with that theme — so the look only + /// changes when the OS is currently in the *other* mode, where switching + /// to that mode's slot is exactly what the feature promises. + pub(crate) fn set_theme_follow_system( + &mut self, + on: bool, + window: &mut Window, + cx: &mut Context, + ) { + if on { + let manual = cx.global::().theme_preset.clone(); + let manual_dark = crate::ui::presets::by_id(cx, &manual).dark; + let cfg = cx.global_mut::(); + cfg.theme_follow_system = true; + if manual_dark { + cfg.theme_preset_dark = manual; + } else { + cfg.theme_preset_light = manual; + } + } else { + // Resolve while following is still on (the pin is released, so + // this reads the real OS appearance). + let effective = crate::ui::theme::effective_preset_id(cx); + let cfg = cx.global_mut::(); + cfg.theme_follow_system = false; + cfg.theme_preset = effective; + } + self.after_theme_change(window, cx); + // Re-aim an open picker panel at a slot that exists in the new mode — + // after the apply above, so `system_dark` reads the unpinned OS value. + let slot = if on { + if crate::ui::theme::system_dark(cx) { + crate::ui::settings::ThemeSlot::Dark + } else { + crate::ui::settings::ThemeSlot::Light + } + } else { + crate::ui::settings::ThemeSlot::Manual + }; + if let Some(s) = self.active_settings_mut() { + s.theme_panel_slot = slot; + } + } + + /// The shared tail of every theme-selection change: repaint, persist, and + /// keep the dependent Settings widgets in step. + fn after_theme_change(&mut self, window: &mut Window, cx: &mut Context) { apply_theme(Some(window), cx); set_menus(cx); cx.global::().save(); @@ -1208,10 +1338,21 @@ impl Tty7App { cx.notify(); } - /// Show/hide the theme picker panel beside the Appearance page. - pub(crate) fn toggle_theme_panel(&mut self, cx: &mut Context) { + /// Show/hide the theme picker panel beside the Appearance page. Clicking + /// the card whose slot the open panel already targets closes it; clicking + /// another card re-aims the open panel at that slot. + pub(crate) fn toggle_theme_panel( + &mut self, + slot: crate::ui::settings::ThemeSlot, + cx: &mut Context, + ) { if let Some(s) = self.active_settings_mut() { - s.theme_panel_open = !s.theme_panel_open; + if s.theme_panel_open && s.theme_panel_slot == slot { + s.theme_panel_open = false; + } else { + s.theme_panel_open = true; + s.theme_panel_slot = slot; + } cx.notify(); } } @@ -1237,7 +1378,7 @@ impl Tty7App { /// and open the color editor on it. This is the entry point for customizing a /// read-only built-in (or an imported iTerm scheme). pub(crate) fn fork_active_theme(&mut self, window: &mut Window, cx: &mut Context) { - let id = cx.global::().theme_preset.clone(); + let id = crate::ui::theme::effective_preset_id(cx); let theme = crate::ui::presets::by_id(cx, &id); match crate::ui::presets::fork_to_file(&theme) { Ok(new_id) => { @@ -1258,7 +1399,7 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - let id = cx.global::().theme_preset.clone(); + let id = crate::ui::theme::effective_preset_id(cx); let mut theme = crate::ui::presets::by_id(cx, &id); if !theme.editable() { return; @@ -1300,7 +1441,7 @@ impl Tty7App { /// set, else the active theme's own value, else fully opaque. pub(crate) fn effective_window_opacity(cx: &App) -> f32 { let config = cx.global::(); - let theme = crate::ui::presets::by_id(cx, &config.theme_preset); + let theme = crate::ui::presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx)); config.window_opacity.or(theme.opacity).unwrap_or(1.0) } @@ -1427,7 +1568,7 @@ impl Tty7App { if self.settings.is_none() { return; } - let id = cx.global::().theme_preset.clone(); + let id = crate::ui::theme::effective_preset_id(cx); let theme = crate::ui::presets::by_id(cx, &id); if !theme.editable() { if let Some(s) = self.settings.as_mut() { @@ -2016,6 +2157,14 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.copy_on_select = on); } + pub(crate) fn set_smart_select(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.smart_select = on); + } + + pub(crate) fn set_tab_completion(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.tab_completion = on); + } + pub(crate) fn set_startup_mode( &mut self, mode: crate::core::config::StartupMode, @@ -2074,6 +2223,24 @@ impl Tty7App { window.focus(&handle, cx); } + /// Ask every pane in the window to re-probe its git status. Called when the + /// window regains focus: the sidebar shows a git line for *every* tab, not + /// just the active one, so refreshing only the focused pane would leave the + /// rest of the list stale — which is exactly the list the user is scanning + /// right after switching back. + /// + /// Panes sharing a cwd fold into one probe in the shared cache, and the + /// throttle there counts per repo rather than per cwd, so once the cache + /// knows where each pane lives the cost of a window with many panes is + /// bounded by the number of distinct repos — not by the number of + /// subdirectories they happen to sit in, which would be the same full-repo + /// `git diff` asked several times over. + fn refresh_git_status_all(&mut self, cx: &mut Context) { + for leaf in self.tabs.iter().flat_map(|tab| tab.pane.leaves()) { + leaf.update(cx, |view, cx| view.refresh_git_status_now(cx)); + } + } + /// Where a freshly opened tab should be inserted, per `new_tab_position`: /// right after the active tab, or appended at the end. Clamped to the tab /// count so the zero-tab home state (active 0, no tabs) inserts at 0. @@ -2097,11 +2264,14 @@ impl Tty7App { cx: &mut Context, ) { // Inherit the cwd of the active tab's focused terminal so the new tab - // opens in the same directory the user is currently working in. + // opens in the same directory the user is currently working in. Local + // cwds only: the new tab is a local shell, and an inherited cwd wins + // over every fallback in `pane::initial_working_directory`, so a remote + // path would be handed straight to the spawn as a working directory. let cwd = self.tabs.get(self.active).and_then(|t| { t.pane .focused_or_first(window, cx) - .and_then(|leaf| leaf.read(cx).cwd()) + .and_then(|leaf| leaf.read(cx).local_cwd()) }); let tab = new_terminal(self.font_size, cwd, None, shell, window, cx); // Leaving the current tab for the new one; snapshot its focused pane @@ -2196,8 +2366,10 @@ impl Tty7App { }; // The new pane inherits the cwd — and the shell, when the pane being // split was opened with an explicit pick (a WSL/fish tab splits into - // more WSL/fish, not back to the default). - let cwd = target.read(cx).cwd(); + // more WSL/fish, not back to the default). Local cwds only: the + // native-SSH branch below has the daemon discard it regardless, and the + // local branch would otherwise spawn against a remote path. + let cwd = target.read(cx).local_cwd(); // Splitting a native-SSH pane opens another SSH pane on the same // connection rather than dropping back to a local shell. Re-resolve the // persisted (secret-free) spec from its saved profile so keychain @@ -2501,7 +2673,7 @@ impl Tty7App { // Capture the tab's cwd *before* its panes are killed (the daemon can't // report it afterwards): if it sat in a tty7-managed worktree, the // cleanup offer below needs it. - let worktree_cwd = self.tab_cwd(index, window, cx); + let worktree_cwd = self.tab_local_cwd(index, window, cx); // Snapshot the tab (layout + each pane's current cwd + name) onto the // recently-closed stack so Cmd+Shift+T can bring it back. let snapshot = tab_to_session(&self.tabs[index], cx); @@ -2548,11 +2720,14 @@ impl Tty7App { let Some(cwd) = cwd else { return }; // Every leaf of every surviving tab, not just focused panes — a shell // tucked away in a split occupies the worktree all the same. + // Local paths only: this list is what stops a worktree being removed + // out from under a live shell, and a remote cwd can neither occupy a + // local worktree nor be compared against one meaningfully. let open_cwds: Vec = self .tabs .iter() .flat_map(|tab| tab.pane.leaves()) - .filter_map(|leaf| leaf.read(cx).cwd()) + .filter_map(|leaf| leaf.read(cx).local_cwd()) .collect(); cx.spawn(async move |this, cx| { let Some(wt) = cx @@ -2646,6 +2821,30 @@ impl Tty7App { } } + /// "Mark as Unread" (tab context menu): re-flag every finished (`Done`) + /// agent turn in the tab as unread, so the avatar's green dot swells back + /// into its count badge until the user next looks at those panes. The + /// active tab's focus target is told the dismissed menu is about to hand + /// focus back to it, so that focus-in doesn't immediately re-read the mark + /// (see `TerminalView::mark_agent_result_unread`). + pub(crate) fn mark_tab_unread(&mut self, index: usize, cx: &mut Context) { + use crate::core::cli_agent::AgentStatus; + let Some(tab) = self.tabs.get(index) else { + return; + }; + let refocus = (index == self.active).then(|| tab.focus_target()).flatten(); + for leaf in tab.pane.leaves() { + let refocus_incoming = refocus.as_ref() == Some(&leaf); + leaf.update(cx, |view, cx| { + if view.agent_session().map(|s| s.status) == Some(AgentStatus::Done) { + view.mark_agent_result_unread(refocus_incoming); + cx.notify(); + } + }); + } + cx.notify(); + } + /// The cwd of the tab's label-driving terminal (focused leaf, else first) — /// what the tab context menu's "Copy Working Directory" copies and "New /// Worktree Tab" derives the repo from. @@ -2662,6 +2861,18 @@ impl Tty7App { .and_then(|leaf| leaf.read(cx).cwd()) } + /// [`tab_cwd`](Self::tab_cwd) restricted to a directory on this machine — + /// for the worktree operations, which shell out to a local `git`. "Copy + /// Working Directory" deliberately keeps using `tab_cwd`: copying a remote + /// pane's remote path is exactly what the user wants there. + fn tab_local_cwd(&self, index: usize, window: &Window, cx: &App) -> Option { + self.tabs + .get(index)? + .pane + .focused_or_first(window, cx) + .and_then(|leaf| leaf.read(cx).local_cwd()) + } + /// "New Worktree Tab": probe the repository containing the tab's cwd for /// defaults (a fresh generated name, the current branch as start point) on /// the background executor, then open the confirmation sheet @@ -2673,7 +2884,7 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - let Some(cwd) = self.tab_cwd(index, window, cx) else { + let Some(cwd) = self.tab_local_cwd(index, window, cx) else { window.push_notification("This tab has no working directory yet", cx); return; }; @@ -2713,33 +2924,25 @@ impl Tty7App { cx.notify(); } - /// Reorder tabs: move the tab at `from` to position `to` (drag-and-drop). - /// Keeps the same tab active across the move and re-persists the session. - pub(crate) fn move_tab(&mut self, from: usize, to: usize, cx: &mut Context) { - if from == to || from >= self.tabs.len() || to >= self.tabs.len() { + /// Rearrange the whole tab vector into `order` (old indices in their new + /// order) — the single path by which a drag-reorder lands, used by the + /// sidebar where a single visual move can imply a larger permutation + /// (relocating a tab inside its group without disturbing the group order, + /// or moving an entire group). Keeps the same tab active and re-persists. + pub(crate) fn apply_tab_order(&mut self, order: &[usize], cx: &mut Context) { + if order.len() != self.tabs.len() || order.iter().enumerate().all(|(i, &o)| i == o) { return; } - // Reordering shifts indices; a pending rename keyed on a fixed index would - // commit onto the wrong tab. Drop it. + // Reordering shifts indices: a rename pending on a fixed one would + // commit onto the wrong tab. self.renaming = None; let was_active = self.active; - let tab = self.tabs.remove(from); - self.tabs.insert(to, tab); - // Re-derive the active index so the same logical tab stays selected: - // removal shifts indices after `from` left, insertion shifts indices at - // or after `to` right. - self.active = if was_active == from { - to - } else { - let mut a = was_active; - if from < a { - a -= 1; - } - if to <= a { - a += 1; - } - a - }; + let mut slots: Vec> = std::mem::take(&mut self.tabs) + .into_iter() + .map(Some) + .collect(); + self.tabs = order.iter().filter_map(|&i| slots[i].take()).collect(); + self.active = order.iter().position(|&i| i == was_active).unwrap_or(0); self.save_session(cx); cx.notify(); } @@ -3050,7 +3253,10 @@ impl Tty7App { .tabs .get(self.active) .and_then(|t| t.pane.focused_or_first(window, cx)) - .and_then(|view| view.read(cx).cwd()); + // Local `git` shell-out, so a remote pane's cwd is not usable — + // it reports "no known directory" rather than silently diffing + // whatever the path collides with locally. + .and_then(|view| view.read(cx).local_cwd()); let Some(cwd) = cwd else { crate::terminal::notify_desktop(Some("tty7"), "This pane has no known directory."); return; @@ -3059,9 +3265,9 @@ impl Tty7App { // which is what a review pass wants. Both invocations are quick; the // prompt builder caps runaway diffs. let run = |args: &[&str]| { - std::process::Command::new("git") - .args(args) - .current_dir(&cwd) + let mut cmd = std::process::Command::new("git"); + cmd.args(args).current_dir(&cwd); + crate::core::proc::hide_console(&mut cmd) .output() .ok() .filter(|o| o.status.success()) @@ -3098,6 +3304,7 @@ impl Tty7App { self.build_font_selects(&mut subs, window, cx); let (shell_program_input, shell_args_input, wd_path_input) = self.build_shell_inputs(&mut subs, window, cx); + let link_file_command_input = self.build_link_file_command_input(&mut subs, window, cx); let scroll_slider = self.build_scroll_slider(&mut subs, window, cx); let window_opacity_slider = self.build_window_opacity_slider(&mut subs, window, cx); // Live filter for the theme picker panel; each keystroke re-renders the @@ -3133,10 +3340,12 @@ impl Tty7App { shell_program_input, shell_args_input, wd_path_input, + link_file_command_input, scroll_slider, window_opacity_slider, theme_editor: None, theme_panel_open: false, + theme_panel_slot: crate::ui::settings::ThemeSlot::Manual, theme_search, recording: None, rebinding_note: None, @@ -3318,6 +3527,59 @@ impl Tty7App { (shell_program_input, shell_args_input, wd_path_input) } + /// File-open command template input (Links section), committing on Enter/blur. + fn build_link_file_command_input( + &mut self, + subs: &mut Vec, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let value = cx + .global::() + .link_file_command + .clone() + .unwrap_or_default(); + let input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("open in default app") + .default_value(value) + }); + subs.push( + cx.subscribe_in(&input, window, move |this, _i, ev, _w, cx| { + if matches!(ev, InputEvent::PressEnter { .. } | InputEvent::Blur) { + this.commit_link_file_command(cx); + } + }), + ); + input + } + + /// Persist the file-open command template from the Links settings input. An + /// empty value clears the override (falls back to the built-in open). + fn commit_link_file_command(&mut self, cx: &mut Context) { + let Some(command) = self.active_settings().map(|s| { + s.link_file_command_input + .read(cx) + .value() + .trim() + .to_string() + }) else { + return; + }; + let command = if command.is_empty() { + None + } else { + Some(command) + }; + let cfg = cx.global_mut::(); + if cfg.link_file_command == command { + return; // no change — avoid a redundant disk write on every Blur + } + cfg.link_file_command = command; + cfg.save(); + cx.notify(); + } + /// Window-opacity slider for the Appearance page (20%–100%). Emits `Change` /// continuously as the user drags; each tick sets the global override and /// repaints, so the translucency is live under the thumb. @@ -3718,8 +3980,14 @@ impl Tty7App { } }; Some(rgb) - } else if v.remote_context().is_some() { - // A foreground `ssh` typed into a shell: a plain neutral dot. + } else if v + .remote_context() + .is_some_and(|r| r.kind != crate::daemon::protocol::RemoteKind::Wsl) + { + // A foreground `ssh` typed into a shell: a plain neutral dot. The + // kind check matters: a WSL pane also carries a `RemoteContext` (so + // its cwd is treated as foreign — see `local_cwd`), but it is not an + // SSH session and this dot means "SSH". Some(0x9CA3AF) } else { None @@ -3777,6 +4045,11 @@ impl Tty7App { cx.notify(); } + /// The focused pane when it is an SSH session of either kind. + /// + /// Not every pane carrying a `RemoteContext` is one: a WSL pane has one too, + /// so that its cwd is treated as foreign (see `TerminalView::local_cwd`), + /// and it must not reach anything SSH-shaped from here. pub(crate) fn active_ssh_pane( &self, window: &Window, @@ -3788,7 +4061,8 @@ impl Tty7App { .pane .focused_or_first(window, cx)?; let pane = pane.read(cx); - Some((pane.pane_id, pane.remote_context()?)) + let remote = pane.remote_context()?; + (remote.kind != crate::daemon::protocol::RemoteKind::Wsl).then_some((pane.pane_id, remote)) } /// The focused pane when it is a *connected native* SSH session — the gate for @@ -4160,6 +4434,32 @@ impl Tty7App { impl Render for Tty7App { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // A live drag-reorder commits when the drag *ends*, which in gpui means + // the mouse was released (nothing else clears an active drag): the first + // frame without one retires the preview and applies the order it was + // last showing. Deliberately not an `on_drop` handler — those only fire + // when the pointer is over that particular element at release, so a + // release a hair outside the rail or the strip would silently lose the + // move. What you were looking at is what you get, wherever you let go. + // One place at the root covers every surface that can start a drag. + if cx.has_active_drag() { + // Still dragging: forget last frame's answer so only what this + // frame actually draws can be committed (see `clear_pending`). + crate::ui::reorder::clear_pending(&self.reorder); + } else if let Some(order) = crate::ui::reorder::take_pending(&self.reorder) { + self.apply_tab_order(&order, cx); + } + // While a tab or group is in hand, the cursor is a closed hand for the + // whole window. It has to be set on the *drag* rather than styled on + // the element: gpui overrides every hovered element's cursor with the + // active drag's for the duration, and that override is `None` — a plain + // arrow — unless something fills it in. Set once per drag (it forces a + // refresh, so re-setting it every frame would spin). + if self.reorder.borrow().is_some() + && cx.active_drag_cursor_style() != Some(gpui::CursorStyle::ClosedHand) + { + cx.set_active_drag_cursor_style(gpui::CursorStyle::ClosedHand, window); + } // Vertical-tab mode: the sidebar owns the tab list, so the title-bar strip // drops its chips (keeping only "+"/"⋯"). Gated on having tabs — the // zero-tab home page keeps the full-width horizontal layout, so an empty @@ -4627,7 +4927,13 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { Pane::Leaf(view) => { let view = view.read(cx); SessionPane::Leaf { - cwd: view.cwd(), + // Local cwd only. A restored pane whose daemon pane is gone + // respawns on the *default local shell* (a shell pick isn't + // persisted), so a remote cwd would come back paired with a + // local shell that cannot chdir into it. Native-SSH panes + // reconnect from `ssh_spec` and the daemon discards the cwd + // for them anyway (`server::SpawnNativeSsh`). + cwd: view.local_cwd(), pane_id: Some(view.pane_id), // Persist the secret-free native-SSH spec so a *dead* pane can be // reconnected on restore (FR-E4/C2); `None` for local panes. A @@ -4635,9 +4941,12 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { ssh_spec: view.ssh_spec(), // The running agent + its native session id (when its hooks // reported one), so a pane the daemon loses can resume the - // agent conversation instead of just reopening a shell. + // agent conversation instead of just reopening a shell. The + // observed launch argv rides along so the resume command keeps + // the user's flags (`--dangerously-skip-permissions`, …). agent: view.agent(), agent_session_id: view.agent_session().and_then(|s| s.session_id), + agent_launch_argv: view.agent_session().and_then(|s| s.launch_argv), } } Pane::Split { @@ -4659,6 +4968,7 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { ssh_spec: None, agent: None, agent_session_id: None, + agent_launch_argv: None, }, } } @@ -4730,6 +5040,7 @@ fn session_to_pane( ssh_spec, agent, agent_session_id, + agent_launch_argv, } => { // Only restore the pane id when the daemon confirms it's still live; // a stale id (daemon restarted, pane killed) falls back to a spawn. @@ -4761,7 +5072,7 @@ fn session_to_pane( if restore.is_none() && cx.global::().restore_agent_sessions && let (Some(agent), Some(id)) = (agent, agent_session_id) - && let Some(cmd) = agent.resume_command(id) + && let Some(cmd) = agent.resume_command(id, agent_launch_argv.as_deref()) { view.read(cx).run_command_line(&cmd); } diff --git a/src/ui/home.rs b/src/ui/home.rs index 08582642..e6c1cef3 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -187,6 +187,7 @@ mod tests { ssh_spec: None, agent: None, agent_session_id: None, + agent_launch_argv: None, } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index c84b8d59..5d5757fd 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -19,6 +19,7 @@ pub mod palette; pub mod pane; pub mod perf; pub mod presets; +pub mod reorder; pub mod right_panel; pub mod settings; pub mod sftp; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index ed8b4cbe..906c1253 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -21,7 +21,6 @@ use gpui_component::{ use uuid::Uuid; -use crate::core::config::Config; use crate::core::ssh_profile::parse_quick_connect; /// What a command actually does. Most variants map to an existing `Tty7App` @@ -272,7 +271,7 @@ impl Command { /// preset. The active theme is marked with a check so the list doubles as a /// "which theme am I on?" indicator. pub fn theme_commands(cx: &App) -> Vec { - let active = cx.global::().theme_preset.clone(); + let active = crate::ui::theme::effective_preset_id(cx); crate::ui::presets::all(cx) .into_iter() .enumerate() diff --git a/src/ui/reorder.rs b/src/ui/reorder.rs new file mode 100644 index 00000000..57df3049 --- /dev/null +++ b/src/ui/reorder.rs @@ -0,0 +1,560 @@ +//! Live drag-to-reorder — the "the list gets out of your way while you drag" +//! behaviour behind the tab strip's chips, the sidebar's rows and the sidebar's +//! group headers. +//! +//! Nothing floats above the window: the item you're dragging stays in the +//! list, dimmed, and travels by the list rearranging around it. gpui's drag +//! system is used only for what it's good at here — knowing a drag is live and +//! redrawing every mouse move — while its floating preview renders nothing. +//! What it doesn't offer at all is any way to know *where* the cursor is +//! mid-drag from inside a drop target: only a `drag_over` style and a final +//! `on_drop`, which is the "one tile swaps with another on release" model this +//! module replaces. Here the surface reads [`Window::mouse_position`] every +//! frame, asks [`Reorder`] where the dragged slot belongs *right now*, and +//! renders the list in that order, so what you see is already the result. +//! +//! The shape of it: +//! +//! | Step | Who | What | +//! |---|---|---| +//! | Measure | every slot, every frame | its own bounds into a per-frame cell | +//! | Freeze | `on_drag` | that cell becomes [`Reorder::rects`] for the whole drag | +//! | Preview | the surface, per frame | [`Reorder::target`] → [`Reorder::order`] | +//! | Track | the held slot | [`Reorder::held_offset`] → follows the cursor | +//! | Slide | each displaced slot | [`Reorder::flip_offset`] → animate to zero | +//! | Record | the surface, per frame | [`set_pending`] — the order a release would give | +//! | Commit | the root, on drag end | [`take_pending`] → `Tty7App::apply_tab_order` | +//! +//! **Geometry is frozen at drag start on purpose.** The preview reflow moves +//! the very slots the hit-testing reads, so measuring live would let the list +//! feed back into its own input and oscillate under a still cursor. Freezing +//! also means a mid-drag scroll isn't tracked — a deliberate trade for a rail +//! whose rows are a few dozen pixels tall. + +use gpui::{Axis, Bounds, Pixels, Point, px}; +use std::cell::{Cell, RefCell}; +use std::path::PathBuf; +use std::rc::Rc; + +/// The app-wide slot for the one drag that can be live at a time. Shared by +/// `Rc` because the `on_drag` that opens it only gets `&mut App`. +pub(crate) type ReorderState = Rc>>; + +/// Everything a surface needs to draw one frame of a live reorder. +pub(crate) struct Preview { + /// Slot indices in the order to render them. + pub(crate) order: Vec, + /// The slot the held item currently occupies — where a release right now + /// would land it. + pub(crate) target: usize, + /// The slot being dragged. It stays in the list like any other — the drag + /// is drawn by the list rearranging, not by a card floating over it — and + /// only wears a "picked up" dimming so you can see which one you have. + pub(crate) from: usize, + /// Bumped whenever the preview order changes; part of each slot's + /// animation id so a slide restarts rather than resuming. + pub(crate) generation: usize, + /// Per slot (indexed by its *frozen* index), the offset to start this + /// frame at so it slides into place. Zero for everything the last change + /// didn't touch, and unused for [`Self::from`] — the held item doesn't + /// slide, it tracks. + pub(crate) offsets: Vec, + /// Where to draw the held item relative to the slot it's laid out in, so + /// it follows the cursor continuously instead of hopping slot to slot. + pub(crate) held: Pixels, +} + +/// This frame's preview of `surface`, if that's where the live drag started +/// and its frozen geometry still describes a list of `len` slots. +pub(crate) fn preview( + state: &ReorderState, + surface: &Surface, + len: usize, + pointer: Point, +) -> Option { + let state = state.borrow(); + let r = state.as_ref().filter(|r| r.covers(surface, len))?; + let target = r.target(pointer); + let (generation, prev) = r.begin_frame(target); + Some(Preview { + order: r.order(target), + target, + from: r.from, + generation, + offsets: (0..len) + .map(|slot| r.flip_offset(slot, prev, target)) + .collect(), + held: r.held_offset(pointer, target), + }) +} + +/// Record the tab order the current preview implies, so releasing the mouse +/// applies exactly what the user was looking at. +/// +/// The surface computes this every frame while it draws the preview, rather +/// than a drop handler working it out on release, because a drop handler only +/// fires when the pointer is over *that element* at release — release a hair +/// above the rail (easy when dragging a row upward) and the move would be +/// silently lost, the list snapping back. The drag ending is the commit, and +/// where the cursor happens to be at that moment doesn't enter into it. +pub(crate) fn set_pending(state: &ReorderState, surface: &Surface, order: Vec) { + if let Some(r) = state.borrow().as_ref().filter(|r| r.surface == *surface) { + *r.pending.borrow_mut() = Some(order); + } +} + +/// Forget the order recorded by the previous frame, at the start of every +/// frame a drag is live — the surfaces re-record it as they draw. +/// +/// Without this the recording is a high-water mark rather than a snapshot: drag +/// a row down and back to its own slot and the surface stops recording (the +/// move is a no-op), so a stale "moved" order would survive to be committed by +/// a release the user made after visibly putting the row back. Same for a frame +/// where the drag's frozen geometry no longer matches the list ([`Reorder::covers`] +/// — a tab closed, or a git probe moved one to another group): nothing is drawn +/// and so nothing may commit. +pub(crate) fn clear_pending(state: &ReorderState) { + if let Some(r) = state.borrow().as_ref() { + r.pending.borrow_mut().take(); + } +} + +/// Take the recorded order out of a finished drag — see [`set_pending`]. +pub(crate) fn take_pending(state: &ReorderState) -> Option> { + state.borrow_mut().take()?.pending.into_inner() +} + +/// Which of the app's reorderable lists a drag belongs to. The sidebar rail +/// holds two at once — the rows inside a group, and the group blocks +/// themselves — so a surface asking "is this drag mine?" needs more than +/// "am I the sidebar". Rows carry their group's repo root (`None` = Scratch) +/// because a row drag must never reflow a sibling group: a tab's group comes +/// from its cwd, not from where it sits in the list. +#[derive(Clone, PartialEq, Eq, Debug)] +pub(crate) enum Surface { + /// The horizontal title-bar strip. Display order is plain tab order. + Strip, + /// The rows of one sidebar group. + SidebarRows(Option), + /// The sidebar's group blocks (header + its rows), dragged by the header. + SidebarGroups, +} + +/// One live drag-reorder. Created when gpui starts a drag, read by the surface +/// on every frame until the drop, then dropped. +pub(crate) struct Reorder { + /// The list this drag belongs to; a surface ignores state that isn't its own. + pub(crate) surface: Surface, + /// Index of the dragged slot in the frozen order. + pub(crate) from: usize, + /// Every slot's bounds in display order, as measured on the last frame + /// before the drag started (see the module docs on why they're frozen). + rects: Vec>, + /// The axis the list runs along — vertical for the rail, horizontal for the strip. + axis: Axis, + /// The list's gap between slots, so a displaced slot's shift matches what + /// the layout will actually do. + gap: Pixels, + /// Where inside the dragged slot the pointer grabbed it, so the slot's + /// position is derived from the cursor exactly as gpui's floating preview is. + grab: Point, + /// The target the previous frame drew, and a counter bumped whenever it + /// changes. The slide-in animation keys its element id off the counter, so + /// a slot that has just been displaced restarts its slide instead of + /// resuming a finished one. + prev: Cell, + generation: Cell, + /// The tab order releasing right now would produce, refreshed every frame + /// by the surface drawing the preview. See [`set_pending`]. + pending: RefCell>>, +} + +impl Reorder { + pub(crate) fn new( + surface: Surface, + from: usize, + rects: Vec>, + axis: Axis, + gap: Pixels, + grab: Point, + ) -> Self { + Self { + surface, + from, + rects, + axis, + gap, + grab, + prev: Cell::new(from), + generation: Cell::new(0), + pending: RefCell::new(None), + } + } + + /// True when this state belongs to `surface` and its frozen geometry still + /// describes a list of `len` slots — a tab closing mid-drag, or the git + /// probe moving a tab to another group, invalidates it rather than letting + /// stale indices reorder the wrong thing. + pub(crate) fn covers(&self, surface: &Surface, len: usize) -> bool { + self.surface == *surface && self.rects.len() == len && self.from < len + } + + /// The scalar component along the list's axis. + fn along(&self, p: Point) -> Pixels { + match self.axis { + Axis::Vertical => p.y, + Axis::Horizontal => p.x, + } + } + + /// A slot's extent along the list's axis. + fn extent(&self, b: &Bounds) -> Pixels { + match self.axis { + Axis::Vertical => b.size.height, + Axis::Horizontal => b.size.width, + } + } + + /// How far a slot moves when the dragged one passes it: the dragged slot's + /// extent plus the gap it also takes with it. + fn shift(&self) -> Pixels { + self.extent(&self.rects[self.from]) + self.gap + } + + /// Where the dragged slot belongs for a cursor at `pointer`: how many of + /// the other slots would sit before it. + /// + /// A neighbour yields once the held item covers half of it — its *trailing* + /// edge past that neighbour's centre going forward, its *leading* edge past + /// it going back. Edges rather than the held item's own centre, because the + /// two are only equivalent when everything is the same size: in the rail a + /// three-row group block is twice a one-row block, and a centre-to-centre + /// test would demand the tall block's middle reach the short one's middle — + /// pointer travel that runs off the top of the list, which is exactly the + /// "big group won't move up" case. Half-overlap asks the same of both + /// directions and of any pair of sizes. + /// + /// The comparison is against the *frozen* centres, never the reflowed ones, + /// so the reflow can't move the number it's being compared to and the + /// crossing can't chase itself under a still cursor. + pub(crate) fn target(&self, pointer: Point) -> usize { + let leading = self.free_origin(pointer); + let trailing = leading + self.extent(&self.rects[self.from]); + self.rects + .iter() + .enumerate() + .filter(|(i, _)| *i != self.from) + .filter(|(i, r)| { + let centre = self.along(r.origin) + self.extent(r) / 2.; + if *i < self.from { + // Still above the held item: it hasn't reached back this far. + leading >= centre + } else { + trailing > centre + } + }) + .count() + } + + /// Where the dragged slot's leading edge is, following the pointer without + /// limit: the cursor less where inside the slot it was grabbed, so the item + /// sits under the cursor exactly where you picked it up. + fn free_origin(&self, pointer: Point) -> Pixels { + self.along(pointer) - self.along(self.grab) + } + + /// [`Self::free_origin`] confined to the list's own span, so dragging far + /// past either end parks the item against that end instead of sending it + /// off across the window. Only the *drawing* is clamped — [`Self::target`] + /// reads the free position, so pushing past the last slot still selects it. + fn held_origin(&self, pointer: Point) -> Pixels { + let first = self.along(self.rects[0].origin); + let last = self.rects.last().expect("non-empty"); + let end = self.along(last.origin) + self.extent(last); + self.free_origin(pointer) + .clamp(first, end - self.extent(&self.rects[self.from])) + } + + /// The offset to draw the dragged slot at so it tracks the pointer: the + /// distance from where the list has *laid it out* this frame (its slot + /// under `target`) to where the cursor is actually holding it. + /// + /// This is what makes the drag feel attached rather than stepwise — the + /// held item moves pixel-for-pixel with the mouse, and the reflow of the + /// others is the only thing that snaps. + pub(crate) fn held_offset(&self, pointer: Point, target: usize) -> Pixels { + let home = self.along(self.rects[self.from].origin); + self.held_origin(pointer) - (home + self.displacement(self.from, target)) + } + + /// Slot indices in preview order: the dragged one lifted out of `from` and + /// dropped back in at `target`. + pub(crate) fn order(&self, target: usize) -> Vec { + let mut order: Vec = (0..self.rects.len()).collect(); + let dragged = order.remove(self.from); + order.insert(target.min(order.len()), dragged); + order + } + + /// Open a frame previewing `target`: returns the animation generation to + /// key slide-ins on, and the target the previous frame drew — which + /// [`Self::flip_offset`] measures the slide from. Call once per frame, + /// before laying the slots out. + pub(crate) fn begin_frame(&self, target: usize) -> (usize, usize) { + let prev = self.prev.get(); + if prev != target { + self.generation.set(self.generation.get() + 1); + self.prev.set(target); + } + (self.generation.get(), prev) + } + + /// Where the slot frozen at index `slot` sits under a given preview, + /// relative to its resting place. + /// + /// The displaced slots each close up by one dragged-slot pitch, in the + /// direction the drag came from. The dragged slot itself moves the other + /// way by everything it has jumped over — it stays in the list rather than + /// floating above it, so it has a position to be displaced to like anyone + /// else, and the two sides always add up to a swap. + fn displacement(&self, slot: usize, target: usize) -> Pixels { + if slot == self.from { + // Sum the pitches of the slots crossed, since rows differ in height. + let crossed = if target > self.from { + self.from + 1..=target + } else { + target..=self.from.saturating_sub(1) + }; + let span: Pixels = crossed + .filter(|&i| i != self.from && i < self.rects.len()) + .map(|i| self.extent(&self.rects[i]) + self.gap) + .fold(px(0.), |a, b| a + b); + if target > self.from { span } else { -span } + } else if self.from < slot && slot <= target { + -self.shift() + } else if target <= slot && slot < self.from { + self.shift() + } else { + px(0.) + } + } + + /// The offset a slot should *start* this frame at so it slides into its new + /// place instead of teleporting: where the last frame drew it, minus where + /// this frame puts it. Zero for every slot the new target didn't disturb — + /// which is all but one on a typical frame, so the list only animates the + /// row you just crossed. + pub(crate) fn flip_offset(&self, slot: usize, prev: usize, target: usize) -> Pixels { + self.displacement(slot, prev) - self.displacement(slot, target) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{point, size}; + + /// A vertical list of `n` slots, each `h` tall with a `gap` between them, + /// starting at y = 0 — the sidebar's shape. + fn column(n: usize, h: f32, gap: f32, from: usize) -> Reorder { + let rects = (0..n) + .map(|i| Bounds { + origin: point(px(0.), px(i as f32 * (h + gap))), + size: size(px(200.), px(h)), + }) + .collect(); + Reorder::new( + Surface::Strip, + from, + rects, + Axis::Vertical, + px(gap), + // Grabbed dead centre of the slot. + point(px(100.), px(h / 2.)), + ) + } + + /// The dragged slot claims a new index the moment its centre reaches where + /// the neighbour would sit without it, and holds its own index until then. + #[test] + fn target_follows_the_pointer_across_neighbours() { + // 4 rows of 30px + 2px gaps: centres at 15, 47, 79, 111. + let r = column(4, 30., 2., 0); + // Dragging row 0, held by its centre. Row 1 yields once row 0's bottom + // edge covers half of it — pointer 32, i.e. bottom edge at 47. + assert_eq!(r.target(point(px(100.), px(32.))), 0); + assert_eq!(r.target(point(px(100.), px(34.))), 1); + // Then row 2's centre (79) at pointer 64, row 3's (111) at 96. + assert_eq!(r.target(point(px(100.), px(66.))), 2); + assert_eq!(r.target(point(px(100.), px(200.))), 3); + } + + /// Dragging upward is the mirror image, and the order it previews is the + /// dragged slot lifted out and re-inserted. + #[test] + fn order_lifts_the_dragged_slot_into_the_target() { + let r = column(4, 30., 2., 3); + assert_eq!(r.target(point(px(100.), px(2.))), 0); + assert_eq!(r.order(0), vec![3, 0, 1, 2]); + assert_eq!(r.order(1), vec![0, 3, 1, 2]); + assert_eq!(r.order(3), vec![0, 1, 2, 3]); + } + + /// Only the slot the drag just crossed gets a slide offset, and it's the + /// full row pitch (row height + gap) in the direction it came from. + #[test] + fn flip_offset_animates_only_the_slot_just_crossed() { + let r = column(4, 30., 2., 0); + // Preview moved from "row 0 stays" to "row 0 sits after row 1": + // row 1 closed up by one pitch, so it starts one pitch lower. + assert_eq!(r.flip_offset(1, 0, 1), px(32.)); + // Rows the crossing didn't touch don't move at all. + assert_eq!(r.flip_offset(2, 0, 1), px(0.)); + assert_eq!(r.flip_offset(3, 0, 1), px(0.)); + // The dragged slot slides too, now that it rides in the list rather + // than floating over it: three rows crossed, three pitches to travel. + assert_eq!(r.flip_offset(0, 0, 3), px(-96.)); + assert_eq!(r.flip_offset(0, 3, 0), px(96.)); + // Backing out again slides row 1 the other way. + assert_eq!(r.flip_offset(1, 1, 0), px(-32.)); + } + + /// A tall block and a short one swap in *both* directions, with the same + /// half-overlap threshold. The regression this pins: under a + /// centre-to-centre test a three-row group could never move above a + /// one-row group, because reaching its centre meant dragging the pointer + /// off the top of the list. + #[test] + fn unequal_sizes_swap_in_both_directions() { + // A 60px block at y=0 and a 140px block at y=62 (2px gap). + let rects = vec![ + Bounds { + origin: point(px(0.), px(0.)), + size: size(px(200.), px(60.)), + }, + Bounds { + origin: point(px(0.), px(62.)), + size: size(px(200.), px(140.)), + }, + ]; + let grab = point(px(100.), px(10.)); + let tall = Reorder::new( + Surface::SidebarGroups, + 1, + rects.clone(), + Axis::Vertical, + px(2.), + grab, + ); + // Dragging the tall block up: it takes the top slot once its leading + // edge passes the short block's centre (30) — pointer 40, i.e. ~30px + // of travel from rest, all of it well inside the list. + assert_eq!(tall.target(point(px(100.), px(41.))), 1); + assert_eq!(tall.target(point(px(100.), px(39.))), 0); + // Held at rest, it keeps its own slot. + assert_eq!(tall.target(point(px(100.), px(72.))), 1); + + // And the short block still goes down past the tall one, at the same + // half-overlap rule: trailing edge (pointer + 50) past centre 132. + let short = Reorder::new( + Surface::SidebarGroups, + 0, + rects, + Axis::Vertical, + px(2.), + grab, + ); + assert_eq!(short.target(point(px(100.), px(80.))), 0); + assert_eq!(short.target(point(px(100.), px(84.))), 1); + } + + /// The held item tracks the pointer pixel for pixel, measured from + /// whichever slot the list has currently laid it out in — so it sits under + /// the cursor both before and after a crossing re-slots it. + #[test] + fn held_offset_tracks_the_pointer_across_a_crossing() { + // 4 rows of 30px + 2px gaps (pitch 32), grabbed dead centre of row 0. + let r = column(4, 30., 2., 0); + // Nudged 10px down, still target 0: the row is 10px off its home slot. + assert_eq!(r.held_offset(point(px(100.), px(25.)), 0), px(10.)); + // Just past row 1's centre the target flips to 1, and the row is now + // laid out one pitch lower — so the same pointer reads 32px less. + assert_eq!(r.held_offset(point(px(100.), px(48.)), 1), px(1.)); + // Dragging far past the end parks it against the last slot instead of + // running off: row 3 starts at 96, so that's the furthest it goes. + assert_eq!(r.held_offset(point(px(100.), px(900.)), 3), px(0.)); + } + + /// Only what the last drawn frame recorded may commit. The regression this + /// pins: dragging an item away and then back to its own slot stops the + /// surface recording (the move became a no-op), so without the per-frame + /// clear the earlier "moved" order would survive and be applied on release, + /// moving an item the user had visibly put back. + #[test] + fn pending_only_survives_the_frame_that_recorded_it() { + let state: ReorderState = Rc::new(RefCell::new(Some(column(3, 30., 2., 0)))); + let mine = Surface::Strip; + + // A frame that previews a move records it. + clear_pending(&state); + set_pending(&state, &mine, vec![1, 0, 2]); + // Another surface's recording is ignored — one drag, one owner. + set_pending(&state, &Surface::SidebarGroups, vec![2, 1, 0]); + + // The next frame draws the item back in its own slot and records + // nothing; the earlier order must not outlive it. + clear_pending(&state); + assert_eq!(take_pending(&state), None); + // Taking also retires the drag. + assert!(state.borrow().is_none()); + + // And the ordinary path: recorded, then released. + *state.borrow_mut() = Some(column(3, 30., 2., 0)); + clear_pending(&state); + set_pending(&state, &mine, vec![1, 0, 2]); + assert_eq!(take_pending(&state), Some(vec![1, 0, 2])); + } + + /// The generation only advances when the preview actually changes, so a + /// jittering cursor inside one slot doesn't restart the slide every frame. + #[test] + fn begin_frame_bumps_the_generation_only_on_change() { + let r = column(3, 30., 2., 0); + assert_eq!(r.begin_frame(0), (0, 0)); + assert_eq!(r.begin_frame(1), (1, 0)); + assert_eq!(r.begin_frame(1), (1, 1)); + assert_eq!(r.begin_frame(2), (2, 1)); + } + + /// A horizontal list measures along x — the strip's chips, which are wider + /// than they are tall and vary in width. + #[test] + fn horizontal_lists_measure_along_x() { + let widths = [100., 160., 120.]; + let mut x = 0.; + let rects = widths + .iter() + .map(|w| { + let b = Bounds { + origin: point(px(x), px(0.)), + size: size(px(*w), px(30.)), + }; + x += w + 6.; + b + }) + .collect(); + let r = Reorder::new( + Surface::Strip, + 0, + rects, + Axis::Horizontal, + px(6.), + point(px(50.), px(15.)), + ); + // Chip 1's centre is at x=186; chip 0 takes its slot once its trailing + // edge (pointer + 50) covers half of it. + assert_eq!(r.target(point(px(135.), px(15.))), 0); + assert_eq!(r.target(point(px(137.), px(15.))), 1); + assert_eq!(r.order(2), vec![1, 2, 0]); + } +} diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 457fde94..849dab67 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -94,7 +94,7 @@ fn settings_search_entries() -> &'static [SearchEntry] { SearchEntry { section: Appearance, title: "Theme", - keywords: "appearance color colours scheme dark light palette background foreground accent", + keywords: "appearance color colours scheme dark light palette background foreground accent sync system os auto follow", }, SearchEntry { section: Appearance, @@ -177,6 +177,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Forward SSH loopback links", keywords: "ssh remote port tunnel localhost forward", }, + SearchEntry { + section: Terminal, + title: "Smart selection", + keywords: "double click word url path select semantic", + }, SearchEntry { section: Terminal, title: "Copy on select", @@ -355,17 +360,23 @@ pub(crate) struct SettingsState { pub(crate) shell_args_input: Entity, /// Custom working-directory path (used when the strategy is `Custom`). pub(crate) wd_path_input: Entity, + /// Command template run when ⌘-clicking a file link (Links section). Empty + /// clears the override, restoring the built-in "open in default app". + pub(crate) link_file_command_input: Entity, /// Mouse-scroll multiplier slider (Terminal section). pub(crate) scroll_slider: Entity, /// Global window-opacity slider (Appearance's Window section). Shows the /// effective value; dragging sets the config override. pub(crate) window_opacity_slider: Entity, - /// The color editor for the active editable theme, or `None` when the active - /// theme is read-only (a built-in / import) or the system is being followed. + /// The color editor for the effective (on-screen) theme, or `None` when + /// that theme is read-only (a built-in / import). pub(crate) theme_editor: Option, /// Whether the theme picker panel is open beside the content pane - /// (Appearance section only). Toggled from the "Current theme" card. + /// (Appearance section only). Toggled from the theme card(s). pub(crate) theme_panel_open: bool, + /// Which theme choice the open picker panel writes to (see [`ThemeSlot`]). + /// Set by the card that opened the panel. + pub(crate) theme_panel_slot: ThemeSlot, /// Live filter for the theme picker panel's list. pub(crate) theme_search: Entity, /// `Some` while a Keybindings row is capturing a new shortcut: the action @@ -403,6 +414,16 @@ pub(crate) struct SettingsState { pub(crate) _subs: Vec, } +/// The theme choice a picker card / the picker panel targets. `Manual` is the +/// single `Config::theme_preset` (sync-with-system off); `Light` / `Dark` are +/// the two follow-system slots (`Config::theme_preset_light` / `_dark`). +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum ThemeSlot { + Manual, + Light, + Dark, +} + /// The SSH section's right-pane selection (see [`SettingsState::ssh_detail`]). #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum SshDetail { @@ -465,6 +486,7 @@ pub(crate) struct SshProfileForm { agent_forward: bool, x11: bool, skip_banner: bool, + shell_integration: bool, verify_host_keys: Option, warn_on_close: Option, @@ -1191,7 +1213,7 @@ impl Tty7App { "Pick a color theme. Each one sets its own light or dark look.", cx, )) - .child(self.render_current_theme(cx)) + .child(self.render_theme_selection(cx)) // Custom-theme management (duplicate / edit colors / open folder) is // *about* themes, so it lives with the picker rather than stranded at // the foot of the page after Cursor. @@ -1266,7 +1288,7 @@ impl Tty7App { }; let config = cx.global::(); let overridden = config.window_opacity.is_some() || config.window_blur.is_some(); - let theme = presets::by_id(cx, &config.theme_preset.clone()); + let theme = presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx)); let opacity = Tty7App::effective_window_opacity(cx); let blur = cx.global::().window_blur.unwrap_or(theme.blur); @@ -1349,7 +1371,7 @@ impl Tty7App { // The theme's current image, for the filename label and the // opacity readout (the slider owns its own thumb position). - let theme = presets::by_id(cx, &cx.global::().theme_preset.clone()); + let theme = presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx)); let image = theme.image.clone(); let image_name = image.as_ref().map(|i| { i.path @@ -1936,6 +1958,7 @@ impl Tty7App { agent_forward: profile.agent_forward, x11: profile.x11, skip_banner: profile.skip_banner, + shell_integration: profile.shell_integration, verify_host_keys: profile.verify_host_keys, warn_on_close: profile.warn_on_close, _subs: subs, @@ -1989,6 +2012,7 @@ impl Tty7App { connect_timeout_s: val(&form.connect_timeout).parse().ok(), warn_on_close: form.warn_on_close, skip_banner: form.skip_banner, + shell_integration: form.shell_integration, login_scripts: split_lines(&form.login_scripts.read(cx).value()), x11: form.x11, algorithms: Algorithms { @@ -2555,6 +2579,22 @@ impl Tty7App { cx, ), ) + .child( + self.settings_row( + "Shell integration", + "Let the remote shell report prompts, exit codes and directory.", + Switch::new("ssh-form-shell-integration") + .checked(form.shell_integration) + .on_click(cx.listener(|this, on: &bool, _w, cx| { + if let Some(f) = this.ssh_form_mut() { + f.shell_integration = *on; + cx.notify(); + } + })) + .into_any_element(), + cx, + ), + ) .child(text_row( self, "Login scripts", @@ -2750,6 +2790,8 @@ impl Tty7App { let clip_trim = cfg.clipboard_trim_trailing_spaces; let copy_on_select = cfg.copy_on_select; let mouse_reporting = cfg.mouse_reporting; + let smart_select = cfg.smart_select; + let tab_completion = cfg.tab_completion; let bell = cfg.bell; // Map the persisted threshold onto its preset radio index (nearest slot // for any off-preset value a hand-edit might leave). @@ -2775,6 +2817,10 @@ impl Tty7App { Some(s) => s.scroll_slider.clone(), None => return div().into_any_element(), }; + let link_file_command_input = match self.active_settings() { + Some(s) => s.link_file_command_input.clone(), + None => return div().into_any_element(), + }; let link_switch = Switch::new("term-link-url") .checked(link_url) @@ -2784,6 +2830,10 @@ impl Tty7App { .checked(ssh_loopback_forward) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_ssh_loopback_forward(*on, cx))) .into_any_element(); + let link_file_command_control = div() + .w(px(300.)) + .child(Input::new(&link_file_command_input).small()) + .into_any_element(); let scrollback_radio = self.segmented( "term-scrollback", &["1,000", "10,000", "100,000"], @@ -2835,6 +2885,14 @@ impl Tty7App { .checked(mouse_reporting) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_mouse_reporting(*on, cx))) .into_any_element(); + let smart_select_switch = Switch::new("term-smart-select") + .checked(smart_select) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smart_select(*on, cx))) + .into_any_element(); + let tab_completion_switch = Switch::new("term-tab-completion") + .checked(tab_completion) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_tab_completion(*on, cx))) + .into_any_element(); let bell_idx = match bell { BellMode::None => 0, BellMode::Visual => 1, @@ -2935,11 +2993,22 @@ impl Tty7App { mouse_report_switch, cx, )) - .when_some(option_alt_row, |v, row| { - v.child(self.section_rule(cx)) - .child(self.section_header("Keyboard", cx)) - .child(row) - }) + .child(self.settings_row( + "Smart selection", + "Double-click selects the whole URL, file path, email, or bracket pair under the cursor.", + smart_select_switch, + cx, + )) + .child(self.section_rule(cx)) + .child(self.section_header("Keyboard", cx)) + .child(self.settings_row( + "Tab completion", + "Tab at the prompt opens tty7's completion menu. When off, Tab goes to the \ + shell's own completion instead.", + tab_completion_switch, + cx, + )) + .when_some(option_alt_row, |v, row| v.child(row)) .child(self.section_rule(cx)) .child(self.section_header("Links", cx)) .child(self.settings_row( @@ -2954,6 +3023,14 @@ impl Tty7App { ssh_loopback_switch, cx, )) + .child(self.settings_row( + "Open files with", + "Command run when ⌘-clicking a file link, instead of the default app. \ + Use {path}, {line}, {column}; a flag whose value is absent is dropped \ + (e.g. herdr edit {path} --line={line}). Empty uses the default app.", + link_file_command_control, + cx, + )) .child(self.section_rule(cx)) .child(self.section_header("Clipboard", cx)) .child(self.settings_row( @@ -3018,7 +3095,7 @@ impl Tty7App { let (dot_color, status_text) = match state { HooksState::NotInstalled => (muted_fg, "Not installed"), HooksState::Installed => (success, "Installed"), - HooksState::Outdated => (warning, "Outdated — installed by another tty7 version"), + HooksState::Outdated => (warning, "Outdated"), }; // The primary action reads as what it will *do* from this state. let primary_label = match state { @@ -3031,8 +3108,11 @@ impl Tty7App { .filter(|(for_agent, _)| *for_agent == agent) .map(|(_, text)| text.clone()); + // items_end: the whole stack shares the row's right edge, so + // status, buttons, and note line up across every agent row. let control = v_flex() .gap_2() + .items_end() .child( h_flex() .gap_2() @@ -3062,8 +3142,18 @@ impl Tty7App { ) }), ) + // Width-capped so a long note (error text) wraps instead of + // inflating the shrink-proof control column and crushing the + // label to zero width. .when_some(row_note, |col, text| { - col.child(div().text_xs().text_color(muted_fg).child(text)) + col.child( + div() + .max_w_80() + .text_xs() + .text_right() + .text_color(muted_fg) + .child(text), + ) }) .into_any_element(); @@ -3277,11 +3367,39 @@ impl Tty7App { ) } - /// The compact "Current theme" card on the Appearance page: a preview of the - /// active theme beside its kind (built-in vs custom) and light/dark mode, - /// its name, and its six chromatic ANSI swatches; the whole row a click - /// target that opens the picker panel on the right. - fn render_current_theme(&self, cx: &mut Context) -> AnyElement { + /// The theme choice block on the Appearance page: the "Sync with system" + /// switch, then either the single manual-theme card or — while following + /// the OS — one card per light/dark slot. + fn render_theme_selection(&self, cx: &mut Context) -> AnyElement { + let follow = cx.global::().theme_follow_system; + let follow_switch = Switch::new("theme-follow-system") + .checked(follow) + .on_click(cx.listener(|this, on: &bool, window, cx| { + this.set_theme_follow_system(*on, window, cx) + })) + .into_any_element(); + let root = v_flex().child(self.settings_row( + "Sync with system", + "Follow the OS appearance with separate light and dark themes.", + follow_switch, + cx, + )); + if follow { + root.child(self.render_theme_card(ThemeSlot::Light, cx)) + .child(self.render_theme_card(ThemeSlot::Dark, cx)) + .into_any_element() + } else { + root.child(self.render_theme_card(ThemeSlot::Manual, cx)) + .into_any_element() + } + } + + /// One compact theme card: a preview of the slot's theme beside its caption + /// (kind + light/dark mode for the manual card, the slot's role for the + /// follow-system cards), its name, and its six chromatic ANSI swatches; the + /// whole row a click target that opens the picker panel on the right, + /// aimed at this slot. + fn render_theme_card(&self, slot: ThemeSlot, cx: &mut Context) -> AnyElement { let theme = cx.theme(); let border = theme.border; let foreground = theme.foreground; @@ -3289,16 +3407,36 @@ impl Tty7App { let hover_bg = theme.secondary.opacity(0.5); let surface = theme.secondary.opacity(0.28); - let active_id = cx.global::().theme_preset.clone(); + let config = cx.global::(); + let (card_id, active_id) = match slot { + ThemeSlot::Manual => ("theme-card-manual", config.theme_preset.clone()), + ThemeSlot::Light => ("theme-card-light", config.theme_preset_light.clone()), + ThemeSlot::Dark => ("theme-card-dark", config.theme_preset_dark.clone()), + }; let active = presets::by_id(cx, &active_id); let name = active.name.clone(); - let mode = if active.dark { "Dark" } else { "Light" }; // A user file (duplicated or dropped in the themes folder) vs a built-in. let kind = if active.path.is_some() { "Custom" } else { "Built-in" }; + let caption = match slot { + ThemeSlot::Manual => { + let mode = if active.dark { "Dark" } else { "Light" }; + format!("{kind} · {mode}") + } + // The slot cards are captioned by their role; the one matching the + // current OS appearance is the theme actually on screen. + ThemeSlot::Light if !crate::ui::theme::system_dark(cx) => { + format!("Light mode · {kind} · Active") + } + ThemeSlot::Light => format!("Light mode · {kind}"), + ThemeSlot::Dark if crate::ui::theme::system_dark(cx) => { + format!("Dark mode · {kind} · Active") + } + ThemeSlot::Dark => format!("Dark mode · {kind}"), + }; // The six chromatic ANSI slots (red…cyan) as tiny swatches — the part of // a theme the mini preview's few bars can't show, and what actually // distinguishes two same-background themes at a glance. @@ -3311,15 +3449,17 @@ impl Tty7App { .bg(rgb(to_u32(active.ansi16[i]))) })); let preview = self.theme_preview(&active); - let open = self.active_settings().is_some_and(|s| s.theme_panel_open); + let open = self + .active_settings() + .is_some_and(|s| s.theme_panel_open && s.theme_panel_slot == slot); div() - .id("current-theme") + .id(card_id) .mt_1() .mb_2() .w_full() .cursor_pointer() - .on_click(cx.listener(|this, _, _w, cx| this.toggle_theme_panel(cx))) + .on_click(cx.listener(move |this, _, _w, cx| this.toggle_theme_panel(slot, cx))) .child( h_flex() .items_center() @@ -3338,12 +3478,7 @@ impl Tty7App { .child( v_flex() .gap_0p5() - .child( - div() - .text_xs() - .text_color(muted_fg) - .child(format!("{kind} · {mode}")), - ) + .child(div().text_xs().text_color(muted_fg).child(caption)) .child( div() .text_sm() @@ -3379,14 +3514,34 @@ impl Tty7App { // as its own surface rather than an extension of the page. let bg = theme.sidebar; - let active_id = cx.global::().theme_preset.clone(); - let (search, query) = match self.active_settings() { + let (search, query, slot) = match self.active_settings() { Some(s) => ( s.theme_search.clone(), s.theme_search.read(cx).value().trim().to_lowercase(), + s.theme_panel_slot, ), None => return div().into_any_element(), }; + let config = cx.global::(); + // Guard against a slot that no longer exists in the current mode (the + // sync switch flipped while the panel was open re-aims it, but stale + // state must still render something sensible). + let slot = match (config.theme_follow_system, slot) { + (false, _) => ThemeSlot::Manual, + (true, ThemeSlot::Manual) => { + if crate::ui::theme::system_dark(cx) { + ThemeSlot::Dark + } else { + ThemeSlot::Light + } + } + (true, s) => s, + }; + let active_id = match slot { + ThemeSlot::Manual => config.theme_preset.clone(), + ThemeSlot::Light => config.theme_preset_light.clone(), + ThemeSlot::Dark => config.theme_preset_dark.clone(), + }; let header = h_flex() .items_center() @@ -3414,7 +3569,11 @@ impl Tty7App { .pb_3() .text_xs() .text_color(muted_fg) - .child("Change your current theme."); + .child(match slot { + ThemeSlot::Manual => "Change your current theme.", + ThemeSlot::Light => "Choose the theme for light mode.", + ThemeSlot::Dark => "Choose the theme for dark mode.", + }); // Plain text input, the same shape the Shell section uses — our own // field, not a bespoke pill. The Input fills its parent, but a percent @@ -3483,8 +3642,10 @@ impl Tty7App { s.child(Icon::new(IconName::Check).small().text_color(foreground)) }), ) - .on_click(cx.listener(move |this, _, window, cx| { - this.set_preset(&click_id, window, cx) + .on_click(cx.listener(move |this, _, window, cx| match slot { + ThemeSlot::Manual => this.set_preset(&click_id, window, cx), + ThemeSlot::Light => this.set_slot_preset(false, &click_id, window, cx), + ThemeSlot::Dark => this.set_slot_preset(true, &click_id, window, cx), })), ); } diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs index e5020e25..64226b36 100644 --- a/src/ui/ssh_connect.rs +++ b/src/ui/ssh_connect.rs @@ -299,6 +299,7 @@ fn build_spec_inner( term: "xterm-256color".to_string(), verify_host_keys: profile.verify_host_keys.unwrap_or(global_verify_host_keys), skip_banner: profile.skip_banner, + shell_integration: profile.shell_integration, login_script: profile.login_scripts.clone(), display_name: (!profile.name.is_empty()).then(|| profile.name.clone()), profile_id: Some(profile.id.to_string()), diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 837bd8fc..0cea4dfd 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -5,29 +5,35 @@ //! //! Split out of `app.rs` as an `impl Tty7App` block, exactly like `tab_strip`. //! It shares the model wholesale: the same `self.tabs`/`self.active` state, the -//! same `tab_label`, the same `activate`/`close_tab`/`move_tab`/`start_rename` -//! operations, the same `DragTab` payload, and the same theme tokens the chips -//! use — so the vertical list stays pixel-consistent with the strip and adds no -//! new state or business logic, only a new set of click targets in a new shape. +//! same `tab_label`, the same `activate`/`close_tab`/`start_rename` operations, +//! the same `DragTab` payload and reorder machinery, and the same theme tokens +//! the chips use — so the vertical list stays pixel-consistent with the strip +//! and adds no new state or business logic, only a new set of click targets in +//! a new shape. use gpui::{ - AnyElement, Bounds, Context, FontWeight, MouseButton, MouseDownEvent, MouseMoveEvent, - MouseUpEvent, Pixels, SharedString, Window, canvas, div, prelude::*, px, + Animation, AnimationExt as _, AnyElement, Axis, Bounds, Context, Div, FontWeight, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Stateful, Window, + WindowControlArea, canvas, deferred, div, ease_out_quint, linear_color_stop, linear_gradient, + prelude::*, px, }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::Input; -use gpui_component::menu::ContextMenuExt as _; -use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; -use std::cell::Cell; +use gpui_component::menu::{ContextMenu, ContextMenuExt as _}; +use gpui_component::{ + ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Sizable as _, h_flex, v_flex, +}; +use std::cell::{Cell, RefCell}; use std::rc::Rc; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::core::config::{Config, SidebarGrouping}; use crate::terminal::git_status::GitStatusCache; use crate::ui::app::{TITLE_BAR_HEIGHT, Tty7App}; use crate::ui::hints::tab_badge_label; -use crate::ui::tab_strip::DragTab; +use crate::ui::reorder::{self, Reorder, Surface}; +use crate::ui::tab_strip::{DragTab, REORDER_SLIDE_MS}; /// Minimum sidebar width, and the maximum as a fraction of the window width, so /// a resize drag can't collapse the rail or let it swallow the terminal. @@ -39,6 +45,27 @@ const MAX_SIDEBAR_WIDTH_RATIO: f32 = 0.5; /// drag. Centered (half overhangs the body) so it clears the row close buttons. const RESIZE_HANDLE_WIDTH: f32 = 8.; +/// Gap between rows in the rail, and between the group blocks — the distance a +/// row or block travels on top of its own height when a drag passes it. +const ROW_GAP: f32 = 2.; + +/// Marks a live drag as a *group* drag — the sidebar counterpart to +/// [`DragTab`], and like it a stateless marker that renders nothing: the block +/// being dragged never leaves the rail, so there is no card floating over the +/// window. Its type is what tells the rail's drop handlers "this is a group, +/// not a tab". Scratch never starts one: it's pinned last by +/// [`sidebar_sections`], so it has no slot to move to. +#[derive(Clone)] +pub(crate) struct DragGroup; + +impl Render for DragGroup { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + // gpui always paints *something* at the cursor for an active drag; + // an empty, zero-sized element is how this drag paints nothing. + div() + } +} + impl Tty7App { /// The vertical tab sidebar rendered down the left edge of the body in /// `tab_bar_position: left` mode. Only reached when at least one tab is open @@ -92,24 +119,105 @@ impl Tty7App { let keys: Rc>> = Rc::new(self.sidebar_group_keys(cx)); let sections = sidebar_sections(&keys); - // Each row's position in display order — the digit its ⌘N badge - // shows. Advanced for every tab, filtered-out ones included, so the - // digits (and what ⌘N targets) don't shift while the search box - // narrows the list. - let mut visual_pos = 0usize; - for (group_name, idxs) in sections { - // Rows first: the search filter may empty a group, in which case - // its header is skipped too (and the header's count reflects the - // *visible* rows while a filter narrows the list). - let mut rows: Vec = Vec::new(); - for i in idxs { - // This row's display-order digit; claimed before the search - // filter can skip the row (see `visual_pos` above). - let badge_pos = visual_pos; - visual_pos += 1; + // Each row's position in display order — the digit its ⌘N badge shows. + // Claimed for every tab, filtered-out ones included, and read off this + // map rather than counted as rows are emitted, so neither the search + // box nor a drag's live reflow can renumber the shortcuts under you. + let badge_pos: Vec = { + let mut pos = vec![0usize; self.tabs.len()]; + for (n, i) in sections.iter().flat_map(|s| s.tabs.iter()).enumerate() { + pos[*i] = n; + } + pos + }; + + // Which tabs each section actually lists, and their labels. Settled + // before anything is laid out, because both drag surfaces are keyed to + // what is *visible*: a group the search box has emptied isn't rendered, + // so it must not claim a slot in the group geometry either — a phantom + // zero-sized slot would sit at the origin and swallow every crossing. + // (Matching is on the visible label; a row keeps its real tab index, so + // activate/close/reorder still hit the right tab when the list is + // narrowed.) + let visible_by_section: Vec> = sections + .iter() + .map(|s| { + s.tabs + .iter() + .map(|&i| (i, self.tab_label(&self.tabs[i], i, Some(window), cx))) + .filter(|(_, label)| query.is_empty() || label.to_lowercase().contains(&query)) + .collect() + }) + .collect(); + + // ── Live drag-reorder, part 1: the group blocks ─────────────────────── + // Repo groups can be dragged by their header to reorder the whole + // block; Scratch can't (it's pinned last, so it has neither a slot to + // move to nor one to give up), so the draggable slots are exactly the + // rendered repo groups. See [`crate::ui::reorder`] for the machinery. + let pointer = window.mouse_position(); + let rendered = |ix: &usize| !visible_by_section[*ix].is_empty(); + let repo_slots: Vec = (0..sections.len()) + .filter(|&ix| sections[ix].key.is_some()) + .filter(rendered) + .collect(); + let repo_groups = repo_slots.len(); + let group_slots: Rc>>> = + Rc::new(RefCell::new(vec![Bounds::default(); repo_groups])); + let group_preview = + reorder::preview(&self.reorder, &Surface::SidebarGroups, repo_groups, pointer); + let repo_roots: Vec = repo_slots + .iter() + .filter_map(|&ix| sections[ix].key.clone()) + .collect(); + let slot_display: Vec = match &group_preview { + Some(p) => { + // Same as the rows below: record what releasing right now would + // produce, so the commit doesn't depend on where the cursor is. + if let (Some(from), Some(to)) = (repo_roots.get(p.from), repo_roots.get(p.target)) + && let Some(order) = regrouped_order(&keys, from, to) + { + reorder::set_pending(&self.reorder, &Surface::SidebarGroups, order); + } + p.order.clone() + } + None => (0..repo_groups).collect(), + }; + // The blocks to lay out, as `(drag slot, section)`. Repo groups lead in + // the previewed slot order; Scratch trails them with no slot of its own. + let mut blocks: Vec<(Option, usize)> = slot_display + .into_iter() + .map(|slot| (Some(slot), repo_slots[slot])) + .collect(); + blocks.extend( + (0..sections.len()) + .filter(|&ix| sections[ix].key.is_none()) + .filter(rendered) + .map(|ix| (None, ix)), + ); + + for (group_slot, group_ix) in blocks { + let section = §ions[group_ix]; + let group_key = section.key.clone(); + // Kept as concrete elements, not `AnyElement`s: a live drag + // restyles them (the slide-in offset), which can only be applied + // once every row of the group has been built. + let mut rows: Vec>> = Vec::new(); + let visible = visible_by_section[group_ix].clone(); + let visible_tabs: Vec = visible.iter().map(|(i, _)| *i).collect(); + let row_slots: Rc>>> = + Rc::new(RefCell::new(vec![Bounds::default(); visible.len()])); + // ── Live drag-reorder, part 2: the rows of this group ───────────── + let row_preview = reorder::preview( + &self.reorder, + &Surface::SidebarRows(group_key.clone()), + visible.len(), + pointer, + ); + for (slot, (i, label)) in visible.into_iter().enumerate() { + let badge_pos = badge_pos[i]; let tab = &self.tabs[i]; let is_active = i == active; - let label = self.tab_label(tab, i, Some(window), cx); // No status/cwd text under the title: the avatar's status dot // already carries working/waiting/done, and the group header + the // trailing branch tag carry the location — a "Working…" or cwd @@ -192,14 +300,6 @@ impl Tty7App { } line }); - // Filter by the search box; matching is on the visible label. The row - // keeps its real index `i`, so activate/close/move still hit the right - // tab even when the list is narrowed. - if !query.is_empty() && !label.to_lowercase().contains(&query) { - continue; - } - let drag_label: SharedString = label.clone().into(); - // Inline rename input for this tab, if it's the one being renamed — // the same `self.renaming` branch the strip uses, so a context-menu // rename works identically in either layout. @@ -238,26 +338,12 @@ impl Tty7App { ) // Branch + diff line, when the pane sits in a git repo. .children(git_line) - // Click activates. (Renaming lives in the context menu, - // matching the strip — no double-click rename.) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _: &MouseDownEvent, window, cx| { - cx.stop_propagation(); - this.activate(i, window, cx); - }), - ) - // Drag the row by its label to reorder it (shared `DragTab`). - .on_drag( - DragTab { - index: i, - label: drag_label.clone(), - }, - |drag, _, _, cx| { - cx.stop_propagation(); - cx.new(|_| drag.clone()) - }, - ) + // No mouse handler of its own: activation *and* the + // reorder drag both live on the row, and a child that + // swallowed the press would take the label — the + // largest part of the row — out of both. (Only the + // diff counts inside the branch line stop the press, + // deliberately: they're their own click target.) .into_any_element(), }; @@ -266,6 +352,38 @@ impl Tty7App { // A per-row group so this row's close affordance reveals on its own // hover without touching siblings (same trick as the chip). .group(SharedString::from(format!("tab-row-{i}"))) + // A row is first of all a switch target, so the hover + // cursor says "click me"; picking it up swaps in the + // closed hand (see `Tty7App::render`). + .cursor_pointer() + // Drag anywhere on the row to reorder it (shared `DragTab`). + // On the row, not on its label: the drag's frame of + // reference is where the *row* was grabbed, which is what + // the frozen geometry below measures — hang it off the + // label and the held row rides a few pixels off the cursor, + // skewing every crossing by that much. `slot` is the row's + // position among the *visible* rows of its group; a drag + // never leaves the group, so that's the whole world it + // needs. The builder runs once, when gpui promotes the + // press into a drag, freezing the geometry as of the last + // painted frame. + .on_drag(DragTab, { + let state = self.reorder.clone(); + let slots = row_slots.clone(); + let group_key = group_key.clone(); + move |_drag, grab, _window, cx| { + cx.stop_propagation(); + *state.borrow_mut() = Some(Reorder::new( + Surface::SidebarRows(group_key.clone()), + slot, + slots.borrow().clone(), + Axis::Vertical, + px(ROW_GAP), + grab, + )); + cx.new(|_| DragTab) + } + }) .w_full() // Size to content with a small, uniform vertical padding: a // one-line shell tab is a short row, a two-line git tab @@ -292,20 +410,31 @@ impl Tty7App { s.text_color(cx.theme().sidebar_foreground) .hover(|s| s.bg(cx.theme().sidebar_accent.opacity(0.5))) }) - // Drop target: dropping a dragged row here moves it to this - // slot — but only within the same group; a cross-group drop is - // a no-op, since a tab's group comes from its cwd's repo, not - // from where it sits in the list. (With grouping off all keys - // are `None`, so the check never blocks anything.) - .drag_over::(|s, _, _, cx| s.bg(cx.theme().drag_border.opacity(0.2))) - .on_drop(cx.listener({ - let keys = keys.clone(); - move |this, drag: &DragTab, _window, cx| { - if keys.get(drag.index) == keys.get(i) { - this.move_tab(drag.index, i, cx); - } - } - })) + // Held: a light dimming so the row under your cursor reads + // as picked up. Not a lift — it stays in the rail's plane. + .when(row_preview.as_ref().is_some_and(|p| p.from == slot), |s| { + s.opacity(0.75) + }) + // Measures this row into its group's slot table — the + // geometry a drag starting on a later frame freezes. + // Absolute and empty, so it costs the layout nothing. + .child( + canvas( + { + let slots = row_slots.clone(); + move |bounds, _window, _cx| { + if let Some(s) = slots.borrow_mut().get_mut(slot) { + *s = bounds; + } + } + }, + |_, _, _, _| {}, + ) + // `inset_0`, not `size_full` — see the strip's copy of + // this canvas for why the distinction matters. + .absolute() + .inset_0(), + ) // A click anywhere on the row (padding, gaps) activates it; the // label and close children stop propagation for their own actions. .on_mouse_down( @@ -318,101 +447,287 @@ impl Tty7App { // Leading avatar: agent brand mark, SSH status, or shell glyph. .child(self.tab_avatar(agent, agent_status, agent_unread, ssh_dot, 22., cx)) .child(label_region) - // Trailing slot: while the shortcut hints are armed it shows the - // row's ⌘N switch digit; otherwise the close affordance — - // opacity-0-until-hover on every row, active or not, so a column - // of tabs reads clean. Space is reserved either way. The digit + // Trailing ⌘N badge: while the shortcut hints are armed the + // row shows its switch digit in an in-flow 20px slot (an + // all-rows-at-once modal reflow, same as the strip). The digit // is the row's *display* position (`activate_visual` speaks the // same order), so under grouping the rail still reads 1…9 top // to bottom instead of scattering the tab-vector indices. - .child(if show_badges && badge_pos < 9 { + .when(show_badges && badge_pos < 9, |row| { // Bare digit, no keycap box — matches the chip badge exactly. - div() - .flex_shrink_0() - .flex() - .items_center() - .justify_center() - .size(px(20.)) - .text_xs() - .font_weight(FontWeight::MEDIUM) - .text_color(if is_active { - cx.theme().sidebar_accent_foreground - } else { - cx.theme().muted_foreground - }) - .child(tab_badge_label(badge_pos)) - .into_any_element() - } else { - div() - .flex_shrink_0() - .opacity(0.) - .group_hover(SharedString::from(format!("tab-row-{i}")), |s| { - s.opacity(1.) - }) - .child( - Button::new(("sidebar-close", i)) - .icon(IconName::Close) - .ghost() - .xsmall() - .on_click(cx.listener(move |this, _, window, cx| { - this.close_tab(i, window, cx); - })), - ) - .into_any_element() + row.child( + div() + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .size(px(20.)) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(if is_active { + cx.theme().sidebar_accent_foreground + } else { + cx.theme().muted_foreground + }) + .child(tab_badge_label(badge_pos)), + ) + }) + // Close affordance: out of flow, so the label runs the full + // rail width instead of always reserving a slot for a button + // that's invisible until hover (same Safari-style float as + // the strip's chips). On hover the ✕ sits over the *title + // line's* right end — pinned to the row top, not centered: + // on a two-line row a centered ✕ would straddle both lines + // and cover the branch line's `+n −n` counts, which are a + // click target of their own (the diff-overlay toggle). A + // solid backing in the row's hover fill plus a short + // gradient run-in fades covered title text out instead of + // hard-cutting mid-glyph. Nothing reflows on hover. + .when(!(show_badges && badge_pos < 9), |row| { + // The row fills are composited over the rail (the accent + // carries alpha; the inactive hover is a half-strength + // wash), so flatten them against `sidebar` to get the + // opaque colour the float must match. + let backing = if is_active { + cx.theme().sidebar.blend(cx.theme().sidebar_accent) + } else { + cx.theme() + .sidebar + .blend(cx.theme().sidebar_accent.opacity(0.5)) + }; + let mut fade_from = backing; + fade_from.a = 0.; + row.child( + h_flex() + .absolute() + // `py_1` row padding: the 20px button covers the + // title line exactly. + .top(px(4.)) + .right(px(6.)) + .opacity(0.) + .group_hover(SharedString::from(format!("tab-row-{i}")), |s| { + s.opacity(1.) + }) + .child(div().w(px(10.)).h(px(20.)).bg(linear_gradient( + 90., + linear_color_stop(fade_from, 0.), + linear_color_stop(backing, 1.), + ))) + .child( + div().bg(backing).child( + Button::new(("sidebar-close", i)) + .icon(IconName::Close) + .ghost() + .xsmall() + .on_click(cx.listener(move |this, _, window, cx| { + this.close_tab(i, window, cx); + })), + ), + ), + ) }); // Per-tab right-click menu, shared with the strip's chips; // `below_wording` flips the trailing close to "Close Tabs Below" // to match the vertical layout. let menu_app = cx.entity().downgrade(); - rows.push( - row.context_menu(move |menu, window, cx| { - Tty7App::tab_context_menu(menu, i, true, &menu_app, window, cx) - }) - .into_any_element(), - ); + rows.push(row.context_menu(move |menu, window, cx| { + Tty7App::tab_context_menu(menu, i, true, &menu_app, window, cx) + })); } if rows.is_empty() { continue; } + + let row_display: Vec = match &row_preview { + Some(p) => { + // Record the tab order releasing right now would produce, so + // letting go applies exactly what's on screen no matter where + // the cursor ended up (see `reorder::set_pending`). + if let Some(order) = + reordered_rows(&keys, &group_key, &visible_tabs, p.from, p.target) + { + reorder::set_pending( + &self.reorder, + &Surface::SidebarRows(group_key.clone()), + order, + ); + } + p.order.clone() + } + None => (0..rows.len()).collect(), + }; + let row_count = rows.len(); + let mut rows: Vec>>> = + rows.into_iter().map(Some).collect(); + let rows: Vec = row_display + .into_iter() + .map(|slot| match &row_preview { + // The row in hand: drawn wherever the cursor is holding it, + // pixel for pixel, with no animation in the way. `deferred` + // keeps its slot in the layout but paints it after its + // siblings, so it passes *over* the rows it's crossing + // instead of being clipped behind them. + Some(p) if p.from == slot => deferred( + rows[slot] + .take() + .expect("each slot emitted once") + .relative() + .top(p.held), + ) + .into_any_element(), + // Slide into place rather than teleporting. `offset` is + // zero for every row the last crossing left alone, so one + // row moves at a time instead of the group re-animating. + Some(p) => { + let offset = p.offsets[slot].as_f32(); + rows[slot] + .take() + .expect("each slot emitted once") + .with_animation( + ( + SharedString::from(format!("row-slide-{}", p.generation)), + slot, + ), + Animation::new(std::time::Duration::from_millis(REORDER_SLIDE_MS)) + .with_easing(ease_out_quint()), + move |el, delta| el.top(px(offset * (1. - delta))), + ) + .into_any_element() + } + None => rows[slot] + .take() + .expect("each slot emitted once") + .into_any_element(), + }) + .collect(); // Group header: the repo's directory name (or "Scratch"), small // and muted so it labels without competing with the rows, plus the - // visible-row count. Not a click target — rows do the activating. - if let Some(name) = group_name { - list = list.child( - h_flex() - .w_full() - .items_center() - .gap_1p5() - .pl_2() - .pr_2() - .pt_1p5() - .pb_0p5() - .text_size(px(11.)) - .text_color(cx.theme().muted_foreground) - // Count sits right next to the name (not pushed to the - // rail's right edge): the name shrinks and truncates if - // long, the count trails it as a quiet tally. - .child( - div() - .flex_shrink(1.) - .min_w_0() - .truncate() - .font_weight(FontWeight::SEMIBOLD) - .child(name.to_uppercase()), + // visible-row count. Not a click target — rows do the activating — + // but it *is* the whole group's drag handle: drag one project name + // and the block moves, tabs and all, with the other groups sliding + // around it exactly as rows do inside one. Scratch (`group_key == + // None`) sits out: it's pinned last, so it has nowhere to go. + let header = section.name.clone().map(|name| { + let label: SharedString = name.to_uppercase().into(); + h_flex() + .id(("sidebar-group", group_ix)) + .w_full() + .items_center() + .gap_1p5() + .pl_2() + .pr_1p5() + .pt_1p5() + .pb_0p5() + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground) + .when_some(group_slot, |header, slot| { + // Unlike a row, a header does nothing on click — its + // only affordance is the drag, so the open hand is the + // honest hover cursor (it closes once you pick it up). + header.cursor_grab().on_drag(DragGroup, { + let state = self.reorder.clone(); + let slots = group_slots.clone(); + move |_drag, grab, _window, cx| { + cx.stop_propagation(); + *state.borrow_mut() = Some(Reorder::new( + Surface::SidebarGroups, + slot, + slots.borrow().clone(), + Axis::Vertical, + px(ROW_GAP), + // The header is the handle, but the *block* + // is what moves. The header leads the block, + // so the grab point inside the header is + // also the grab point inside the block — + // it passes through unchanged. + grab, + )); + cx.new(|_| DragGroup) + } + }) + }) + // Count sits right next to the name (not pushed to the + // rail's right edge): the name shrinks and truncates if + // long, the count trails it as a quiet tally. + .child( + div() + .flex_shrink(1.) + .min_w_0() + .truncate() + .font_weight(FontWeight::SEMIBOLD) + .child(label), + ) + .child( + div() + .flex_shrink_0() + .text_color(cx.theme().muted_foreground.opacity(0.7)) + .child(row_count.to_string()), + ) + }); + + // One block per group — header plus its rows — so a header drag can + // move the whole thing as a unit and measure it as one slot. + let block = v_flex() + .w_full() + .gap(px(ROW_GAP)) + // Held: the block you're dragging dims, exactly as a held row + // does — nothing lifts off the rail. + .when( + group_preview + .as_ref() + .is_some_and(|p| Some(p.from) == group_slot), + |b| b.opacity(0.75), + ) + .children(header) + .children(rows) + // Measures the block for the group-drag geometry. Only the + // rendered repo groups hold a slot — Scratch is pinned last and + // never moves, and a group the search box emptied isn't here at + // all — so `group_slot` indexes that list, not `sections`. + .when_some(group_slot, |block, slot| { + block.child( + canvas( + { + let slots = group_slots.clone(); + move |bounds, _window, _cx| { + if let Some(s) = slots.borrow_mut().get_mut(slot) { + *s = bounds; + } + } + }, + |_, _, _, _| {}, ) - .child( - div() - .flex_shrink_0() - .text_color(cx.theme().muted_foreground.opacity(0.7)) - .child(rows.len().to_string()), - ), - ); - } - for row in rows { - list = list.child(row); - } + .absolute() + .inset_0(), + ) + }); + + list = list.child(match (&group_preview, group_slot) { + // The block in hand tracks the cursor, painted over the ones it + // crosses (same treatment a held row gets inside a group). + (Some(p), Some(slot)) if p.from == slot => { + deferred(block.relative().top(p.held)).into_any_element() + } + // Everything else slides; a slotless block (Scratch) never + // moves, so it falls through to the plain block below. + (Some(p), Some(slot)) => { + let offset = p.offsets[slot].as_f32(); + block + .with_animation( + ( + SharedString::from(format!("group-slide-{}", p.generation)), + slot, + ), + Animation::new(std::time::Duration::from_millis(REORDER_SLIDE_MS)) + .with_easing(ease_out_quint()), + move |el, delta| el.top(px(offset * (1. - delta))), + ) + .into_any_element() + } + _ => block.into_any_element(), + }); } // The rail's own controls — new tab, and collapse — live in the top zone @@ -592,7 +907,37 @@ impl Tty7App { // so the rail reads as one panel from the very top edge. The // rail's controls ride its right end, on the title bar's own // center line — same row as the "⋯" across the window. - .child(controls) + // + // The real `TitleBar` — which carries the window's drag region + // — only spans the *right* column in this layout, so this strip + // would be dead space you can't grab the window by. Make the + // controls' own row act like the title bar it sits level with: + // drag to move, double-click to zoom. Driven exactly like + // `TitleBar` does it (and the settings overlay's stand-in + // strip): a press arms a flag and the first *move* starts the + // window move, so a plain click — and a double-click — still + // lands intact, while the buttons on the right keep taking + // their own clicks. + .child({ + let should_move = Rc::new(Cell::new(false)); + controls + .id("sidebar-titlebar-drag") + .window_control_area(WindowControlArea::Drag) + .on_mouse_down(MouseButton::Left, { + let should_move = should_move.clone(); + move |_, _, _| should_move.set(true) + }) + .on_mouse_up(MouseButton::Left, { + let should_move = should_move.clone(); + move |_, _, _| should_move.set(false) + }) + .on_mouse_move(move |_, window, _| { + if should_move.replace(false) { + window.start_window_move(); + } + }) + .on_double_click(|_, window, _| window.titlebar_double_click()) + }) .child(top_bar) .child(list), ) @@ -643,7 +988,7 @@ impl Tty7App { let keys = self.sidebar_group_keys(cx); sidebar_sections(&keys) .into_iter() - .flat_map(|(_, idxs)| idxs) + .flat_map(|s| s.tabs) .collect() } @@ -662,12 +1007,25 @@ impl Tty7App { } } -/// Partition per-tab group keys into the sidebar's sections: `(header, -/// indices)` with groups in first-appearance order (a new repo appends, the -/// existing ones never reshuffle) and the Scratch group pinned last. A `None` -/// header means "render flat, no headers" — used when no tab is in any repo, -/// where a lone Scratch header over everything would be noise. -fn sidebar_sections(keys: &[Option]) -> Vec<(Option, Vec)> { +/// One block of the sidebar: a header and the tabs under it. +#[derive(Debug, PartialEq)] +struct Section { + /// The repo root this group is keyed on, `None` for Scratch. Sections with + /// a key are the draggable ones (Scratch is pinned last), and it doubles as + /// the group's identity in a drag. + key: Option, + /// The header text, or `None` for "render flat, no header". + name: Option, + /// The group's tabs, in tab order. + tabs: Vec, +} + +/// Partition per-tab group keys into the sidebar's sections: groups in +/// first-appearance order (a new repo appends, the existing ones never +/// reshuffle) with the Scratch group pinned last. A nameless single section +/// means "render flat, no headers" — used when no tab is in any repo, where a +/// lone Scratch header over everything would be noise. +fn sidebar_sections(keys: &[Option]) -> Vec
{ let mut group_order: Vec<&PathBuf> = Vec::new(); for k in keys.iter().flatten() { if !group_order.iter().any(|g| *g == k) { @@ -675,26 +1033,109 @@ fn sidebar_sections(keys: &[Option]) -> Vec<(Option, Vec } } if group_order.is_empty() { - return vec![(None, (0..keys.len()).collect())]; + return vec![Section { + key: None, + name: None, + tabs: (0..keys.len()).collect(), + }]; } let names = group_names(&group_order); - let mut sections: Vec<(Option, Vec)> = group_order + let mut sections: Vec
= group_order .iter() .zip(names) - .map(|(root, name)| { - let idxs = (0..keys.len()) + .map(|(root, name)| Section { + key: Some((*root).clone()), + name: Some(name), + tabs: (0..keys.len()) .filter(|&i| keys[i].as_ref() == Some(*root)) - .collect(); - (Some(name), idxs) + .collect(), }) .collect(); let scratch: Vec = (0..keys.len()).filter(|&i| keys[i].is_none()).collect(); if !scratch.is_empty() { - sections.push((Some("Scratch".into()), scratch)); + sections.push(Section { + key: None, + name: Some("Scratch".into()), + tabs: scratch, + }); } sections } +/// The tab permutation for a row dropped at a new place inside its own group: +/// `visible` are the group's rows as the rail currently lists them (the search +/// box may be hiding others), and the row at `from` lands where the row at `to` +/// is now. +/// +/// Like [`regrouped_order`] this returns a whole-vector permutation rather than +/// a single move, because "third row in this group" only means something once +/// the vector is laid out the way the rail draws it: groups in their existing +/// order, each one contiguous, Scratch last. Rows the filter is hiding keep +/// their place in the group, and no other group is disturbed. +fn reordered_rows( + keys: &[Option], + group: &Option, + visible: &[usize], + from: usize, + to: usize, +) -> Option> { + let (&moved, &anchor) = (visible.get(from)?, visible.get(to)?); + if moved == anchor { + return None; + } + let mut members: Vec = (0..keys.len()).filter(|&i| keys[i] == *group).collect(); + members.retain(|&i| i != moved); + // Land it on the far side of the row it was dropped onto, so dragging down + // ends up below that row and dragging up above it. + let at = members.iter().position(|&i| i == anchor)? + usize::from(to > from); + members.insert(at, moved); + + let mut out: Vec = Vec::with_capacity(keys.len()); + for g in sidebar_sections(keys).iter().map(|s| &s.key) { + if g == group { + out.extend_from_slice(&members); + } else { + out.extend((0..keys.len()).filter(|&i| keys[i] == *g)); + } + } + Some(out) +} + +/// The tab permutation that moves the group rooted at `from` into `to`'s slot, +/// as old indices in their new order — or `None` when the move is a no-op (same +/// group, or either root no longer has any tab). +/// +/// Groups are ordered by first appearance in the tab vector, so the move is +/// "reorder the group list, then lay the tabs back out group by group". Each +/// group therefore comes out *contiguous*, with Scratch last, matching exactly +/// what the sidebar renders — which also settles the old caveat that a tab drag +/// inside an interleaved group could shuffle other groups' headers: after any +/// header drag the vector is compacted and interleaving is gone. Relative order +/// within a group is preserved. +fn regrouped_order(keys: &[Option], from: &Path, to: &Path) -> Option> { + if from == to { + return None; + } + let mut order: Vec<&PathBuf> = Vec::new(); + for k in keys.iter().flatten() { + if !order.iter().any(|g| *g == k) { + order.push(k); + } + } + let fi = order.iter().position(|g| g.as_path() == from)?; + let ti = order.iter().position(|g| g.as_path() == to)?; + let moved = order.remove(fi); + order.insert(ti, moved); + + let mut out: Vec = Vec::with_capacity(keys.len()); + for g in &order { + out.extend((0..keys.len()).filter(|&i| keys[i].as_ref() == Some(*g))); + } + // Scratch tabs trail the repo groups, which is where the sidebar draws them. + out.extend((0..keys.len()).filter(|&i| keys[i].is_none())); + Some(out) +} + /// Display names for the group roots: each root's directory name, extended /// upward by parent components only while it collides with another root's /// (`app` stays `app` on its own; two checkouts both named `app` become @@ -764,17 +1205,100 @@ mod tests { Some(p("/w/beta")), ]; let sections = sidebar_sections(&keys); + let shape: Vec<(Option, Option, Vec)> = sections + .into_iter() + .map(|s| (s.key, s.name, s.tabs)) + .collect(); assert_eq!( - sections, + shape, vec![ - (Some("beta".into()), vec![0, 3]), - (Some("alpha".into()), vec![2]), - (Some("Scratch".into()), vec![1]), + (Some(p("/w/beta")), Some("beta".into()), vec![0, 3]), + (Some(p("/w/alpha")), Some("alpha".into()), vec![2]), + (None, Some("Scratch".into()), vec![1]), ] ); + // No tab in any repo: one headerless section over everything. let flat = sidebar_sections(&[None, None]); - assert_eq!(flat, vec![(None, vec![0, 1])]); + assert_eq!(flat.len(), 1); + assert_eq!(flat[0].name, None); + assert_eq!(flat[0].tabs, vec![0, 1]); + } + + /// A row dropped inside its group lands on the far side of the row it was + /// dropped onto, leaves every other group alone, and comes out with the + /// groups laid out contiguously the way the rail draws them. + #[test] + fn reordered_rows_moves_within_the_group_only() { + // alpha owns 0 and 2, interleaved with beta's 1; scratch is 3. + let keys = vec![ + Some(p("/w/alpha")), + Some(p("/w/beta")), + Some(p("/w/alpha")), + None, + ]; + let alpha = Some(p("/w/alpha")); + // alpha's first row dragged onto its second: alpha reads [2, 0], and + // the vector comes out grouped — alpha, beta, scratch. + assert_eq!( + reordered_rows(&keys, &alpha, &[0, 2], 0, 1), + Some(vec![2, 0, 1, 3]) + ); + // Back the other way. + assert_eq!( + reordered_rows(&keys, &alpha, &[0, 2], 1, 0), + Some(vec![2, 0, 1, 3]) + ); + // Dropping a row on itself changes nothing. + assert_eq!(reordered_rows(&keys, &alpha, &[0, 2], 1, 1), None); + } + + /// Rows the search box is hiding aren't dragged along: the visible rows + /// reorder among themselves and the hidden one keeps its place in the group. + #[test] + fn reordered_rows_leaves_filtered_out_rows_alone() { + let keys = vec![Some(p("/w/a")), Some(p("/w/a")), Some(p("/w/a"))]; + let a = Some(p("/w/a")); + // Only rows 0 and 2 are listed; dragging 0 past 2 puts it after row 2, + // and row 1 stays between… where it was relative to the others. + assert_eq!( + reordered_rows(&keys, &a, &[0, 2], 0, 1), + Some(vec![1, 2, 0]) + ); + } + + /// A header drag moves the whole group into the target's slot and lays + /// every group out contiguously, Scratch last, keeping intra-group order. + #[test] + fn regrouped_order_moves_the_group_into_the_target_slot() { + // Groups by first appearance: alpha (0, 3), beta (2), gamma (4). + let keys = vec![ + Some(p("/w/alpha")), + None, + Some(p("/w/beta")), + Some(p("/w/alpha")), + Some(p("/w/gamma")), + ]; + // gamma dropped on alpha → gamma, alpha, beta, then Scratch. + assert_eq!( + regrouped_order(&keys, &p("/w/gamma"), &p("/w/alpha")), + Some(vec![4, 0, 3, 2, 1]) + ); + // alpha dropped on gamma (a move down) → beta, gamma, alpha. + assert_eq!( + regrouped_order(&keys, &p("/w/alpha"), &p("/w/gamma")), + Some(vec![2, 4, 0, 3, 1]) + ); + } + + /// Dropping a group on itself, or naming a root no tab lives in, is a + /// no-op rather than a re-shuffle. + #[test] + fn regrouped_order_ignores_self_and_unknown_roots() { + let keys = vec![Some(p("/w/alpha")), Some(p("/w/beta"))]; + assert_eq!(regrouped_order(&keys, &p("/w/alpha"), &p("/w/alpha")), None); + assert_eq!(regrouped_order(&keys, &p("/w/gone"), &p("/w/beta")), None); + assert_eq!(regrouped_order(&keys, &p("/w/alpha"), &p("/w/gone")), None); } /// Same-named roots grow a parent prefix until distinct; unrelated names diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index b4156b48..1a5925ba 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -5,19 +5,30 @@ //! orchestration rather than chrome rendering. use gpui::{ - AnyElement, App, Axis, Context, FontWeight, MouseButton, MouseDownEvent, SharedString, Window, - div, prelude::*, px, + Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, Context, FontWeight, MouseButton, + MouseDownEvent, Pixels, SharedString, Window, canvas, deferred, div, ease_out_quint, + linear_color_stop, linear_gradient, prelude::*, px, }; use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants as _}; use gpui_component::input::Input; use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; use gpui_component::{ActiveTheme as _, Icon, IconName, Selectable as _, Sizable as _, h_flex}; +use std::cell::RefCell; +use std::rc::Rc; use crate::core::actions::{OpenSettings, TogglePalette}; use crate::core::config::{Config, RightPanelTab}; use crate::daemon::protocol::ShellSpec; use crate::ui::app::{Tab, Tty7App}; use crate::ui::hints::tab_badge_label; +use crate::ui::reorder::{self, Reorder, Surface}; + +/// How long a slot takes to slide out of the way of a dragged tab, and the +/// gap between chips it has to travel. Short and hard-decelerating: long +/// enough to read as motion, short enough that a fast drag across the strip +/// never queues up a backlog of sliding tabs. +pub(crate) const REORDER_SLIDE_MS: u64 = 140; +const CHIP_GAP: f32 = 6.; /// How many trailing path components a deep tab label keeps, mirroring /// ghostty's zsh integration title `%(4~|…/%3~|%~)`: a path deeper than this @@ -121,28 +132,21 @@ fn short_title(raw: &str) -> String { label } -/// Drag payload for reordering tabs. Carries the source index and a label so the -/// drag preview can show the tab being moved. `pub(crate)` so the vertical -/// [`tab_sidebar`](crate::ui::tab_sidebar) reuses the same payload (and could one -/// day support strip ↔ sidebar cross-drops via the shared `move_tab`). +/// Marks a live drag as a *tab* drag: its type is what the rail's and strip's +/// drop handlers match on, and its presence is what keeps gpui redrawing while +/// the pointer moves. It deliberately carries no state and renders nothing — +/// the tab being dragged never leaves the list, so there is no card floating +/// over the window; the reorder is drawn entirely by the list itself (see +/// [`crate::ui::reorder`]). `pub(crate)` so the vertical +/// [`tab_sidebar`](crate::ui::tab_sidebar) shares the same payload. #[derive(Clone)] -pub(crate) struct DragTab { - pub(crate) index: usize, - pub(crate) label: SharedString, -} +pub(crate) struct DragTab; impl Render for DragTab { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + // gpui always paints *something* at the cursor for an active drag; + // an empty, zero-sized element is how this drag paints nothing. div() - .px_3() - .py_1() - .rounded_lg() - .bg(cx.theme().secondary) - .border_1() - .border_color(cx.theme().border) - .text_sm() - .text_color(cx.theme().foreground) - .child(self.label.clone()) } } @@ -463,6 +467,9 @@ impl Tty7App { let spec = ShellSpec { program: shell.program.clone(), args: shell.args.clone(), + // Every arg on a dropdown row was written by + // `core::shells::detect_shells`, not the user. + args_are_tty7_defaults: true, }; let open = app.clone(); let item = if shell.label == default_name { @@ -540,6 +547,27 @@ impl Tty7App { } })); + // Mark as Unread — re-arm the avatar's green Done badge so a result + // you want to revisit nags again. Only agent tabs get the entry, and + // only a settled (`Done`) tab has a finished turn to mark; a busier + // status (working/waiting) owns the dot anyway, so the entry disables + // rather than promising a badge that can't show yet. + let tab = this.tabs.get(index); + if tab.is_some_and(|t| t.agent(cx).is_some()) { + let done = tab.and_then(|t| t.agent_status(cx)) + == Some(crate::core::cli_agent::AgentStatus::Done); + menu = menu.item( + PopupMenuItem::new("Mark as Unread") + .disabled(!done) + .on_click({ + let app = app.clone(); + move |_, _window, cx| { + let _ = app.update(cx, |this, cx| this.mark_tab_unread(index, cx)); + } + }), + ); + } + // Worktree: an isolated checkout of this tab's repo on a fresh branch, // opened as a new tab — parallel-agent fuel. Only offered when the // tab's cwd actually sits in a git repository (a filesystem-only @@ -675,17 +703,47 @@ impl Tty7App { // `min_w`) and truncates their labels rather than pushing the "+" away. let mut chips = h_flex() .items_center() - .gap_1p5() + .gap(px(CHIP_GAP)) .min_w_0() .max_w(chips_avail) .overflow_hidden(); - for (i, tab) in self.tabs.iter().enumerate() { + // ── Live drag-reorder ───────────────────────────────────────────────── + // While a chip is being dragged the strip renders in the order a drop + // would produce (see [`crate::ui::reorder`]): the dragged chip travels + // along the row itself — nothing floats over the window — and every + // chip it passes slides over to meet it. `slots` collects this frame's + // chip geometry: the reference a drag starting on a later frame freezes. + let slots: Rc>>> = + Rc::new(RefCell::new(vec![Bounds::default(); self.tabs.len()])); + let preview = reorder::preview( + &self.reorder, + &Surface::Strip, + self.tabs.len(), + window.mouse_position(), + ); + // Display order: plain tab order, or the previewed one mid-drag. In the + // strip a slot *is* a tab index, so the previewed order doubles as the + // tab permutation a release would commit — recorded every frame so + // letting go applies exactly what's on screen (see `reorder`). + let display: Vec = match &preview { + Some(p) => { + reorder::set_pending(&self.reorder, &Surface::Strip, p.order.clone()); + p.order.clone() + } + None => (0..self.tabs.len()).collect(), + }; + + for i in display { // In sidebar mode the vertical rail carries the tab list; the strip // keeps only its "+"/"⋯" chrome, so skip the chip row entirely. if !show_chips { break; } + // The chip you're holding: still a chip in the row, just dimmed so + // it reads as picked up while it slides between slots. + let dragged = preview.as_ref().is_some_and(|p| p.from == i); + let tab = &self.tabs[i]; let is_active = i == active; let label = self.tab_label(tab, i, Some(window), cx); // SSH status dot (PRD FR-E2): coloured by the pane's connection phase. @@ -703,9 +761,6 @@ impl Tty7App { .as_ref() .filter(|r| r.index == i) .map(|r| r.input.clone()); - // Clean label (no pane-count suffix) for the rename prefill / drag preview. - let drag_label: SharedString = label.clone().into(); - // Either the editable input (while renaming) or the clickable, // draggable label. let label_region = match rename_input { @@ -731,42 +786,38 @@ impl Tty7App { // from the type, not from colour alone. .when(is_active, |d| d.font_weight(FontWeight::MEDIUM)) .child(label) - // Single click activates; double click zooms the window, - // same as the rest of the titlebar. (Renaming lives in the - // context menu.) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, ev: &MouseDownEvent, window, cx| { - // Swallow the event — on Windows the chip's `occlude()` - // means it would never reach the TitleBar anyway, so we - // forward the double-click zoom explicitly instead. - // Caveat: gpui only implements `titlebar_double_click` - // on macOS; on Windows/Linux it's a no-op, so the chip - // doesn't zoom there until upstream adds support. - cx.stop_propagation(); - if ev.click_count >= 2 { - window.titlebar_double_click(); - } else { - this.activate(i, window, cx); - } - }), - ) - // Drag the tab by its label to reorder it. - .on_drag( - DragTab { - index: i, - label: drag_label.clone(), - }, - |drag, _, _, cx| { - cx.stop_propagation(); - cx.new(|_| drag.clone()) - }, - ) + // No mouse handler of its own: click and drag both live on + // the chip, and a child that swallowed the press would take + // the label — most of the chip — out of both. .into_any_element(), }; let chip = h_flex() .id(("tab-chip", i)) + // Drag anywhere on the chip to reorder it. On the chip, not on + // its label: the drag's frame of reference is where the *chip* + // was grabbed, which is what the frozen geometry below + // measures — hang it off the label and the held chip rides + // offset from the cursor, skewing every crossing by that much. + // The builder runs once, when gpui promotes the press into a + // drag, freezing the strip's geometry as of the last painted + // frame. + .on_drag(DragTab, { + let state = self.reorder.clone(); + let slots = slots.clone(); + move |_drag, grab, _window, cx| { + cx.stop_propagation(); + *state.borrow_mut() = Some(Reorder::new( + Surface::Strip, + i, + slots.borrow().clone(), + Axis::Horizontal, + px(CHIP_GAP), + grab, + )); + cx.new(|_| DragTab) + } + }) // The strip lives inside gpui-component's `TitleBar`, which marks // its whole area as `WindowControlArea::Drag`. On Windows that maps // to `HTCAPTION`, so unless an element on top registers a @@ -779,6 +830,10 @@ impl Tty7App { // A group so this chip's close affordance can reveal on hover // (progressive disclosure) without affecting sibling tabs. .group(SharedString::from(format!("tab-chip-{i}"))) + // Same as the sidebar rows: a chip is a switch target first, so + // hover says "click me" and the drag swaps in the closed hand + // (see `Tty7App::render`). + .cursor_pointer() .items_center() .justify_between() .gap_1p5() @@ -809,20 +864,53 @@ impl Tty7App { s.text_color(cx.theme().muted_foreground) .hover(|s| s.bg(cx.theme().muted)) }) - // Drop target: dropping a dragged tab here moves it to this slot. - .drag_over::(|s, _, _, cx| s.bg(cx.theme().drag_border.opacity(0.2))) - .on_drop(cx.listener(move |this, drag: &DragTab, _window, cx| { - this.move_tab(drag.index, i, cx); - })) - // A click anywhere on the chip activates the tab. Clicks on the - // label or close button are handled by those children (which stop - // propagation), so this fires for the rest — icon, padding, the - // bare chip — making the whole tab a switch target, not just text. + // Held: a light dimming so the chip under your cursor reads as + // picked up. Not a lift — it stays in the row's own plane. + .when(dragged, |s| s.opacity(0.75)) + // Measures this chip into the frame's slot table. Absolute and + // empty, so it costs the layout nothing. + .child( + canvas( + { + let slots = slots.clone(); + move |bounds, _window, _cx| { + if let Some(slot) = slots.borrow_mut().get_mut(i) { + *slot = bounds; + } + } + }, + |_, _, _, _| {}, + ) + // `inset_0`, not `size_full`: an absolutely-positioned + // child with no insets is laid out at its parent's + // *content* box, so a measuring canvas inside a padded + // element would report an origin shifted right by the + // left padding — and the held chip would ride that far + // off the cursor. Pinning all four insets to 0 anchors it + // to the padding box, which is the chip itself. + .absolute() + .inset_0(), + ) + // A click anywhere on the chip activates the tab; a double click + // zooms the window, as the rest of the title bar does. Both live + // here rather than on the label so the whole chip — label, icon, + // padding — is one switch target and one drag handle. (The close + // button and the rename input stop the press for their own use.) + // + // The event is swallowed: on Windows the chip's `occlude()` means + // it would never reach the TitleBar anyway, so the zoom is + // forwarded explicitly. Caveat: gpui only implements + // `titlebar_double_click` on macOS; elsewhere it's a no-op until + // upstream adds support. .on_mouse_down( MouseButton::Left, - cx.listener(move |this, _: &MouseDownEvent, window, cx| { + cx.listener(move |this, ev: &MouseDownEvent, window, cx| { cx.stop_propagation(); - this.activate(i, window, cx); + if ev.click_count >= 2 { + window.titlebar_double_click(); + } else { + this.activate(i, window, cx); + } }), ) // Leading SSH status dot when this tab hosts an SSH session. @@ -851,58 +939,110 @@ impl Tty7App { }) // Clickable / editable label region. .child(label_region) - // Trailing slot: normally the close affordance — kept out of the - // way (opacity 0) on every chip, active or not, and fades in on - // chip hover, so a row of tabs reads clean instead of - // three-icons-per-chip busy. Space is reserved either way, so - // nothing shifts on hover. While the shortcut hints are armed, - // the same slot shows the tab's ⌘N badge instead. - .child(if show_badges && i < 9 { + // Trailing ⌘N badge: while the shortcut hints are armed the + // badge takes an in-flow 20px slot (the strip reflows once as + // the hints arm/disarm — a deliberate, all-chips-at-once modal + // moment). It can't float like the close button below: badges + // also show on unhovered inactive chips, which are transparent + // over the window background (possibly a gradient or image), so + // there's no solid colour to back an overlay with. + .when(show_badges && i < 9, |chip| { // Bare digit, no keycap box — the hint blends into the chip - // rather than reading as another button. Sized to the exact - // 20px square of the close button it stands in for, so the - // swap can never change the chip's width (an ellipsized - // label would otherwise reflow and the strip would jitter). - div() - .flex_shrink_0() - .flex() - .items_center() - .justify_center() - .size(px(20.)) - .text_xs() - .font_weight(FontWeight::MEDIUM) - .text_color(if is_active { - cx.theme().foreground - } else { - cx.theme().muted_foreground - }) - .child(tab_badge_label(i)) - .into_any_element() - } else { - div() - .flex_shrink_0() - .opacity(0.) - .group_hover(SharedString::from(format!("tab-chip-{i}")), |s| { - s.opacity(1.) - }) - .child( - Button::new(("tab-close", i)) - .icon(IconName::Close) - .ghost() - .xsmall() - .on_click(cx.listener(move |this, _, window, cx| { - this.close_tab(i, window, cx); - })), - ) - .into_any_element() + // rather than reading as another button. + chip.child( + div() + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .size(px(20.)) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(if is_active { + cx.theme().foreground + } else { + cx.theme().muted_foreground + }) + .child(tab_badge_label(i)), + ) + }) + // Close affordance: out of flow, so the label runs the full + // chip width instead of always reserving a 20px slot for a + // button that's invisible until hover. On hover the ✕ floats + // over the label's right edge (Safari-style) on a solid backing + // in the chip's current fill — `secondary` on the active chip, + // `muted` on an inactive one, whose hover fill is exactly what + // the ✕'s visibility implies — with a short gradient run-in so + // covered text fades out instead of hard-cutting mid-glyph. + // Nothing reflows on hover. + .when(!(show_badges && i < 9), |chip| { + let backing = if is_active { + cx.theme().secondary + } else { + cx.theme().muted + }; + let mut fade_from = backing; + fade_from.a = 0.; + chip.child( + h_flex() + .absolute() + .top(px(5.)) + .right(px(6.)) + .opacity(0.) + .group_hover(SharedString::from(format!("tab-chip-{i}")), |s| { + s.opacity(1.) + }) + .child(div().w(px(10.)).h(px(20.)).bg(linear_gradient( + 90., + linear_color_stop(fade_from, 0.), + linear_color_stop(backing, 1.), + ))) + .child( + div().bg(backing).child( + Button::new(("tab-close", i)) + .icon(IconName::Close) + .ghost() + .xsmall() + .on_click(cx.listener(move |this, _, window, cx| { + this.close_tab(i, window, cx); + })), + ), + ), + ) }); // Per-tab right-click menu (rename / worktree / split / copy cwd / // close group) — the same builder the sidebar rows use. let menu_app = cx.entity().downgrade(); - chips = chips.child(chip.context_menu(move |menu, window, cx| { + let chip = chip.context_menu(move |menu, window, cx| { Self::tab_context_menu(menu, i, false, &menu_app, window, cx) - })); + }); + chips = chips.child(match &preview { + // The chip in hand: drawn wherever the cursor is holding it, + // pixel for pixel, with no animation in the way. `deferred` + // keeps its slot in the layout but paints it after its + // siblings, so it passes *over* the chips it's crossing + // instead of being clipped behind them. + Some(p) if p.from == i => deferred(chip.relative().left(p.held)).into_any_element(), + // A chip the drag just crossed starts the frame where it used + // to be and eases to its new slot. `offset` is zero for every + // chip the last crossing left alone, so this is one moving + // chip at a time, not the whole row re-animating every frame. + Some(p) => { + let offset = p.offsets[i].as_f32(); + chip.with_animation( + ( + SharedString::from(format!("chip-slide-{}", p.generation)), + i, + ), + Animation::new(std::time::Duration::from_millis(REORDER_SLIDE_MS)) + .with_easing(ease_out_quint()), + move |el, delta| el.left(px(offset * (1. - delta))), + ) + .into_any_element() + } + None => chip.into_any_element(), + }); } // "+" new-tab button — click opens the shell picker. The default shell @@ -993,6 +1133,7 @@ impl Tty7App { // Only `chips` is width-capped and `overflow_hidden`, so neither button is // pushed off-screen no matter how many tabs are open. h_flex() + .id("tab-strip") .items_center() .gap_1p5() // Chip mode: viewport-derived width (see `strip_w`) so the right edge — diff --git a/src/ui/theme.rs b/src/ui/theme.rs index eee5e9e2..936e8202 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -87,12 +87,37 @@ pub(crate) fn window_background(bg: &presets::ActiveBackground) -> Background { } } +/// Whether the OS is currently in dark mode, as gpui reports it. Only +/// meaningful while the native appearance isn't pinned (see +/// [`sync_native_appearance`]) — a pinned appearance reports the pin, not the +/// OS setting, which is why callers gate on `Config::theme_follow_system`. +pub(crate) fn system_dark(cx: &App) -> bool { + matches!( + cx.window_appearance(), + gpui::WindowAppearance::Dark | gpui::WindowAppearance::VibrantDark + ) +} + +/// The id of the theme that should be on screen right now: the light/dark +/// slot matching the OS appearance while `Config::theme_follow_system` is on, +/// otherwise the manual `Config::theme_preset`. +pub(crate) fn effective_preset_id(cx: &App) -> String { + let config = cx.global::(); + if !config.theme_follow_system { + config.theme_preset.clone() + } else if system_dark(cx) { + config.theme_preset_dark.clone() + } else { + config.theme_preset_light.clone() + } +} + /// The background appearance the window should be *created* with: Blurred when /// the effective theme wants blur, otherwise Transparent — never Opaque, so the /// opacity slider works live (see the comment in [`apply_theme`]). pub(crate) fn background_appearance(cx: &App) -> WindowBackgroundAppearance { let config = cx.global::(); - let theme = presets::by_id(cx, &config.theme_preset); + let theme = presets::by_id(cx, &effective_preset_id(cx)); if config.window_blur.unwrap_or(theme.blur) { WindowBackgroundAppearance::Blurred } else { @@ -100,15 +125,23 @@ pub(crate) fn background_appearance(cx: &App) -> WindowBackgroundAppearance { } } -/// Paint gpui-component's `Theme` from the active color theme (selected by -/// `Config::theme_preset`). The theme's inferred `dark` brightness picks the +/// Paint gpui-component's `Theme` from the active color theme (resolved by +/// [`effective_preset_id`]). The theme's inferred `dark` brightness picks the /// component `ThemeMode`; every shell surface is then derived from the theme's /// background/foreground (see `Theme::neutrals`). Also publishes the /// terminal-facing palette as the `ActivePalette` global so the renderer matches, /// and applies the theme's window opacity/blur. pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { + let follow = cx.global::().theme_follow_system; + // While following the OS the native pin must be released *before* the + // theme is resolved: `effective_preset_id` reads the system appearance + // through `effectiveAppearance`, which keeps reporting the pinned value + // until the pin is cleared. + if follow { + sync_native_appearance(None); + } + let theme = presets::by_id(cx, &effective_preset_id(cx)); let config = cx.global::(); - let theme = presets::by_id(cx, &config.theme_preset.clone()); let mode = if theme.dark { ThemeMode::Dark } else { @@ -120,8 +153,12 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { let opacity = config.window_opacity.or(theme.opacity).filter(|o| *o < 1.0); let blur = config.window_blur.unwrap_or(theme.blur); // Force the native macOS chrome (traffic lights, system menus, scrollbars) - // into the theme's own light/dark mode regardless of the OS setting. - sync_native_appearance(theme.dark); + // into the theme's own light/dark mode regardless of the OS setting — + // only while *not* following the OS, where the chrome should track the + // system (and pinning would blind `system_dark` to OS flips). + if !follow { + sync_native_appearance(Some(theme.dark)); + } let m = theme.neutrals(); let active = theme.active_palette(); @@ -278,8 +315,9 @@ pub(crate) fn apply_cursor_hide_mode(cx: &mut App) { cx.set_cursor_hide_mode(mode); } -/// Force the macOS app appearance to match the active theme's light/dark mode -/// instead of following the OS `Appearance` setting. +/// Pin the macOS app appearance to the active theme's light/dark mode +/// (`Some(dark)`), or release the pin so it follows the OS `Appearance` +/// setting again (`None`, used while `Config::theme_follow_system` is on). /// /// macOS draws the native traffic-light buttons according to the window's /// effective appearance. With a dark tty7 theme on a light-mode macOS, the @@ -289,7 +327,7 @@ pub(crate) fn apply_cursor_hide_mode(cx: &mut App) { /// no setter, so we pin `NSApplication.appearance` ourselves via AppKit. This /// also keeps system menus, context menus and scrollbars in the right mode. #[cfg(target_os = "macos")] -fn sync_native_appearance(dark: bool) { +fn sync_native_appearance(dark: Option) { use objc2::MainThreadMarker; use objc2_app_kit::{ NSAppearance, NSAppearanceNameAqua, NSAppearanceNameDarkAqua, NSApplication, @@ -300,18 +338,20 @@ fn sync_native_appearance(dark: bool) { let Some(mtm) = MainThreadMarker::new() else { return; }; - // SAFETY: reading the framework-provided appearance-name statics. - let name = unsafe { - if dark { - NSAppearanceNameDarkAqua - } else { - NSAppearanceNameAqua - } - }; - if let Some(appearance) = NSAppearance::appearanceNamed(name) { - NSApplication::sharedApplication(mtm).setAppearance(Some(&appearance)); - } + let appearance = dark.and_then(|dark| { + // SAFETY: reading the framework-provided appearance-name statics. + let name = unsafe { + if dark { + NSAppearanceNameDarkAqua + } else { + NSAppearanceNameAqua + } + }; + NSAppearance::appearanceNamed(name) + }); + // `None` here means "inherit from the system" — the AppKit way to unpin. + NSApplication::sharedApplication(mtm).setAppearance(appearance.as_deref()); } #[cfg(not(target_os = "macos"))] -fn sync_native_appearance(_dark: bool) {} +fn sync_native_appearance(_dark: Option) {} diff --git a/src/ui/tray/icon.rs b/src/ui/tray/icon.rs index 1d7c92a6..ae11b045 100644 --- a/src/ui/tray/icon.rs +++ b/src/ui/tray/icon.rs @@ -1,12 +1,12 @@ //! Tray bitmap rendering: the bundled SVGs rasterized with `resvg` (gpui's //! own SVG path only yields a tinted alpha mask, so the tray draws its own). //! -//! Two states per platform: +//! Per platform: //! - macOS: the outline terminal glyph (`tray.svg`) as a *template* image — -//! the system recolors its alpha for light/dark menu bars. Attention swaps -//! to a non-template variant: the glyph recolored to a mid-grey that reads -//! on both bar appearances, plus an amber badge (template images can't -//! carry color, so attention opts out of templating). +//! the system recolors its alpha for light/dark menu bars, permanently. +//! Attention never touches the bitmap (template images can't carry color, +//! and leaving template mode made the glyph illegible); the tooltip and +//! menu carry agent status instead (see `native.rs`). //! - Windows / Linux: the colored app icon (`app-icon.svg`); attention //! punches a transparent ring into the corner and fills an amber badge, so //! the badge separates from the orange tile behind it. @@ -27,9 +27,9 @@ const GLYPH_SVG: &[u8] = include_bytes!("../../../assets/tray.svg"); #[cfg(not(target_os = "macos"))] const GLYPH_SVG: &[u8] = include_bytes!("../../../assets/app-icon.svg"); -/// Physical pixel size. macOS forces the NSImage to 18 pt in the status bar -/// regardless of pixel size (see tray-icon's macOS backend), so 36 px renders -/// crisp on retina. Windows tray slots are 16–32 px; 32 downsamples cleanly. +/// Physical pixel size. On macOS the bitmap is 36 px (retina-crisp at 18 pt); +/// native.rs then overrides the NSImage to 22 pt so the glyph fills the menu +/// bar. Windows tray slots are 16–32 px; 32 downsamples cleanly. #[cfg(target_os = "macos")] const SIZE: u32 = 36; #[cfg(not(target_os = "macos"))] @@ -37,22 +37,32 @@ const SIZE: u32 = 32; /// The `Waiting` amber, same hue as the in-window status dot /// (`AgentStatus::dot_rgb`). +#[cfg(not(target_os = "macos"))] const AMBER: (u8, u8, u8) = (0xF5, 0x9E, 0x0B); -/// Render the tray icon. `attention` = some agent is blocked on the user. -/// `None` only on a malformed bundled SVG, i.e. never in practice — callers -/// treat it as "no icon change". +/// Render the tray icon: the template outline glyph, always — attention +/// never touches the bitmap, so the icon stays a template image the system +/// keeps legible on any bar. `None` only on a malformed bundled SVG, i.e. +/// never in practice — callers treat it as "no icon change". +#[cfg(target_os = "macos")] +pub(super) fn render() -> Option { + let tree = usvg::Tree::from_data(GLYPH_SVG, &usvg::Options::default()).ok()?; + let mut pixmap = tiny_skia::Pixmap::new(SIZE, SIZE)?; + resvg::render(&tree, fit_center(&tree, SIZE), &mut pixmap.as_mut()); + Some(to_rgba(&pixmap)) +} + +/// Render the tray icon. `attention` = some agent is blocked on the user — +/// stamps the amber badge into the colored app icon. `None` only on a +/// malformed bundled SVG, i.e. never in practice — callers treat it as "no +/// icon change". +#[cfg(not(target_os = "macos"))] pub(super) fn render(attention: bool) -> Option { let tree = usvg::Tree::from_data(GLYPH_SVG, &usvg::Options::default()).ok()?; let mut pixmap = tiny_skia::Pixmap::new(SIZE, SIZE)?; resvg::render(&tree, fit_center(&tree, SIZE), &mut pixmap.as_mut()); if attention { - // macOS attention leaves template mode (color needs real RGB), so the - // glyph must carry its own color: a mid-grey legible on both light - // and dark menu bars. Colored platforms keep the icon's own colors. - #[cfg(target_os = "macos")] - recolor(&mut pixmap, (0x8E, 0x8E, 0x93)); badge(&mut pixmap); } @@ -202,6 +212,7 @@ fn recolor(pixmap: &mut tiny_skia::Pixmap, rgb: (u8, u8, u8)) { /// Stamp the amber attention badge in the top-right corner: first clear a /// slightly larger disc so the badge is ringed by transparency (separating it /// from whatever the glyph or a colored tile puts behind it), then fill. +#[cfg(not(target_os = "macos"))] fn badge(pixmap: &mut tiny_skia::Pixmap) { let s = SIZE as f32; let (cx, cy) = (s * 0.78, s * 0.22); @@ -301,8 +312,20 @@ mod tests { assert_eq!((px[1], px[2]), (0, 0)); } + /// The template glyph renders at the declared size with visible coverage. + #[cfg(target_os = "macos")] + #[test] + fn render_produces_template_glyph() { + let img = render().unwrap(); + assert_eq!((img.width, img.height), (SIZE, SIZE)); + assert_eq!(img.data.len(), (SIZE * SIZE * 4) as usize); + let covered = img.data.chunks_exact(4).filter(|p| p[3] > 0).count(); + assert!(covered > 0, "icon rendered fully transparent"); + } + /// Both tray states render at the declared size with visible coverage, /// and the attention badge actually changes the bitmap. + #[cfg(not(target_os = "macos"))] #[test] fn render_produces_both_states() { let normal = render(false).unwrap(); diff --git a/src/ui/tray/mod.rs b/src/ui/tray/mod.rs index a41d246c..df8b0a0b 100644 --- a/src/ui/tray/mod.rs +++ b/src/ui/tray/mod.rs @@ -1,10 +1,12 @@ //! System tray / menu bar status item. //! -//! The tray is the app's face outside the window: the icon flips to an -//! attention state the moment any pane's coding agent blocks on the user -//! (amber `Waiting`), and the menu lists every agent pane — click one to -//! reveal it — plus window/notification/quit controls. Menu labels are -//! English, matching the native app menus (`ui::theme::set_menus`). +//! The tray is the app's face outside the window: on Windows/Linux the icon +//! flips to an amber-badged attention state the moment any pane's coding +//! agent blocks on the user (`Waiting`); on macOS the icon is a template +//! image that stays calm in every state (legible on any bar) — agent status +//! lives in the tooltip and menu instead. The menu lists every agent pane — +//! click one to reveal it — plus window/notification/quit controls. Menu +//! labels are English, matching the native app menus (`ui::theme::set_menus`). //! //! Platform split (see Cargo.toml for the why): //! - macOS / Windows: tauri's `tray-icon` (NSStatusItem / Shell_NotifyIcon), @@ -90,7 +92,10 @@ pub(crate) struct TraySnapshot { } impl TraySnapshot { - /// Whether any agent is blocked on the user — drives the attention icon. + /// Whether any agent is blocked on the user — drives the attention badge + /// on Windows/Linux (the macOS icon stays calm; the tooltip and menu + /// carry agent status there). + #[cfg_attr(target_os = "macos", allow(dead_code))] pub(crate) fn attention(&self) -> bool { self.agents.iter().any(|a| a.status == AgentStatus::Waiting) } @@ -266,6 +271,7 @@ mod tests { fn attention_follows_waiting_and_tooltip_counts() { assert!(snapshot_with_agent(AgentStatus::Waiting).attention()); assert!(!snapshot_with_agent(AgentStatus::Working).attention()); + assert!(!snapshot_with_agent(AgentStatus::Done).attention()); assert_eq!( snapshot_with_agent(AgentStatus::Waiting).tooltip(), "tty7 — 1 waiting" diff --git a/src/ui/tray/native.rs b/src/ui/tray/native.rs index 5e818250..4eb8b762 100644 --- a/src/ui/tray/native.rs +++ b/src/ui/tray/native.rs @@ -14,8 +14,10 @@ use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; pub(super) struct Backend { tray: TrayIcon, - /// The icon state currently shown, so a snapshot diff that doesn't flip - /// attention skips the bitmap rebuild. + /// Whether the amber badge is currently stamped, so a snapshot diff that + /// doesn't flip attention skips the bitmap rebuild. macOS tracks nothing: + /// its template glyph never changes (see `icon.rs`). + #[cfg(not(target_os = "macos"))] attention: bool, } @@ -37,12 +39,15 @@ impl Backend { } })); + #[cfg(target_os = "macos")] + let img = icon::render()?; + #[cfg(not(target_os = "macos"))] let img = icon::render(false)?; let icon = Icon::from_rgba(img.data, img.width, img.height).ok()?; let tray = TrayIconBuilder::new() .with_icon(icon) - // The calm glyph is a template on macOS (system recolors it for - // the bar); a no-op on Windows. + // The glyph is a template on macOS (system recolors it for the + // bar, in both states); a no-op on Windows. .with_icon_as_template(true) .with_tooltip("tty7") .with_menu(Box::new(build_menu(&TraySnapshot::default()))) @@ -57,26 +62,50 @@ impl Backend { return None; } }; + + // tray-icon hardcodes the NSImage height to 18 pt; override to a + // larger size so the glyph fills more of the menu bar. The bitmap + // itself is already rendered at `icon::SIZE` px (retina-ready). + #[cfg(target_os = "macos")] + if let Some(status_item) = tray.ns_status_item() { + if let Some(mtm) = objc2::MainThreadMarker::new() { + if let Some(button) = status_item.button(mtm) { + if let Some(nsimage) = button.image() { + // 22 pt matches the macOS menu bar height; the glyph + // scales proportionally from its 96×96 viewBox. + let target_h: f64 = 22.0; + let aspect = nsimage.size().width / nsimage.size().height; + nsimage.setSize(objc2_foundation::NSSize::new(target_h * aspect, target_h)); + } + } + } + } Some(Self { tray, + #[cfg(not(target_os = "macos"))] attention: false, }) } /// Push a changed snapshot into the native item: menu always (it's what - /// changed), icon only across an attention flip. + /// changed); on Windows, the badge only across an attention flip. The + /// macOS icon is a template image in every state — the system recolors it + /// for the bar, and it never carries an attention mark (status lives in + /// the tooltip and menu). pub(super) fn update(&mut self, snap: &TraySnapshot) { self.tray.set_menu(Some(Box::new(build_menu(snap)))); let _ = self.tray.set_tooltip(Some(snap.tooltip())); - let attention = snap.attention(); - if attention != self.attention { - self.attention = attention; - if let Some(img) = icon::render(attention) - && let Ok(icon) = Icon::from_rgba(img.data, img.width, img.height) - { - // Attention leaves template mode: the amber badge needs - // its real color (macOS; the flag is a no-op on Windows). - let _ = self.tray.set_icon_with_as_template(Some(icon), !attention); + // Windows: flip the amber corner badge on the colored icon. + #[cfg(not(target_os = "macos"))] + { + let attention = snap.attention(); + if attention != self.attention { + self.attention = attention; + if let Some(img) = icon::render(attention) + && let Ok(icon) = Icon::from_rgba(img.data, img.width, img.height) + { + let _ = self.tray.set_icon(Some(icon)); + } } } }