Merge branch 'main' into worktree-code-panel

# Conflicts:
#	src/core/config.rs
#	src/ui/mod.rs
#	src/ui/tab_sidebar.rs
#	src/ui/tab_strip.rs
This commit is contained in:
l0ng-ai
2026-07-24 15:15:08 +08:00
59 changed files with 7628 additions and 780 deletions
+9 -2
View File
@@ -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 }}
+2 -2
View File
@@ -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
+5 -1
View File
@@ -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
+3 -2
View File
@@ -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/
+93
View File
@@ -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
Generated
+212 -23
View File
@@ -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"
+22 -2
View File
@@ -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
+1 -1
View File
@@ -11,7 +11,7 @@
<br />
[![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)
+1 -1
View File
@@ -11,7 +11,7 @@
<br />
[![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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

+10 -5
View File
@@ -1,6 +1,11 @@
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
<rect x="8" y="8" width="104" height="104" rx="24" fill="#ff8a5c"/>
<rect x="26" y="32" width="68" height="56" rx="13" fill="none" stroke="#0d1117" stroke-width="6"/>
<line x1="26" y1="49" x2="94" y2="49" stroke="#0d1117" stroke-width="4" opacity="0.7"/>
<rect x="60" y="60" width="12" height="20" rx="2.5" fill="#0d1117"/>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<!-- "Duo" mark (design 2a): two offset session panes, prompt chevron in the
front one. Tile inset + radius follow the macOS Big Sur icon grid
(824pt tile @1024). -->
<rect x="100" y="100" width="824" height="824" rx="185" fill="#ECEAE4"/>
<g transform="translate(108.8 125.6) scale(8.4)">
<rect x="28" y="16" width="58" height="46" rx="12" fill="#3FDD8C" opacity="0.8"/>
<rect x="10" y="30" width="58" height="46" rx="12" fill="#17171A"/>
<path d="M24 42 L35 53 L24 64" fill="none" stroke="#ECEAE4" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 427 B

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+5 -4
View File
@@ -1,5 +1,6 @@
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
<rect x="12" y="22" width="96" height="76" rx="15" fill="none" stroke="#3F3F46" stroke-width="6"/>
<line x1="12" y1="44" x2="108" y2="44" stroke="#3F3F46" stroke-width="4" opacity="0.7"/>
<rect x="52" y="58" width="15" height="26" rx="3" fill="#FF6B35"/>
<svg width="120" height="120" viewBox="8 6 80 80" xmlns="http://www.w3.org/2000/svg">
<!-- "Duo" mark on transparent — same geometry as app-icon.svg, no tile. -->
<rect x="28" y="16" width="58" height="46" rx="12" fill="#3FDD8C" opacity="0.8"/>
<rect x="10" y="30" width="58" height="46" rx="12" fill="#17171A"/>
<path d="M24 42 L35 53 L24 64" fill="none" stroke="#ECEAE4" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 356 B

After

Width:  |  Height:  |  Size: 456 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 532 KiB

After

Width:  |  Height:  |  Size: 137 KiB

+15 -4
View File
@@ -1,5 +1,16 @@
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
<rect x="11" y="21" width="98" height="78" rx="18" fill="none" stroke="#000" stroke-width="9"/>
<path d="M34 48 L50 61 L34 74" fill="none" stroke="#000" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="60" y1="76" x2="82" y2="76" stroke="#000" stroke-width="9" stroke-linecap="round"/>
<svg width="96" height="96" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
<!-- Template (alpha-only) tray glyph: the "Duo" mark, same geometry as
app-icon.svg. Solid front pane with the chevron knocked out; the back
pane reads through partial alpha (template images keep alpha, drop
color). The mask spans both panes so the chevron cuts clean through. -->
<defs>
<mask id="chevron-cut">
<rect width="96" height="96" fill="#fff"/>
<path d="M24 42 L35 53 L24 64" fill="none" stroke="#000" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
</mask>
</defs>
<g mask="url(#chevron-cut)">
<rect x="28" y="16" width="58" height="46" rx="12" fill="#000" opacity="0.35"/>
<rect x="10" y="30" width="58" height="46" rx="12" fill="#000"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 414 B

After

Width:  |  Height:  |  Size: 824 B

BIN
View File
Binary file not shown.
+4 -2
View File
@@ -5,7 +5,7 @@
## Input
- **Ghost suggestions** — your history completes the whole line as you type; <kbd>→</kbd> 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** — <kbd>⌃ R</kbd> 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** <kbd>⌘ P</kbd> · scrollback search <kbd>⌘ F</kbd>
- **⌘-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)
+4 -2
View File
@@ -5,7 +5,7 @@
## 输入
- **影子建议** —— 边打字边用你的历史补全整条命令,<kbd>→</kbd> 接受
- **带说明的 Tab 补全** —— 每个 flag、每个子命令都带说明,覆盖约 100 个常用命令
- **带说明的 Tab 补全** —— 每个 flag、每个子命令都带说明,覆盖约 100 个常用命令;tty7 没有候选时 Tab 自动交给 shell 自己的补全,整个功能也可关闭(设置 → 终端 → 键盘,或 `config.json` 里的 `tab_completion`
- **语法高亮** —— 边打边亮,什么都不用装
- **模糊历史搜索** —— <kbd>⌃ R</kbd> 看到每条命令在哪跑的、什么时候、有没有失败
- **历史开箱即用** —— 你已有的 shell 历史直接生效,并跨会话延续
@@ -18,7 +18,9 @@
- **侧栏按仓库分组** —— 左侧标签栏按 git 仓库分组、每组一个标题行,不在仓库里的标签归入末尾的 *Scratch* 组;切分支、仓库内 `cd` 都不会挪动行(`config.json``sidebar_grouping`:默认 `repo``none` 恢复扁平列表)
- **命令面板** <kbd>⌘ P</kbd> · 回滚搜索 <kbd>⌘ F</kbd>
- **⌘ 点击打开链接** · 桌面通知 · 划选即复制(可选,设置 → 终端 → 剪贴板)
- **智能双击选中** —— 双击直接选中整条 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`,默认开启)
+47 -25
View File
@@ -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<u8> {
"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<String> {
let path = agent
.target_path()
@@ -412,10 +442,7 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result<String> {
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<String> {
// 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<String> {
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<String> {
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<String> {
{
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");
+433 -17
View File
@@ -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<String> {
///
/// `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<String> {
// 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 <sessionId>` (`-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 <id>`) 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<Vec<String>> {
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 <old-id>`: 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<String>,
/// 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<Vec<String>>,
/// 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<std::path::PathBuf>,
/// 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<String>,
pub message: Option<String>,
/// 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<std::path::PathBuf>,
}
/// Parse a complete OSC payload (identifier included, e.g.
@@ -621,6 +790,8 @@ pub fn parse_agent_event(payload: &[u8]) -> Option<AgentEvent> {
session_id: Option<String>,
#[serde(default)]
message: Option<String>,
#[serde(default)]
cwd: Option<String>,
}
let w: Wire = serde_json::from_slice(json).ok()?;
@@ -631,6 +802,7 @@ pub fn parse_agent_event(payload: &[u8]) -> Option<AgentEvent> {
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::<Vec<_>>();
// 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 <old>` 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]
+91
View File
@@ -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.21.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<String>,
/// 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(
+1
View File
@@ -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;
+40
View File
@@ -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
}
+25
View File
@@ -54,6 +54,12 @@ pub enum SessionPane {
agent: Option<crate::core::cli_agent::CLIAgent>,
#[serde(default)]
agent_session_id: Option<String>,
/// 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<Vec<String>>,
},
/// 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,
},
}],
};
+26 -10
View File
@@ -278,7 +278,11 @@ fn detect_windows() -> Vec<DetectedShell> {
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<DetectedShell> {
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<PathBuf> {
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<String> {
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<PathBuf> {
}
/// 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<String> {
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() {
+31
View File
@@ -74,6 +74,12 @@ pub struct SshProfile {
pub warn_on_close: Option<bool>,
/// 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<String>,
/// 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
}
+3 -4
View File
@@ -62,10 +62,9 @@ pub fn is_inside_repo(cwd: &Path) -> bool {
/// Run `git -C <dir> <args>`, returning trimmed stdout on success and trimmed
/// stderr as the error otherwise.
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
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() {
+389 -46
View File
@@ -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<String>,
/// 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<ShellSpec>,
configured: Option<(String, Vec<String>)>,
) -> Option<(String, Vec<String>)> {
spawn_override.map(|s| (s.program, s.args)).or(configured)
) -> Option<ChosenShell> {
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<ShellSpec>,
) -> anyhow::Result<SpawnConfig> {
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<RemoteContext> {
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<ShellSpec>,
configured: Option<ChosenShell>,
initial_cwd: &Option<PathBuf>,
) -> anyhow::Result<(CommandBuilder, Option<PathBuf>)> {
// 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<PathBuf>) -> Option<PathBuf> {
// 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<PathBuf>) {
@@ -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<crate::core::cli_agent::CLIAgent>,
/// 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<Vec<String>>,
/// 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;<cmd>`
/// mark). `Some(answer)` is a real poll result; its inner `None` ("polled,
/// no agent") clears the chip.
agent: Box<dyn Fn() -> Option<Option<crate::core::cli_agent::CLIAgent>> + 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<dyn Fn() -> Option<Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>> + 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<String, String>,
) -> Option<crate::core::cli_agent::CLIAgent> {
// 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<RemoteContext>) {
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<crate::core::cli_agent::CLIAgent>) {
fn apply_agent(
st: &mut PaneState,
detected: Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>,
) {
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<crate::core::cli_agent::CLIAgen
let _ = sub.send(DaemonMsg::AgentStatus(None));
}
}
if agent.is_none() {
st.agent_argv = None;
}
if let Some(sub) = &st.subscriber {
let _ = sub.send(DaemonMsg::Agent(agent));
}
st.agent = agent;
stamp_launch_argv(st, argv);
}
/// Record the detected agent's launch argv and mirror it into the rich session
/// state (pushing the change to the client) when one exists — resume-after-
/// restart reads it from there. `None` (a poll that saw no argv) and an empty
/// argv (Windows mark detection, identity without a trustworthy argv) never
/// wipe a captured value; the chip clearing in [`apply_agent`] does that.
fn stamp_launch_argv(st: &mut PaneState, argv: Option<Vec<String>>) {
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<Box<dyn MasterPty + Send>>) -> Opti
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn foreground_agent(
master: &Mutex<Box<dyn MasterPty + Send>>,
) -> Option<Option<crate::core::cli_agent::CLIAgent>> {
) -> Option<Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>> {
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<Box<dyn MasterPty + Send>>,
) -> Option<Option<crate::core::cli_agent::CLIAgent>> {
) -> Option<Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>> {
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<PathBuf> {
/// `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<PathBuf> {
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<String> {
#[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!(
+67 -3
View File
@@ -76,6 +76,18 @@ pub struct ShellSpec {
pub program: String,
#[serde(default)]
pub args: Vec<String>,
/// 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<String>,
/// 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());
}
File diff suppressed because it is too large Load Diff
+17 -5
View File
@@ -528,6 +528,10 @@ mod tests {
/// match — this is what keeps a stale pidfile with a recycled pid from
/// getting an innocent process killed. Driven with a real `sleep` child:
/// alive, path readable, basename `sleep` ≠ the test binary's.
///
/// `spawn` returns after the fork, possibly before the child has exec'd —
/// until then its executable path still reads as *this* test binary — so
/// the path assertions poll until the exec is visible.
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn reap_guard_rejects_a_live_process_of_another_executable() {
@@ -538,11 +542,19 @@ mod tests {
let pid = child.id() as libc::pid_t;
assert!(process_alive(pid), "the sleep child is alive and ours");
assert_eq!(
process_path(pid).and_then(|p| p.file_name().map(|n| n.to_os_string())),
Some("sleep".into()),
"process_path resolves an arbitrary pid, not just our parent"
);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let basename = process_path(pid).and_then(|p| p.file_name().map(|n| n.to_os_string()));
if basename == Some("sleep".into()) {
break;
}
assert!(
Instant::now() < deadline,
"process_path resolves an arbitrary pid, not just our parent \
(still {basename:?} after 5s)"
);
std::thread::sleep(Duration::from_millis(10));
}
assert!(
!process_matches_own_exe(pid),
"sleep must not match the test binary; matching here would mean the reap could kill it"
+4
View File
@@ -173,6 +173,10 @@ fn spawn_proxy_command(
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.kill_on_drop(true);
// The daemon is detached and has no console to lend this child, so without
// the flag a `ProxyCommand` (`ssh -W`, `connect.exe`, `cloudflared`) gets a
// console of its own that stays up for the whole session.
crate::core::proc::hide_console_tokio(&mut cmd);
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("spawn ProxyCommand failed: {e}"))?;
+116 -5
View File
@@ -35,12 +35,13 @@ use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::Duration;
use russh::Pty;
use russh::{ChannelMsg, Pty};
use crate::daemon::protocol::{
LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, ManagedForward, NativeSshSpec,
SshForwardRule, SshPhase, WinSize,
};
use crate::daemon::shell_integration::remote;
use forward::RemoteForwardTable;
use handler::ClientHandler;
@@ -84,6 +85,11 @@ pub struct SshManager {
/// The WS4 managed-forward registry (Local/Remote/Dynamic + native loopback),
/// driven on this manager's runtime.
forwards: SshForwardRegistry,
/// Memoized remote shell-integration probes, keyed like connections. A
/// present `None` means "probed, nothing to inject" — cached just as firmly
/// as a hit so an unintegrable host isn't re-probed on every new tab. See
/// [`SshManager::remote_bootstrap`].
probes: Mutex<HashMap<ConnectionKey, Option<(remote::RemoteShell, String)>>>,
}
impl SshManager {
@@ -101,6 +107,7 @@ impl SshManager {
runtime,
conns: Mutex::new(HashMap::new()),
forwards: SshForwardRegistry::default(),
probes: Mutex::new(HashMap::new()),
}
})
}
@@ -316,10 +323,29 @@ impl SshManager {
let _ = channel.agent_forward(false).await;
}
channel
.request_shell(true)
.await
.map_err(|e| format!("shell request failed: {e}"))?;
// Shell integration (OSC 133 + cwd reporting) for the remote shell. When
// the remote is one we know how to bootstrap, the shell is started by an
// `exec` request carrying a setup script that ends in `exec <shell>`,
// rather than by a bare `shell` request; see `shell_integration::remote`.
// Anything unrecognized — or a probe that couldn't be run — falls through
// to the plain shell request, which is exactly what every session did
// before this existed.
// Opting out short-circuits the probe too, not just the bootstrap: a
// profile with the switch off should cost nothing and touch nothing.
let bootstrap = match spec.shell_integration {
true => self.remote_bootstrap(&conn).await,
false => None,
};
match bootstrap {
Some(script) => channel
.exec(true, script)
.await
.map_err(|e| format!("shell request failed: {e}"))?,
None => channel
.request_shell(true)
.await
.map_err(|e| format!("shell request failed: {e}"))?,
}
// Login script: each line verbatim + newline, in order, no expect-logic.
for line in &spec.login_script {
@@ -341,6 +367,41 @@ impl SshManager {
self.conns.lock().unwrap().remove(key);
}
/// The shell-integration bootstrap script for `conn`'s next shell, or `None`
/// to start that shell bare.
///
/// Deciding costs one `exec` round-trip against the remote (see
/// [`probe_remote_shell`]), so the answer is memoized on the connection key —
/// the same identity connections are reused under. Opening a second tab to a
/// host therefore pays nothing, and a *reconnect* to a host probed earlier
/// pays nothing either: which shell a login lands in doesn't change between
/// connections, so the cache deliberately outlives them.
///
/// Two panes racing to a not-yet-probed host may both probe. That is a
/// duplicated round-trip on a cold connection, not a correctness problem —
/// the probe has no side effects and both arrive at the same answer — so it
/// isn't worth serializing every spawn behind a per-key lock the way
/// connection establishment is.
async fn remote_bootstrap(&self, conn: &Arc<SshConnection>) -> Option<String> {
let key = conn.key().clone();
let cached = { self.probes.lock().unwrap().get(&key).cloned() };
let probed = match cached {
Some(hit) => hit,
None => {
let probed = probe_remote_shell(conn).await;
match &probed {
Some((shell, path)) => {
log::debug!("ssh {key:?}: remote shell {shell:?} at {path}")
}
None => log::debug!("ssh {key:?}: no remote shell integration"),
}
self.probes.lock().unwrap().insert(key, probed.clone());
probed
}
};
probed.map(|(shell, path)| remote::bootstrap_command(shell, &path))
}
/// Establish (or reuse) the connection for `spec`, recursing through the jump
/// chain. Boxed because it is `async`-recursive. The returned `bool` is `true`
/// when an existing connection was reused (no fresh authentication) — the
@@ -443,6 +504,54 @@ impl SshManager {
}
}
/// How long to wait for the shell probe before giving up on integrating a
/// remote. Generous, because the probe runs under the remote's login shell and
/// therefore behind whatever its `.zshenv` does; short enough that a host which
/// never answers costs a pause, not a hang. Expiring is not an error — the
/// session continues with a plain shell.
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
/// Cap on probe output, in case the remote's startup files are chatty. Far more
/// than the two lines we asked for; a remote that exceeds it has already told us
/// everything [`remote::parse_probe`] could use.
const PROBE_OUTPUT_LIMIT: usize = 8 * 1024;
/// Ask the remote which login shell it would start, on a throwaway channel.
///
/// This is a non-PTY `exec`, so it runs and exits without touching the session
/// the user is about to get; nothing here can break that session, and every
/// failure path returns `None`, meaning "start the shell bare".
///
/// stderr is folded in with stdout because the marker-based parse tolerates
/// noise, and a remote whose startup files complain on stderr would otherwise
/// have its (perfectly good) answer thrown away.
async fn probe_remote_shell(conn: &SshConnection) -> Option<(remote::RemoteShell, String)> {
let mut channel = conn.open_session_channel().await.ok()?;
channel.exec(true, remote::PROBE_COMMAND).await.ok()?;
let mut out: Vec<u8> = Vec::new();
let collect = async {
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. } => {
out.extend_from_slice(&data);
if out.len() >= PROBE_OUTPUT_LIMIT {
break;
}
}
ChannelMsg::Eof | ChannelMsg::Close => break,
_ => {}
}
}
};
// A timeout doesn't discard what did arrive: the answer is on the second
// line, so a remote that printed it and then stalled before closing the
// channel is still perfectly readable.
let _ = tokio::time::timeout(PROBE_TIMEOUT, collect).await;
remote::parse_probe(&String::from_utf8_lossy(&out))
}
/// A conservative set of PTY modes for the shell channel — an interactive TTY
/// with canonical input, echo, and signal handling on, and standard baud codes.
/// The remote line discipline uses these as its starting point.
@@ -487,6 +596,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,
@@ -523,6 +633,7 @@ mod tests {
runtime,
conns: Mutex::new(HashMap::new()),
forwards: SshForwardRegistry::default(),
probes: Mutex::new(HashMap::new()),
};
let key = ConnectionKey::from_spec(&base_spec());
mgr.conns
+35
View File
@@ -246,6 +246,39 @@ fn enrich_path_from_login_shell() {
unsafe { std::env::set_var("PATH", merged) };
}
/// A bare (non-bundled) binary — `cargo dev` / `cargo run` — has no
/// `Info.plist` pointing the Dock at `tty7.icns`, so macOS shows the generic
/// executable icon. Feed the Dock the bundled logo at runtime in that case;
/// launches from the real `.app` keep the `.icns` and skip this.
#[cfg(target_os = "macos")]
fn set_dock_icon_for_bare_binary() {
use objc2::{AnyThread, MainThreadMarker};
use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::NSData;
let bundled = std::env::current_exe().is_ok_and(|p| {
p.components()
.any(|c| c.as_os_str().to_string_lossy().ends_with(".app"))
});
if bundled {
return;
}
// gpui's `run` closure executes on the main thread; bail defensively
// rather than panic if that ever stops holding.
let Some(mtm) = MainThreadMarker::new() else {
return;
};
static ICON_PNG: &[u8] = include_bytes!("../assets/app-icon.png");
let data = NSData::with_bytes(ICON_PNG);
if let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) {
// SAFETY: passing a valid NSImage on the main thread; AppKit copies the
// reference, no ownership transferred.
unsafe {
NSApplication::sharedApplication(mtm).setApplicationIconImage(Some(&image));
}
}
}
fn main() {
// Agent-hook mode: `tty7 agent-hook <agent> <event>` is the tiny emitter
// Claude Code's hooks invoke (see `core::agent_hooks`). It reads the hook
@@ -323,6 +356,8 @@ fn main() {
gpui_component::init(cx);
register_bundled_fonts(cx);
cx.activate(true);
#[cfg(target_os = "macos")]
set_dock_icon_for_bare_binary();
// Load user config once and stash it as a global for views to read.
cx.set_global(Config::load());
// Build the theme registry (built-ins + user theme files) before the
+96 -18
View File
@@ -222,24 +222,76 @@ impl CmdEditor {
self.cursor = self.chars.len();
}
/// Bounds `(start, end)` of the word (run of non-whitespace) containing char
/// index `idx`. On whitespace this collapses to `(idx, idx)`.
pub fn word_bounds(&self, idx: usize) -> (usize, usize) {
/// Bounds `(start, end)` of the word containing char index `idx`: the run
/// of chars that are neither whitespace nor in `separators` (the
/// configured word-separator set, shared with the grid's semantic
/// selection). A separator char is its own one-char word, matching the
/// grid; on whitespace the run collapses and the leftward walk snaps to
/// the previous word's start.
///
/// `smart` mirrors `Config::smart_select`: with it off this is exactly
/// [`Self::plain_word_bounds`], so the Settings toggle governs the prompt
/// editor and the grid alike.
pub fn word_bounds(&self, idx: usize, separators: &str, smart: bool) -> (usize, usize) {
let idx = idx.min(self.chars.len());
if !smart {
return self.plain_word_bounds(idx, separators, smart);
}
// A bracket or quote selects through its match, same as the grid.
// Checked before CJK segmentation so full-width `()`/`“”` pair
// instead of being segmented as lone punctuation tokens. Only for
// the double-click itself — drags use `plain_word_bounds` so the
// selection doesn't lurch when the pointer crosses a quote.
if let Some((s, e)) = super::smart_select::pair_range(&self.chars, idx) {
return (s, e + 1);
}
// CJK prose has no separators between words: segment it with the
// platform dictionary instead of selecting the whole unbroken run.
if let Some(&c) = self.chars.get(idx)
&& super::smart_select::is_cjk(c)
{
let text: String = self.chars.iter().collect();
if let Some((s, e)) = super::smart_select::cjk_word_range(&text, idx) {
return (s, e + 1);
}
}
self.plain_word_bounds(idx, separators, smart)
}
/// [`Self::word_bounds`] without the pair/segmentation smarts: the plain
/// separator-walk word. Used for word-granular drags, where pair matching
/// would make the selection jump around as the pointer crosses a quote.
/// `smart` still governs the mixed-script narrowing, so a drag matches
/// what the double-click that started it selected.
fn plain_word_bounds(&self, idx: usize, separators: &str, smart: bool) -> (usize, usize) {
let idx = idx.min(self.chars.len());
if let Some(&c) = self.chars.get(idx)
&& !c.is_whitespace()
&& separators.contains(c)
{
return (idx, idx + 1);
}
let boundary = |c: char| c.is_whitespace() || separators.contains(c);
let mut s = idx;
while s > 0 && !self.chars[s - 1].is_whitespace() {
while s > 0 && !boundary(self.chars[s - 1]) {
s -= 1;
}
let mut e = idx;
while e < self.chars.len() && !self.chars[e].is_whitespace() {
while e < self.chars.len() && !boundary(self.chars[e]) {
e += 1;
}
// Mixed-script runs (a Latin word glued to CJK text) shrink to the
// clicked char's script class — same correction as the grid's.
if smart && idx < e {
let (ns, ne) = super::smart_select::narrow_to_script(&self.chars, idx, s, e - 1);
return (ns, ne + 1);
}
(s, e)
}
/// Select the word (run of non-whitespace) containing char index `idx`.
pub fn select_word_at(&mut self, idx: usize) {
let (s, e) = self.word_bounds(idx);
/// Select the word containing char index `idx` (see [`Self::word_bounds`]).
pub fn select_word_at(&mut self, idx: usize, separators: &str, smart: bool) {
let (s, e) = self.word_bounds(idx, separators, smart);
self.anchor = Some(s);
self.cursor = e;
}
@@ -249,8 +301,15 @@ impl CmdEditor {
/// selection grows by whole words: dragging past the anchor word selects
/// forward to the far edge of the word under `idx`, dragging before it selects
/// backward to that word's near edge. The cursor sits at the moving edge.
pub fn extend_word_to(&mut self, anchor_start: usize, anchor_end: usize, idx: usize) {
let (ws, we) = self.word_bounds(idx.min(self.chars.len()));
pub fn extend_word_to(
&mut self,
anchor_start: usize,
anchor_end: usize,
idx: usize,
separators: &str,
smart: bool,
) {
let (ws, we) = self.plain_word_bounds(idx.min(self.chars.len()), separators, smart);
if we >= anchor_end {
self.anchor = Some(anchor_start);
self.cursor = we;
@@ -573,15 +632,34 @@ mod tests {
assert_eq!(e.selection(), None);
}
/// The default word-separator set (mirrors `Config::word_separators`).
const SEPS: &str = ",│`|:\"' ()[]{}<>\t";
#[test]
fn select_word_and_all() {
let mut e = ed("git push origin", 6);
e.select_word_at(6); // cursor on "push"
e.select_word_at(6, SEPS, true); // cursor on "push"
assert_eq!(e.selected_text().as_deref(), Some("push"));
e.select_all();
assert_eq!(e.selection(), Some((0, 15)));
}
#[test]
fn select_word_stops_at_separators() {
// Quotes and commas bound a word; a separator char is its own word.
let mut e = ed("echo 'a,b'", 0);
e.select_word_at(6, SEPS, true); // on "a"
assert_eq!(e.selected_text().as_deref(), Some("a"));
e.select_word_at(7, SEPS, true); // on the comma itself
assert_eq!(e.selected_text().as_deref(), Some(","));
e.select_word_at(5, SEPS, true); // on the opening quote: pairs to the close
assert_eq!(e.selected_text().as_deref(), Some("'a,b'"));
// `/ . - _ =` are not separators: a path stays one word.
let mut e = ed("cat ./a-b/c_d.txt", 0);
e.select_word_at(8, SEPS, true);
assert_eq!(e.selected_text().as_deref(), Some("./a-b/c_d.txt"));
}
#[test]
fn extend_to_keeps_anchor() {
let mut e = ed("abcdef", 2);
@@ -595,20 +673,20 @@ mod tests {
fn extend_word_to_grows_by_whole_words_both_directions() {
// Double-click "push" (chars 4..8), then drag over later/earlier words.
let mut e = ed("git push origin main", 4);
e.select_word_at(6);
e.select_word_at(6, SEPS, true);
let (s, a) = e.selection().unwrap(); // (4, 8) == "push"
assert_eq!((s, a), (4, 8));
// Drag forward into "origin": selection reaches that word's far edge.
e.extend_word_to(s, a, 10);
e.extend_word_to(s, a, 10, SEPS, true);
assert_eq!(e.selected_text().as_deref(), Some("push origin"));
// Drag on into "main": grows to its end.
e.extend_word_to(s, a, 18);
e.extend_word_to(s, a, 18, SEPS, true);
assert_eq!(e.selected_text().as_deref(), Some("push origin main"));
// Drag backward before the anchor word into "git": anchor flips to the
// word's far edge, selection covers "git push".
e.extend_word_to(s, a, 1);
e.extend_word_to(s, a, 1, SEPS, true);
assert_eq!(e.selected_text().as_deref(), Some("git push"));
}
@@ -673,15 +751,15 @@ mod tests {
// that word — the same left-scan that makes a double-click at the end
// of the line select the last word.
let mut e = ed("ab cd", 0);
e.select_word_at(2); // the space between the words
e.select_word_at(2, SEPS, true); // the space between the words
assert_eq!(e.selected_text().as_deref(), Some("ab"));
// Index at/past the end selects the trailing word, clamped.
e.select_word_at(99);
e.select_word_at(99, SEPS, true);
assert_eq!(e.selected_text().as_deref(), Some("cd"));
// On a gap wider than one cell there is no adjacent word to the left of
// the clicked cell: the empty range collapses to no selection.
let mut e = ed("ab cd", 0);
e.select_word_at(3); // second space: both neighbours are whitespace
e.select_word_at(3, SEPS, true); // second space: both neighbours are whitespace
assert_eq!(e.selection(), None);
}
+202 -27
View File
@@ -118,10 +118,38 @@ const BUILTINS: &[&str] = &[
/// (or `$PATH` entries) can't blow up the UI or the cycle.
const MAX_CANDIDATES: usize = 400;
/// Commands whose arguments are directories, never files. They have no Fig
/// signature (shell builtins), so the generic path fallback handles them —
/// which must not offer files (`cd tar` completing to `tar.exe` is never
/// right).
const DIR_ONLY_COMMANDS: &[&str] = &["cd", "pushd", "popd", "rmdir"];
/// The command name the cursor's word is an argument of: the first token of
/// the current simple command (after the last shell separator), reduced to its
/// basename so `/bin/rmdir` matches like `rmdir`. `None` when there is no
/// command token before the word.
fn current_command(chars: &[char], word_start: usize) -> Option<String> {
let prefix: String = chars[..word_start].iter().collect();
let seg_start = prefix
.rfind(['|', '&', ';', '\n', '('])
.map(|i| i + 1)
.unwrap_or(0);
let cmd = prefix[seg_start..].split_whitespace().next()?;
let base = cmd
.rfind(std::path::is_separator)
.map(|i| &cmd[i + 1..])
.unwrap_or(cmd);
(!base.is_empty()).then(|| base.to_string())
}
/// Compute completions for `line` at char position `cursor`, resolving relative
/// paths against `cwd`: command names in command position, filesystem paths
/// elsewhere. Returns `None` when there's nothing to offer.
pub fn complete(line: &str, cursor: usize, cwd: &Path) -> Option<Completion> {
///
/// `cwd` is `None` when the pane has no directory on *this* machine — a remote
/// pane. Command completion still runs; everything that would touch the local
/// filesystem is skipped rather than answered from the wrong machine.
pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option<Completion> {
let chars: Vec<char> = line.chars().collect();
let cursor = cursor.min(chars.len());
@@ -141,9 +169,31 @@ pub fn complete(line: &str, cursor: usize, cwd: &Path) -> Option<Completion> {
// back to filesystem paths. A signature slot that declares suggestions
// or generators owns the position: it returns `Some` (possibly with no
// sync candidates but pending scripts) rather than ceding to paths.
//
// A missing `cwd` means a remote pane, and it disables only the parts
// that read *this* machine: paths and generators (see
// [`complete_signature`]). The rest of a signature is static text —
// `git push`, `--verbose` — and is just as true on the remote, so it is
// still offered. Withholding it too would make every Tab in a remote
// pane a no-match, and a no-match hands the line to the shell, which
// costs the user the inline editor for that prompt.
match complete_signature(&chars, word_start, &word, cwd) {
Some(sig) => (sig.cands, sig.pending),
None => (complete_path(&word, cwd), Vec::new()),
None => match cwd {
// No signature and no local filesystem to fall back on. Offering
// this machine's names would insert them into a remote command
// line where they do not exist; returning nothing instead lets
// the caller hand the Tab to the remote's own completion, which
// can actually see that filesystem.
None => (Vec::new(), Vec::new()),
// No signature: generic paths, narrowed to directories when
// the command only takes those (`cd`, `pushd`, …).
Some(cwd) => {
let dirs_only = current_command(&chars, word_start)
.is_some_and(|c| DIR_ONLY_COMMANDS.contains(&c.as_str()));
(complete_path(&word, cwd, dirs_only), Vec::new())
}
},
}
};
let candidates: Vec<Candidate> = word_cands
@@ -233,7 +283,9 @@ fn sort_candidates_by_closeness(cands: &mut [Candidate]) {
/// Filesystem path completion. Splits `word` into the directory part (kept
/// verbatim in each candidate so the typed path prefix is preserved) and the
/// final-segment prefix to match in that directory. Ordered by closeness.
fn complete_path(word: &str, cwd: &Path) -> Vec<WordCand> {
/// `dirs_only` drops file entries — for commands / argument slots that only
/// accept directories.
fn complete_path(word: &str, cwd: &Path, dirs_only: bool) -> Vec<WordCand> {
// Split on the last path separator. `is_separator` is `/` on Unix and both
// `/` and `\` on Windows, so a `C:\Users\me\f`-style word splits correctly
// under the (future) Windows line editor; separators are ASCII so the byte
@@ -257,7 +309,15 @@ fn complete_path(word: &str, cwd: &Path) -> Vec<WordCand> {
if !name.starts_with(prefix) {
continue;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
// Follow symlinks when classifying: a symlink to a directory must count
// as one (it both takes the trailing `/` and survives a dirs-only
// filter — `cd` into a linked dir is routine).
let is_dir = entry
.file_type()
.is_ok_and(|t| t.is_dir() || (t.is_symlink() && entry.path().is_dir()));
if dirs_only && !is_dir {
continue;
}
let kind = if is_dir {
CandidateKind::Dir
} else {
@@ -297,11 +357,19 @@ struct SigResult {
/// value, suggestion, or generator slot (so a bare argument still lists files).
/// A slot with generators returns `Some` even with zero sync candidates — its
/// results are still inbound, and falling back to paths there is exactly #51.
///
/// `cwd` is `None` for a remote pane, which suppresses the two things that would
/// answer with *this* machine's state: path completion, and generators. The
/// generator exclusion matters more than it looks — a generator is a local
/// `/bin/sh -c` (see [`super::generator`]), so `git checkout <Tab>` against a
/// remote would offer the branches of whatever repo the *local* cwd happens to
/// sit in. Wrong filenames are obvious when they fail; wrong branch names look
/// plausible and land in a real command.
fn complete_signature(
chars: &[char],
word_start: usize,
word: &str,
cwd: &Path,
cwd: Option<&Path>,
) -> Option<SigResult> {
// Only the current simple command matters: start after the last shell
// separator so `foo | git <tab>` completes `git`, not `foo`.
@@ -345,10 +413,15 @@ fn complete_signature(
if let Some(arg) = pending_value {
let mut out = Vec::new();
push_arg_suggestions(&mut out, arg, word);
if arg.wants_paths() {
out.extend(complete_path(word, cwd));
if let Some(cwd) = cwd {
if arg.wants_paths() {
out.extend(complete_path(word, cwd, arg.wants_dirs_only()));
}
}
let pending = collect_generators(arg);
let pending = match cwd {
Some(_) => collect_generators(arg),
None => Vec::new(),
};
// A slot that declares suggestions or generators owns the position even
// when nothing matches yet; only a truly featureless value slot cedes to
// path completion.
@@ -382,10 +455,14 @@ fn complete_signature(
let mut claims_slot = false;
if let Some(arg) = node.args().first() {
push_arg_suggestions(&mut out, arg, word);
if arg.wants_paths() {
out.extend(complete_path(word, cwd));
if let Some(cwd) = cwd {
if arg.wants_paths() {
out.extend(complete_path(word, cwd, arg.wants_dirs_only()));
}
}
if cwd.is_some() {
pending = collect_generators(arg);
}
pending = collect_generators(arg);
// Suggestions/generators mean this positional owns the slot: don't cede
// to paths just because the sync list came back empty.
claims_slot = !arg.suggestions.is_empty() || !pending.is_empty();
@@ -688,7 +765,7 @@ mod tests {
/// The candidate texts `complete` returns for `line` with the cursor at the
/// end, or an empty vec when it offers nothing.
fn texts(line: &str) -> Vec<String> {
complete(line, line.chars().count(), Path::new("/"))
complete(line, line.chars().count(), Some(Path::new("/")))
.map(|c| c.candidates.into_iter().map(|c| c.text).collect())
.unwrap_or_default()
}
@@ -699,7 +776,7 @@ mod tests {
assert!(t.iter().any(|s| s == "commit"), "git subcommands: {t:?}");
assert!(t.iter().any(|s| s == "status"));
// Descriptions ride along for the menu's second column.
let c = complete("git ", 4, Path::new("/")).unwrap();
let c = complete("git ", 4, Some(Path::new("/"))).unwrap();
let commit = c.candidates.iter().find(|c| c.text == "commit").unwrap();
assert_eq!(commit.kind, CandidateKind::Value);
assert!(commit.description.is_some());
@@ -714,7 +791,7 @@ mod tests {
#[test]
fn signature_offers_flags_for_the_active_subcommand() {
let c = complete("git commit --", 13, Path::new("/")).unwrap();
let c = complete("git commit --", 13, Some(Path::new("/"))).unwrap();
let msg = c.candidates.iter().find(|c| c.text == "--message").unwrap();
assert_eq!(msg.kind, CandidateKind::Flag);
assert_eq!(
@@ -741,7 +818,8 @@ mod tests {
// position — it returns the generator scripts and no path candidates.
let dir = temp_tree("gen-checkout", &[("sentinel.txt", false), ("subdir", true)]);
let line = "git checkout ";
let c = complete(line, line.chars().count(), &dir).expect("generator slot is a completion");
let c = complete(line, line.chars().count(), Some(dir.as_path()))
.expect("generator slot is a completion");
assert!(
!c.pending.is_empty(),
"the branch/tag generators ride along as pending scripts"
@@ -767,7 +845,7 @@ mod tests {
fn generator_script_tokens_join_with_single_spaces() {
// The converter word-split original string scripts; joining restores a
// single `/bin/sh -c` command.
let c = complete("git checkout ", 13, Path::new("/")).unwrap();
let c = complete("git checkout ", 13, Some(Path::new("/"))).unwrap();
let branch = c
.pending
.iter()
@@ -821,11 +899,39 @@ mod tests {
assert_eq!(s.selected().unwrap().text, "branch-b");
}
#[test]
fn dir_only_commands_complete_only_directories() {
// `cd tar` must offer `target/`, never `tar.gz` (#136) — same for the
// other dir-only builtins, and for absolute spellings by basename.
let dir = temp_tree("dironly", &[("target", true), ("tar.gz", false)]);
let only_dirs = |line: &str| {
complete(line, line.chars().count(), Some(dir.as_path()))
.map(|c| c.candidates.into_iter().map(|c| c.text).collect::<Vec<_>>())
.unwrap_or_default()
};
assert_eq!(only_dirs("cd tar"), vec!["target"]);
assert_eq!(only_dirs("pushd tar"), vec!["target"]);
assert_eq!(only_dirs("/bin/rmdir tar"), vec!["target"]);
// Only the current simple command counts: `cd` after a pipe governs.
assert_eq!(only_dirs("foo | cd tar"), vec!["target"]);
// A bare argument slot narrows too.
assert_eq!(only_dirs("cd "), vec!["target"]);
// A generic command keeps offering files alongside directories.
let both = only_dirs("frobnicate tar");
assert!(both.contains(&"tar.gz".to_string()), "{both:?}");
assert!(both.contains(&"target".to_string()), "{both:?}");
}
#[test]
fn unknown_command_falls_back_to_paths() {
// A command with no signature still path-completes (no panic, no menu here).
let dir = temp_tree("fallback", &[("readme.md", false)]);
let c = complete("frobnicate read", "frobnicate read".chars().count(), &dir).unwrap();
let c = complete(
"frobnicate read",
"frobnicate read".chars().count(),
Some(dir.as_path()),
)
.unwrap();
assert_eq!(c.candidates[0].text, "readme.md");
}
@@ -925,7 +1031,7 @@ mod tests {
#[test]
fn command_position_offers_builtins_with_word_range() {
let c = complete("ech", 3, Path::new("/")).unwrap();
let c = complete("ech", 3, Some(Path::new("/"))).unwrap();
let echo = c.candidates.iter().find(|c| c.text == "echo").unwrap();
assert_eq!(echo.kind, CandidateKind::Command);
assert_eq!((echo.start, echo.end), (0, 3)); // replaces the word "ech"
@@ -938,7 +1044,7 @@ mod tests {
&[("apple.txt", false), ("apply.sh", false), ("assets", true)],
);
let line = "cat a";
let c = complete(line, line.chars().count(), &dir).unwrap();
let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap();
let names: Vec<&str> = c.candidates.iter().map(|c| c.text.as_str()).collect();
// Closeness order: assets(6) < apply.sh(8) < apple.txt(9).
assert_eq!(names, vec!["assets", "apply.sh", "apple.txt"]);
@@ -952,7 +1058,7 @@ mod tests {
let dir = temp_tree("nested", &[("sub", true)]);
std::fs::write(dir.join("sub/file.rs"), b"").unwrap();
let line = "cat sub/f";
let c = complete(line, line.chars().count(), &dir).unwrap();
let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap();
assert_eq!(c.candidates[0].text, "sub/file.rs");
assert_eq!(c.candidates[0].start, 4);
}
@@ -960,9 +1066,9 @@ mod tests {
#[test]
fn hidden_files_only_with_dot_prefix() {
let dir = temp_tree("hidden", &[(".secret", false), ("visible", false)]);
let c = complete("ls v", 4, &dir).unwrap();
let c = complete("ls v", 4, Some(dir.as_path())).unwrap();
assert!(c.candidates.iter().all(|c| !c.text.starts_with('.')));
let c = complete("ls .", 4, &dir).unwrap();
let c = complete("ls .", 4, Some(dir.as_path())).unwrap();
assert!(c.candidates.iter().any(|c| c.text == ".secret"));
}
@@ -978,18 +1084,87 @@ mod tests {
],
);
let line = "cat x";
let c = complete(line, line.chars().count(), &dir).unwrap();
let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap();
let names: Vec<&str> = c.candidates.iter().map(|c| c.text.as_str()).collect();
assert_eq!(names, vec!["xa", "xy", "xyz", "xyzzy"]);
}
/// A remote pane has no local cwd. Path candidates must come back empty
/// rather than from tty7's own directory — inserting a local filename into
/// a remote command line names a file that isn't there. Command completion
/// is unaffected: it reads `$PATH`, not the cwd.
#[test]
fn a_remote_pane_completes_commands_but_never_local_paths() {
let dir = temp_tree("remote", &[("only-here.txt", false), ("subdir", true)]);
// With a local cwd the file is offered...
let c = complete("cat only", 8, Some(dir.as_path())).expect("local pane completes paths");
assert!(c.candidates.iter().any(|c| c.text.starts_with("only-here")));
// ...and with none it is not, from the same line.
assert!(complete("cat only", 8, None).is_none());
// Nor does a bare argument position dump anything.
assert!(complete("cat ", 4, None).is_none());
// Command position still works — that source never touches the cwd.
let c = complete("ech", 3, None).expect("command completion needs no cwd");
assert!(c.candidates.iter().any(|c| c.text == "echo"));
}
/// The static half of a signature — subcommands, flags — describes the
/// *command*, not the machine, so it survives the loss of a local cwd. This
/// is what keeps Tab useful in a remote pane: a position with no candidates
/// hands the line to the shell (`handoff_tab_to_shell`), which costs the
/// user the inline editor until the next prompt, so answering "nothing" for
/// every `git <Tab>` was a real regression once remote panes gained an
/// editor at all.
#[test]
fn a_remote_pane_still_gets_a_signatures_static_candidates() {
let c = complete("git ", 4, None).expect("subcommands need no filesystem");
assert!(c.candidates.iter().any(|c| c.text == "commit"));
assert!(c.candidates.iter().any(|c| c.text == "push"));
// Prefix filtering works the same as it does locally.
let c = complete("git ch", 6, None).expect("subcommands need no filesystem");
assert!(c.candidates.iter().any(|c| c.text == "checkout"));
assert!(!c.candidates.iter().any(|c| c.text == "commit"));
// Flags too.
let c = complete("git commit --", 13, None).expect("flags need no filesystem");
assert!(c.candidates.iter().any(|c| c.text == "--message"));
}
/// Generators are local `/bin/sh -c` child processes, so against a remote
/// they would answer with this machine's state — `git checkout <Tab>`
/// offering the branches of whatever repo tty7's own cwd sits in. Unlike a
/// wrong filename, a wrong branch name is plausible enough to be accepted.
#[test]
fn a_remote_pane_never_runs_a_generator() {
// Locally this slot is generator-owned (the branch list).
let local = complete("git checkout ", 13, Some(Path::new("/"))).unwrap();
assert!(
!local.pending.is_empty(),
"expected the local branch generator to still be declared"
);
// Remotely the same slot may keep its static candidates, but must not
// schedule a single script.
if let Some(remote) = complete("git checkout ", 13, None) {
assert!(
remote.pending.is_empty(),
"a remote pane scheduled local generators: {:?}",
remote.pending
);
}
}
#[test]
fn no_candidates_returns_none() {
let dir = temp_tree("empty", &[("zzz", false)]);
assert!(complete("cat q", 5, &dir).is_none());
assert!(complete("cat q", 5, Some(dir.as_path())).is_none());
// A blank line offers nothing (no dump of every command on bare Tab).
assert!(complete("", 0, &dir).is_none());
assert!(complete(" ", 3, &dir).is_none());
assert!(complete("", 0, Some(dir.as_path())).is_none());
assert!(complete(" ", 3, Some(dir.as_path())).is_none());
}
#[test]
@@ -997,7 +1172,7 @@ mod tests {
// Caret sits right after "ap" with more text following; the candidate
// replaces only `word_start..cursor`, leaving the tail untouched.
let dir = temp_tree("midline", &[("apple.txt", false)]);
let c = complete("cat ap x.log", 6, &dir).unwrap();
let c = complete("cat ap x.log", 6, Some(dir.as_path())).unwrap();
let apple = c.candidates.iter().find(|c| c.text == "apple.txt").unwrap();
assert_eq!((apple.start, apple.end), (4, 6));
// Applying it splices over just that range.
+1 -1
View File
@@ -1209,7 +1209,7 @@ impl TerminalElement {
return;
}
if button == MouseButton::Left {
v.on_select_start(col, row, left, clicks, cx);
v.on_select_start(col, row, left, clicks, mods.shift, cx);
}
});
});
+200 -32
View File
@@ -20,6 +20,7 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// A repo's git snapshot: the branch it's on and how much the working tree has
/// changed against `HEAD`. `added`/`removed` sum the per-file line counts from
@@ -62,39 +63,46 @@ pub fn probe(cwd: &Path) -> Option<RepoSnapshot> {
if !cwd.exists() {
return None;
}
// Doubles as the "is this a git repo" gate: fails outside a work tree.
let root = git(cwd, &["rev-parse", "--show-toplevel"])?;
let root = PathBuf::from(root.trim_end_matches(['\n', '\r']));
// One `rev-parse` answers every path question at once: the work-tree root
// (which doubles as the "is this a git repo" gate — it fails outside a
// work tree) plus the git-dir/common-dir pair that tells a linked worktree
// from a main checkout. Asking separately cost two process spawns per
// probe, which mattered once probes stopped being rare: they now also fire
// on window activation and on an agent's tool calls, across every pane.
let paths = git(
cwd,
&[
"rev-parse",
"--path-format=absolute",
"--show-toplevel",
"--git-dir",
"--git-common-dir",
],
)?;
let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r']));
let root = PathBuf::from(lines.next()?);
// A git old enough to reject `--path-format` fails the whole invocation
// above, so reaching here means the two dirs are present — but degrade to
// "main checkout" rather than trusting that, same as the old code did.
let home = repo_home(&root, lines.next(), lines.next());
let branch = branch_name(cwd)?;
Some(RepoSnapshot {
home: repo_home(cwd, &root),
home,
root,
branch,
counts: diff_numstat(cwd),
})
}
/// The repository "home" every checkout of one repo shares: for a linked
/// worktree (its git dir differs from the common git dir) the main work
/// tree's root — the parent of `<main>/.git`; for the main checkout itself,
/// a submodule, or any failure to tell, the work-tree root unchanged. A bare
/// common dir (no trailing `.git` component, the bare-repo-plus-worktrees
/// layout) anchors on the bare directory itself — still one shared key.
fn repo_home(cwd: &Path, root: &Path) -> PathBuf {
let both = git(
cwd,
&[
"rev-parse",
"--path-format=absolute",
"--git-dir",
"--git-common-dir",
],
);
let Some(both) = both else {
return root.to_path_buf();
};
let mut lines = both.lines();
let (Some(git_dir), Some(common)) = (lines.next(), lines.next()) else {
/// The repository "home" every checkout of one repo shares, from the work-tree
/// `root` and the `--git-dir` / `--git-common-dir` pair: for a linked worktree
/// (its git dir differs from the common git dir) the main work tree's root —
/// the parent of `<main>/.git`; for the main checkout itself, a submodule, or
/// any failure to tell, the work-tree root unchanged. A bare common dir (no
/// trailing `.git` component, the bare-repo-plus-worktrees layout) anchors on
/// the bare directory itself — still one shared key.
fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf {
let (Some(git_dir), Some(common)) = (git_dir, common_dir) else {
return root.to_path_buf();
};
if git_dir == common {
@@ -133,6 +141,9 @@ pub struct GitStatusCache {
/// In-flight cwds re-triggered meanwhile — reprobed once their flight
/// lands, so the newest trigger's state is never skipped.
dirty: HashSet<PathBuf>,
/// When each cwd's last probe *landed*, for the throttle that opportunistic
/// triggers go through ([`begin_probe_throttled`](Self::begin_probe_throttled)).
last_probe: HashMap<PathBuf, Instant>,
}
impl gpui::Global for GitStatusCache {}
@@ -177,12 +188,75 @@ impl GitStatusCache {
}
}
/// Claim an *opportunistic* probe for `cwd`: one triggered by a cheap,
/// frequent signal — the window regaining focus, an agent finishing a tool
/// call — rather than by a rare edge like a command ending.
///
/// Unlike [`begin_probe`](Self::begin_probe) this declines instead of
/// queueing: a probe already in flight, or one against a repo probed less
/// than `min_interval` ago, drops the trigger entirely (no dirty mark, no
/// rerun). That's the whole point of the two entry points — the rare edges
/// must never be missed, while these signals repeat on their own, so a
/// count that's a second stale beats a `git` storm across every pane of a
/// repo the moment the user alt-tabs back.
///
/// The throttle counts per *repo*, not per cwd (see
/// [`throttle_key`](Self::throttle_key)), and the claim stamps the clock
/// rather than waiting for the landing: without that, a dozen panes
/// scattered over one repo's subdirectories would all claim in the same
/// instant — each of them passing a throttle no probe had answered yet —
/// and produce a dozen identical full-repo diffs.
pub fn begin_probe_throttled(&mut self, cwd: &Path, min_interval: Duration) -> bool {
if self.in_flight.contains(cwd) {
return false;
}
let key = self.throttle_key(cwd).to_path_buf();
if self
.last_probe
.get(&key)
.is_some_and(|at| at.elapsed() < min_interval)
{
return false;
}
self.last_probe.insert(key, Instant::now());
self.in_flight.insert(cwd.to_path_buf());
true
}
/// What the opportunistic throttle counts against: the work-tree root once
/// some probe has answered for `cwd`, and `cwd` itself before that.
///
/// The counts a probe produces are repo-wide — `git diff --numstat HEAD`
/// ignores which subdirectory it ran in — so panes at `repo/`, `repo/src`
/// and `repo/docs` are three ways of asking one question, and want one
/// shared clock rather than one each. In-flight dedup stays keyed by cwd:
/// it brackets a specific spawn, and [`finish_probe`](Self::finish_probe)
/// has to be able to release exactly what was claimed.
///
/// Before any probe has landed the root is simply unknown, so the first
/// sweep over a repo still costs one probe per distinct cwd; every sweep
/// after that collapses to one.
fn throttle_key<'a>(&'a self, cwd: &'a Path) -> &'a Path {
match self.roots.get(cwd) {
Some(Some(root)) => root,
_ => cwd,
}
}
/// Fold a landed probe for `cwd` into the cache. A failed diff inside a
/// live repo keeps the root's previous counts (a transient `git` error is
/// not "the tree went clean"). Returns whether the cwd was re-triggered
/// while this probe flew — the caller should start one more probe.
pub fn finish_probe(&mut self, cwd: &Path, snapshot: Option<RepoSnapshot>) -> bool {
self.in_flight.remove(cwd);
// Re-stamp on landing so the gap is measured from fresh counts, and
// under the root this probe just resolved — which is how a cwd first
// learns to share its repo's clock (at claim time it had none).
let key = match &snapshot {
Some(snap) => snap.root.clone(),
None => self.throttle_key(cwd).to_path_buf(),
};
self.last_probe.insert(key, Instant::now());
match snapshot {
Some(snap) => {
let (added, removed) = snap.counts.unwrap_or_else(|| {
@@ -251,18 +325,19 @@ fn diff_numstat(cwd: &Path) -> Option<(u32, u32)> {
/// Run `git -C <cwd> <args>` and return stdout on success, `None` on a
/// non-zero exit or a missing `git`. `GIT_OPTIONAL_LOCKS=0` makes the read
/// truly read-only; stdin is nulled so a misconfigured git can't block on a
/// prompt. Shared with [`git_diff`](crate::terminal::git_diff) so every git
/// read in the app goes through the same lock-free, prompt-proof invocation.
/// prompt; `hide_console` keeps this GUI process from flashing a console window
/// on Windows for every probe. Shared with [`git_diff`](crate::terminal::git_diff)
/// so every git read in the app goes through the same lock-free, prompt-proof
/// invocation.
pub(crate) fn git(cwd: &Path, args: &[&str]) -> Option<String> {
let out = Command::new("git")
.arg("-C")
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(cwd)
.args(args)
.env("GIT_OPTIONAL_LOCKS", "0")
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
.stderr(Stdio::null());
let out = crate::core::proc::hide_console(&mut cmd).output().ok()?;
if !out.status.success() {
return None;
}
@@ -421,4 +496,97 @@ mod tests {
assert_eq!(cache.status_for(main).unwrap().branch, "main");
assert_eq!(cache.status_for(wt).unwrap().branch, "feat/x");
}
/// The four shapes `repo_home` has to tell apart, straight from the
/// `--git-dir` / `--git-common-dir` pair the merged `rev-parse` returns.
#[test]
fn repo_home_resolves_worktree_layouts() {
let root = Path::new("/repo/.wt/feat");
// A main checkout: the two dirs agree, so the work tree is its own home.
assert_eq!(
repo_home(Path::new("/repo"), Some("/repo/.git"), Some("/repo/.git")),
PathBuf::from("/repo")
);
// A linked worktree: the common dir is the main checkout's `.git`, so
// the home is that `.git`'s parent — the main work tree.
assert_eq!(
repo_home(root, Some("/repo/.git/worktrees/feat"), Some("/repo/.git")),
PathBuf::from("/repo")
);
// A bare repo with worktrees hanging off it: no `.git` component to
// strip, so the bare dir itself is the shared key.
assert_eq!(
repo_home(root, Some("/bare.git/worktrees/feat"), Some("/bare.git")),
PathBuf::from("/bare.git")
);
// A git too old (or too odd) to answer both: degrade to the work tree
// rather than guessing a grouping key.
assert_eq!(
repo_home(root, Some("/repo/.git"), None),
root.to_path_buf()
);
assert_eq!(repo_home(root, None, None), root.to_path_buf());
}
/// The opportunistic path declines where the edge path queues: an in-flight
/// probe drops the trigger (and leaves nothing dirty, so no rerun), and a
/// probe that just landed rate-limits the next one.
#[test]
fn throttled_probes_decline_instead_of_queueing() {
let mut cache = GitStatusCache::default();
let cwd = Path::new("/repo");
let gap = Duration::from_secs(60);
assert!(cache.begin_probe_throttled(cwd, gap));
// In flight: declined, and unlike `begin_probe` it doesn't mark dirty —
// the landing reports "nothing pending" rather than asking for a rerun.
assert!(!cache.begin_probe_throttled(cwd, gap));
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0))))));
// Landed just now: still inside the gap, so the next trigger is dropped.
assert!(!cache.begin_probe_throttled(cwd, gap));
// …but a zero gap always lets one through, and edge triggers never
// consult the throttle at all.
assert!(cache.begin_probe_throttled(cwd, Duration::ZERO));
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0))))));
assert!(cache.begin_probe(cwd));
}
/// The throttle is per repo, not per cwd: panes sitting in different
/// subdirectories ask one question (the counts are repo-wide), so once the
/// cache knows where they live, a window activation costs one probe for
/// the repo rather than one per pane.
#[test]
fn throttle_collapses_subdirectories_of_one_repo() {
let mut cache = GitStatusCache::default();
let (top, src, docs) = (
Path::new("/repo"),
Path::new("/repo/src"),
Path::new("/repo/docs"),
);
let gap = Duration::from_secs(60);
// Nothing known yet, so each cwd is its own key and each gets a probe.
for cwd in [top, src, docs] {
assert!(cache.begin_probe_throttled(cwd, gap));
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((3, 1))))));
}
// Now all three resolve to `/repo`, so the next sweep collapses: the
// first pane to ask spends the probe and the rest ride on it.
assert!(!cache.begin_probe_throttled(top, gap));
assert!(!cache.begin_probe_throttled(src, gap));
// …and the claim itself is what stops the stampede — with the clock
// wound back far enough to let one through, the *others* still decline
// while it is in flight, even though nothing has landed yet.
assert!(cache.begin_probe_throttled(docs, Duration::ZERO));
assert!(!cache.begin_probe_throttled(top, gap));
assert!(!cache.begin_probe_throttled(src, gap));
// A pane elsewhere is untouched by any of it.
let other = Path::new("/other");
assert!(cache.begin_probe_throttled(other, gap));
}
}
+153 -2
View File
@@ -105,6 +105,51 @@ pub(super) fn reshape_option_keystroke(
}
}
/// True when a keystroke is ordinary text that macOS should deliver through the
/// input context (`insertText:` → `replace_text_in_range` → `commit_text`)
/// rather than the raw `key_char` path.
///
/// gpui derives `key_char` by running the event's *virtual keycode* back through
/// the current layout (`chars_for_modified_key` in its macOS backend); it never
/// reads the event's Unicode payload. That is fine for a physical keyboard, where
/// the keycode is the truth, but wrong for any event whose text lives only in the
/// payload — notably remote-control apps, which synthesize keystrokes as
/// `CGEventCreateKeyboardEvent(src, 0, …)` + `CGEventKeyboardSetUnicodeString()`.
/// Keycode 0 is `a`, so every remotely typed character arrived as `a`.
///
/// gpui already diverts printable keys to the input context, but only while a
/// composing input source is active (`is_ime_input_source_active`), so the bug
/// appeared and vanished depending on which input method was selected — and the
/// plain ABC layout, the macOS default, always lost the text. Declining the key
/// here instead makes the IME the single delivery path for text on macOS: gpui
/// falls through to `handleEvent:`, and the Unicode payload survives.
///
/// Chords are deliberately excluded: Ctrl/Cmd/Fn belong to the encoders below,
/// and Option is owned by [`reshape_option_keystroke`]'s Meta policy.
///
/// REPORT_ALL_KEYS_AS_ESC is excluded too: it asks for every key as
/// `CSI <code>;<mods>[;<text>]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 <code>;<mods>[;<text>]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<Vec<u8>> {
@@ -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 {
+1
View File
@@ -35,6 +35,7 @@ mod reverse_search;
pub mod search;
mod signature;
mod size;
mod smart_select;
mod typeahead;
pub mod view;
+32
View File
@@ -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<i32> {
@@ -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();
+7
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+672 -71
View File
File diff suppressed because it is too large Load Diff
+363 -52
View File
@@ -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<RefCell<Option<crate::ui::reorder::Reorder>>>,
/// 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<InputState>,
@@ -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::<Config>().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<SessionTab> = 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<Self>) {
cx.global_mut::<Config>().theme_preset = id.to_string();
let dark_now = crate::ui::theme::system_dark(cx);
let cfg = cx.global_mut::<Config>();
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<Self>,
) {
let cfg = cx.global_mut::<Config>();
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<Self>,
) {
if on {
let manual = cx.global::<Config>().theme_preset.clone();
let manual_dark = crate::ui::presets::by_id(cx, &manual).dark;
let cfg = cx.global_mut::<Config>();
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::<Config>();
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<Self>) {
apply_theme(Some(window), cx);
set_menus(cx);
cx.global::<Config>().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<Self>) {
/// 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<Self>,
) {
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<Self>) {
let id = cx.global::<Config>().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<Self>,
) {
let id = cx.global::<Config>().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::<Config>();
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::<Config>().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>) {
self.update_config(cx, |cfg| cfg.smart_select = on);
}
pub(crate) fn set_tab_completion(&mut self, on: bool, cx: &mut Context<Self>) {
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<Self>) {
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<Self>,
) {
// 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<std::path::PathBuf> = 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<Self>) {
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<std::path::PathBuf> {
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<Self>,
) {
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<Self>) {
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<Self>) {
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<Option<Tab>> = 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<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<InputState> {
let value = cx
.global::<Config>()
.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<Self>) {
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::<Config>();
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<Self>) -> 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::<Config>().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);
}
+1
View File
@@ -187,6 +187,7 @@ mod tests {
ssh_spec: None,
agent: None,
agent_session_id: None,
agent_launch_argv: None,
}
}
+1
View File
@@ -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;
+1 -2
View File
@@ -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<Command> {
let active = cx.global::<Config>().theme_preset.clone();
let active = crate::ui::theme::effective_preset_id(cx);
crate::ui::presets::all(cx)
.into_iter()
.enumerate()
+560
View File
@@ -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<RefCell<Option<Reorder>>>;
/// 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<usize>,
/// 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<Pixels>,
/// 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<Pixels>,
) -> Option<Preview> {
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<usize>) {
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<Vec<usize>> {
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<PathBuf>),
/// 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<Bounds<Pixels>>,
/// 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<Pixels>,
/// 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<usize>,
generation: Cell<usize>,
/// The tab order releasing right now would produce, refreshed every frame
/// by the surface drawing the preview. See [`set_pending`].
pending: RefCell<Option<Vec<usize>>>,
}
impl Reorder {
pub(crate) fn new(
surface: Surface,
from: usize,
rects: Vec<Bounds<Pixels>>,
axis: Axis,
gap: Pixels,
grab: Point<Pixels>,
) -> 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>) -> 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>) -> 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<Pixels>) -> 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>) -> 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>) -> 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<Pixels>, 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<usize> {
let mut order: Vec<usize> = (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]);
}
}
+196 -35
View File
@@ -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<InputState>,
/// Custom working-directory path (used when the strategy is `Custom`).
pub(crate) wd_path_input: Entity<InputState>,
/// 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<InputState>,
/// Mouse-scroll multiplier slider (Terminal section).
pub(crate) scroll_slider: Entity<SliderState>,
/// Global window-opacity slider (Appearance's Window section). Shows the
/// effective value; dragging sets the config override.
pub(crate) window_opacity_slider: Entity<SliderState>,
/// 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<ThemeEditor>,
/// 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<InputState>,
/// `Some` while a Keybindings row is capturing a new shortcut: the action
@@ -403,6 +414,16 @@ pub(crate) struct SettingsState {
pub(crate) _subs: Vec<Subscription>,
}
/// 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<bool>,
warn_on_close: Option<bool>,
@@ -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::<Config>();
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::<Config>().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::<Config>().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<Self>) -> 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<Self>) -> AnyElement {
let follow = cx.global::<Config>().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<Self>) -> 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::<Config>().theme_preset.clone();
let config = cx.global::<Config>();
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::<Config>().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::<Config>();
// 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),
})),
);
}
+1
View File
@@ -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()),
+690 -166
View File
@@ -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<Self>) -> 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<Vec<Option<PathBuf>>> = 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<AnyElement> = 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<usize> = {
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<Vec<(usize, String)>> = 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<usize> = (0..sections.len())
.filter(|&ix| sections[ix].key.is_some())
.filter(rendered)
.collect();
let repo_groups = repo_slots.len();
let group_slots: Rc<RefCell<Vec<Bounds<Pixels>>>> =
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<PathBuf> = repo_slots
.iter()
.filter_map(|&ix| sections[ix].key.clone())
.collect();
let slot_display: Vec<usize> = 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>, 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 = &sections[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<ContextMenu<Stateful<Div>>> = Vec::new();
let visible = visible_by_section[group_ix].clone();
let visible_tabs: Vec<usize> = visible.iter().map(|(i, _)| *i).collect();
let row_slots: Rc<RefCell<Vec<Bounds<Pixels>>>> =
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::<DragTab>(|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<usize> = 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<Option<ContextMenu<Stateful<Div>>>> =
rows.into_iter().map(Some).collect();
let rows: Vec<AnyElement> = 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<PathBuf>]) -> Vec<(Option<String>, Vec<usize>)> {
/// 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<PathBuf>,
/// The header text, or `None` for "render flat, no header".
name: Option<String>,
/// The group's tabs, in tab order.
tabs: Vec<usize>,
}
/// 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<PathBuf>]) -> Vec<Section> {
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<PathBuf>]) -> Vec<(Option<String>, Vec<usize>
}
}
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<String>, Vec<usize>)> = group_order
let mut sections: Vec<Section> = 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<usize> = (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<PathBuf>],
group: &Option<PathBuf>,
visible: &[usize],
from: usize,
to: usize,
) -> Option<Vec<usize>> {
let (&moved, &anchor) = (visible.get(from)?, visible.get(to)?);
if moved == anchor {
return None;
}
let mut members: Vec<usize> = (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<usize> = 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<PathBuf>], from: &Path, to: &Path) -> Option<Vec<usize>> {
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<usize> = 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<PathBuf>, Option<String>, Vec<usize>)> = 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
+253 -112
View File
@@ -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<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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<RefCell<Vec<Bounds<Pixels>>>> =
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<usize> = 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::<DragTab>(|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 —
+61 -21
View File
@@ -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::<Config>();
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::<Config>();
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::<Config>().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::<Config>();
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<bool>) {
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<bool>) {}
+39 -16
View File
@@ -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 1632 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 1632 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<RgbaImage> {
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<RgbaImage> {
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();
+12 -6
View File
@@ -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"
+43 -14
View File
@@ -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));
}
}
}
}