From 0df604054d332f9317c959eefc7d972fff3f0d03 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Sun, 16 Aug 2026 17:35:39 +0800 Subject: [PATCH 01/33] fix(daemon): keep a lingering daemon findable and reapable after quit-and-stop (#655) * fix(daemon): keep a lingering daemon findable and reapable after quit-and-stop Quit-and-stop could strand a daemon that had already unlinked daemon.sock and deleted daemon.pid but never finished exiting: libc exit() runs atexit handlers and static destructors beside dozens of live threads, and a finalizer that blocks leaves the process holding the singleton lock with no name on disk. Every later launch then spawns a daemon that stands down against the lock and times out red, forever. Three changes, each a fallback for the others: - on_shutdown keeps the pidfile: once the endpoint is unlinked it is the only handle anything has on a process that is not gone yet. A pidfile that outlives a clean exit was already handled by recorded_daemon_is_dead and the reap path. - The daemon exits through _exit(2) (after flushing the logger), skipping the atexit/destructor window entirely; everything owed to disk is flushed explicitly in on_shutdown. - spawn::stop reaps with the pid it captured before asking the daemon to die, instead of re-reading a pidfile an old build's shutdown may have wiped mid-stop; reap_recorded_daemon keeps the pidfile when the process survives even SIGKILL, so the next attempt still has someone to reap. * review: fix stale stop() comment, pin the mid-stop pidfile-vanish ordering in the test The comment at the top of stop() still claimed a clean shutdown removes the pidfile, which this branch just made untrue; it now states the real reasons the pid is captured early. The vanishing-pidfile test now asserts the sweeper's delete actually landed while stop() was waiting, so a future shrink of PROCESS_EXIT_TIMEOUT cannot silently turn it into a weaker scenario. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- Cargo.lock | 1 + crates/tty7-core/src/daemon/server.rs | 31 ++++- crates/tty7-core/src/daemon/spawn.rs | 54 +++++--- crates/tty7-server/Cargo.toml | 4 + crates/tty7-server/tests/daemon_stop.rs | 165 ++++++++++++++++++++++++ 5 files changed, 236 insertions(+), 19 deletions(-) create mode 100644 crates/tty7-server/tests/daemon_stop.rs diff --git a/Cargo.lock b/Cargo.lock index facf376a..238e3015 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9862,6 +9862,7 @@ dependencies = [ name = "tty7-server" version = "26.8.3" dependencies = [ + "libc", "tempfile", "tty7-core", ] diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index d14de0cb..0f50f028 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -633,7 +633,7 @@ fn serve_sigterm(registry: Arc) { store_scrollback_now(®istry); registry.drain_and_kill(); on_shutdown(); - std::process::exit(0); + exit_now(); } }) .ok(); @@ -646,7 +646,32 @@ fn on_shutdown() { transport::remove_stale_endpoint(); #[cfg(windows)] crate::host::server::remove_control_endpoint(); - crate::daemon::pidfile::remove(); + // The pidfile stays. Once the endpoint above is unlinked it is the only + // name anything has for this process, and the process is not gone yet — + // a stalled exit after this point used to leave a daemon holding the + // singleton lock with no pidfile, which `spawn::stop` and + // `ensure_running` could then never find or reap: every later launch + // stood down against a server serving nobody (#653). A pidfile that + // outlives a clean exit is already handled — `recorded_daemon_is_dead` + // reads a dead pid as stale, and `reap_recorded_daemon` deletes the file + // once the process is confirmed gone. +} + +/// Ends the daemon immediately, skipping libc `exit`'s atexit handlers and +/// static destructors. Those run while this process's other threads — the +/// accept loop, the control listeners — are still live, and a finalizer that +/// blocks on anything one of them holds never returns: the daemon then +/// lingers with its endpoint already unlinked but the singleton lock still +/// held (#653). Everything that must reach disk is flushed explicitly in +/// `on_shutdown`, so nothing is owed to the handlers being skipped. +fn exit_now() -> ! { + log::logger().flush(); + #[cfg(unix)] + unsafe { + libc::_exit(0) + } + #[cfg(not(unix))] + std::process::exit(0) } fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { @@ -804,7 +829,7 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { std::time::Duration::from_secs(3), ); on_shutdown(); - std::process::exit(0); + exit_now(); } ClientMsg::Kill { pane_id } => { diff --git a/crates/tty7-core/src/daemon/spawn.rs b/crates/tty7-core/src/daemon/spawn.rs index 9692066a..f656349c 100644 --- a/crates/tty7-core/src/daemon/spawn.rs +++ b/crates/tty7-core/src/daemon/spawn.rs @@ -273,7 +273,7 @@ pub fn ensure_running() -> anyhow::Result<()> { } if stale { - reap_recorded_daemon(); + reap_recorded_daemon(None); if transport::endpoint_exists() { transport::remove_stale_endpoint(); @@ -480,10 +480,12 @@ pub fn hand_off() -> anyhow::Result<()> { pub fn stop() { use std::io::Write as _; - // Read the pid before asking the daemon to die: a clean shutdown removes - // the pidfile, and the endpoint disappearing is not the same event as the - // process releasing its image — the gap between them is exactly where an - // installer starts replacing files that are still locked. + // Read the pid before asking the daemon to die: the endpoint disappearing + // is not the same event as the process releasing its image — the gap + // between them is exactly where an installer starts replacing files that + // are still locked — and an old build's shutdown still deletes the pidfile + // before the process is gone, so this is the last moment the pid is + // guaranteed readable. let recorded = pidfile::read().filter(|&pid| pid > 4 && pid != std::process::id()); if let Ok(mut stream) = transport::connect() { @@ -502,7 +504,13 @@ pub fn stop() { log::warn!("daemon pid {pid} released its endpoint but has not exited yet"); } - reap_recorded_daemon(); + // With the pid read at the top, not from the pidfile: a shutdown that got + // as far as its cleanup deleted nothing we rely on here, but a build that + // still removes the pidfile mid-shutdown — or one whose exit stalled after + // the cleanup — would otherwise leave the reap with no pid to act on, and + // the survivor holding the singleton lock against every later launch + // (#653). + reap_recorded_daemon(recorded); if transport::endpoint_exists() { transport::remove_stale_endpoint(); @@ -534,16 +542,25 @@ fn wait_for_recorded_exit(_pid: u32, _timeout: Duration) -> bool { true } +/// `recorded` is a pid the caller captured before asking the daemon to die; +/// the pidfile is only the fallback, because a shutdown that stalled after its +/// cleanup may have already deleted it. #[cfg(any(target_os = "macos", target_os = "linux"))] -fn reap_recorded_daemon() { - let Some(pid) = pidfile::read() else { return }; +fn reap_recorded_daemon(recorded: Option) { + let Some(pid) = recorded.or_else(pidfile::read) else { + return; + }; if pid <= 1 || pid == std::process::id() { pidfile::remove(); return; } if process_matches_daemon_exe(pid as libc::pid_t) { log::warn!("reaping unreachable daemon (pid {pid}); its sessions will be hung up"); - reap_process(pid as libc::pid_t); + if !reap_process(pid as libc::pid_t) { + // The pid is the only handle left on the survivor; keep the file + // so the next attempt still has someone to reap. + return; + } } pidfile::remove(); } @@ -555,14 +572,17 @@ fn process_matches_daemon_exe(pid: libc::pid_t) -> bool { .is_some_and(|name| is_reapable_daemon_name(&name)) } +/// Whether the process is gone by the end. #[cfg(any(target_os = "macos", target_os = "linux"))] -fn reap_process(pid: libc::pid_t) { +fn reap_process(pid: libc::pid_t) -> bool { if signal_and_await_exit(pid, libc::SIGTERM, REAP_TERM_TIMEOUT) { - return; + return true; } - if !signal_and_await_exit(pid, libc::SIGKILL, REAP_KILL_TIMEOUT) { - log::error!("daemon pid {pid} survived SIGKILL; leaving it behind"); + if signal_and_await_exit(pid, libc::SIGKILL, REAP_KILL_TIMEOUT) { + return true; } + log::error!("daemon pid {pid} survived SIGKILL; leaving it behind"); + false } #[cfg(any(target_os = "macos", target_os = "linux"))] @@ -577,10 +597,12 @@ fn process_alive(pid: libc::pid_t) -> bool { } #[cfg(windows)] -fn reap_recorded_daemon() { +fn reap_recorded_daemon(recorded: Option) { use crate::daemon::winproc; - let Some(pid) = pidfile::read() else { return }; + let Some(pid) = recorded.or_else(pidfile::read) else { + return; + }; if pid <= 4 || pid == std::process::id() { pidfile::remove(); return; @@ -691,7 +713,7 @@ fn wait_until_images_unlocked(dir: &Path, deadline: Instant) -> Result<(), Strin } #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] -fn reap_recorded_daemon() {} +fn reap_recorded_daemon(_recorded: Option) {} fn spawn_detached() -> anyhow::Result<()> { let exe = std::env::current_exe() diff --git a/crates/tty7-server/Cargo.toml b/crates/tty7-server/Cargo.toml index d675dd0a..22625569 100644 --- a/crates/tty7-server/Cargo.toml +++ b/crates/tty7-server/Cargo.toml @@ -26,5 +26,9 @@ tty7-core = { path = "../tty7-core" } # Sandboxes for the suite: an empty directory per case, removed on drop. The # server is on this machine, so a local temp dir is a path in its namespace. tempfile = "3" +# `tests/daemon_stop.rs` probes and, on failure, kills the daemon it spawned; +# a pid needs `kill(2)`, which `Child` cannot express once ownership has moved +# to the thread collecting the exit. +libc = "0.2" [lints] workspace = true diff --git a/crates/tty7-server/tests/daemon_stop.rs b/crates/tty7-server/tests/daemon_stop.rs new file mode 100644 index 00000000..670c8bfd --- /dev/null +++ b/crates/tty7-server/tests/daemon_stop.rs @@ -0,0 +1,165 @@ +//! Guards for #653: a daemon that lingers after unlinking its endpoint must +//! stay findable and reapable, or its singleton lock makes every later launch +//! stand down and time out red with no way back short of `pkill`. +//! +//! Unix-only: both tests drive the daemon through unix sockets and signals. + +#![cfg(unix)] + +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use tty7_core::client::PaneClient; +use tty7_core::daemon::protocol::ClientMsg; + +const READY_WITHIN: Duration = Duration::from_secs(30); +const EXIT_WITHIN: Duration = Duration::from_secs(10); + +fn spawn_daemon(dir: &Path) -> Child { + Command::new(env!("CARGO_BIN_EXE_tty7-server")) + .arg("--daemon") + .arg("--config-dir") + .arg(dir) + .env("TTY7_DATA_DIR", dir) + .env("TTY7_CONTROL_SOCK", dir.join("control.sock")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("start tty7-server --daemon") +} + +fn await_ready(dir: &Path) { + let endpoint = dir.join("daemon.sock"); + let deadline = Instant::now() + READY_WITHIN; + while PaneClient::at(&endpoint).version().is_err() || !dir.join("daemon.pid").exists() { + assert!( + Instant::now() < deadline, + "tty7-server did not open its endpoint within {READY_WITHIN:?}" + ); + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn send_shutdown(endpoint: &Path) { + use std::io::Write as _; + let mut stream = + std::os::unix::net::UnixStream::connect(endpoint).expect("connect to the daemon"); + ClientMsg::Shutdown + .encode(&mut stream) + .expect("send Shutdown"); + stream.flush().expect("flush Shutdown"); +} + +fn await_exit(child: &mut Child) -> std::process::ExitStatus { + let deadline = Instant::now() + EXIT_WITHIN; + loop { + if let Some(status) = child.try_wait().expect("query the daemon's state") { + return status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("the daemon did not exit within {EXIT_WITHIN:?} of Shutdown"); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// The pidfile must outlive the daemon's own cleanup: once `daemon.sock` is +/// unlinked it is the only name anything has for the process, and an exit +/// that stalls after the cleanup (#653) is findable through it — deleting it +/// there is what turned a lingering process into a permanent lockout. +#[test] +fn a_clean_shutdown_keeps_the_pidfile_until_the_process_is_gone() { + let dir = tempfile::TempDir::new().unwrap(); + let mut child = spawn_daemon(dir.path()); + await_ready(dir.path()); + let pid = child.id().to_string(); + assert_eq!( + std::fs::read_to_string(dir.path().join("daemon.pid")) + .unwrap() + .trim(), + pid, + "the pidfile names the running daemon" + ); + + send_shutdown(&dir.path().join("daemon.sock")); + let status = await_exit(&mut child); + + assert!(status.success(), "clean shutdown exits cleanly: {status:?}"); + assert!( + !dir.path().join("daemon.sock").exists(), + "shutdown unlinks the endpoint" + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("daemon.pid")) + .unwrap() + .trim(), + pid, + "the pidfile survives the daemon's cleanup; the reap in spawn::stop \ + and ensure_running deletes it once the process is confirmed gone" + ); +} + +/// `spawn::stop` must reap a daemon that stopped listening but never exited, +/// even when the pidfile vanishes under it mid-stop — the ordering an old +/// build's shutdown produces when it wipes its files and then stalls (#653). +/// The pid captured at the top of `stop` is what the reap has to act on. +#[test] +fn stop_reaps_a_lingering_daemon_even_after_the_pidfile_vanishes() { + let dir = tempfile::TempDir::new().unwrap(); + tty7_core::core::config::set_config_dir(dir.path().to_path_buf()); + let child = spawn_daemon(dir.path()); + let pid = child.id(); + await_ready(dir.path()); + + // Collect the child the moment it dies: outside tests the daemon is + // nobody's child, and a zombie would read as alive to the reap's + // liveness poll. + let waiter = std::thread::spawn(move || { + let mut child = child; + child.wait() + }); + + // The #653 state, as stop() meets it: the endpoint is gone before stop() + // can ask for a shutdown, and the pidfile disappears while stop() is + // still waiting on the process. The delete must land inside stop()'s + // wait on the still-alive process (PROCESS_EXIT_TIMEOUT in spawn.rs), + // which the elapsed assertion below pins. + const SWEEP_DELAY: Duration = Duration::from_secs(1); + std::fs::remove_file(dir.path().join("daemon.sock")).unwrap(); + let pidfile = dir.path().join("daemon.pid"); + let sweeper = std::thread::spawn(move || { + std::thread::sleep(SWEEP_DELAY); + std::fs::remove_file(pidfile).is_ok() + }); + + let stop_started = Instant::now(); + tty7_core::daemon::spawn::stop(); + + assert!( + stop_started.elapsed() >= SWEEP_DELAY, + "stop() returned before the sweeper's delete — the mid-stop pidfile \ + removal this test exists to exercise never happened; lower SWEEP_DELAY \ + below spawn.rs's PROCESS_EXIT_TIMEOUT" + ); + assert!( + sweeper.join().unwrap(), + "the sweeper found no pidfile to delete — stop() removed it early, so \ + the vanishing-pidfile ordering was not exercised" + ); + let deadline = Instant::now() + Duration::from_secs(2); + while unsafe { libc::kill(pid as libc::pid_t, 0) } == 0 { + if Instant::now() >= deadline { + unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) }; + panic!("stop() left the daemon (pid {pid}) holding the singleton lock"); + } + std::thread::sleep(Duration::from_millis(50)); + } + waiter + .join() + .unwrap() + .expect("collect the reaped daemon's exit"); +} From ac3c95a64797db35c432d9bcdf6416b0a2140dd1 Mon Sep 17 00:00:00 2001 From: webdev <86946125+biztex@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:43:52 +0700 Subject: [PATCH 02/33] feat(update): install verified Linux AppImage releases in app (#306) (#652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last platform from #306: a Linux install running as an AppImage can now download, verify, and apply a release from inside the app, through the same tty7-updater helper the macOS (#309) and Windows (#330) paths use. Tarball and distro installs are deliberately untouched — they keep the named-package hint and the release page, because replacing a file a package manager may own is not this code's call to make. The installed artifact is one file, the path $APPIMAGE names, so the install is the simplest of the three platforms: stage the download beside the image (two renames only stay atomic on one filesystem), verify, swap, relaunch, and restore the preserved previous image if the new one does not survive its launch grace. What is Linux-shaped about it is the mount: the image the GUI runs from is FUSE-mounted by the AppImage runtime and torn down when the app exits, which is the moment the installer starts working — so the GUI copies the helper out of the mount into staging and runs the copy, the way the Windows path runs a private copy because Setup replaces the installed one. The daemon is left running throughout, as on macOS: nothing on Linux locks a running executable's file, and the panes it serves are the reason the update restarts only the GUI. The swap also carries the installed image's own mode onto its replacement, so a 0700 image stays private and the download's missing execute bit never reaches the installation. Verification holds the issue's requirements with what an unsigned ELF can offer: the bytes must match the release's checksums.txt, the file must actually be a type-2 AppImage — a mis-published asset fails with a name instead of at launch — and the image must state the version it claims. That statement is new: bundle-appimage.sh stamps X-AppImage-Version into the desktop entry, and the updater reads it back with one --appimage-extract, answered by the runtime before any application code and without FUSE. The same pass requires the new image to bundle its own tty7-updater, because an image without one would install fine and then be the last version that ever could. release.yml and nightly.yml now build the updater on the Linux leg and bundle it into the AppImage, and both check the packaged image for the same facts the updater checks on a user's machine — helper present, version stamped — so a packaging mistake fails the workflow instead of the update. The first release carrying this can only bootstrap: images already installed predate the helper and keep the manual hint, so the first complete in-app update is the release after it. --- .github/scripts/bundle-appimage.sh | 12 + .github/workflows/ci.yml | 1 - .github/workflows/nightly.yml | 28 +- .github/workflows/release.yml | 28 +- src/bin/tty7-updater.rs | 703 ++++++++++++++++++++++++++++- src/core/update.rs | 227 +++++++++- 6 files changed, 969 insertions(+), 30 deletions(-) diff --git a/.github/scripts/bundle-appimage.sh b/.github/scripts/bundle-appimage.sh index 03234afe..d5d25d72 100755 --- a/.github/scripts/bundle-appimage.sh +++ b/.github/scripts/bundle-appimage.sh @@ -62,6 +62,12 @@ chmod +x "$APPDIR/usr/bin/tty7-app" cp "target/${TARGET}/release/tty7" "$APPDIR/usr/bin/tty7" chmod +x "$APPDIR/usr/bin/tty7" +# The in-app updater, beside the GUI the way every platform ships it. The GUI +# copies it out of the mount into its staging directory before use — the mount +# is gone by the time an install runs (see src/bin/tty7-updater.rs). +cp "target/${TARGET}/release/tty7-updater" "$APPDIR/usr/bin/tty7-updater" +chmod +x "$APPDIR/usr/bin/tty7-updater" + # A desktop entry + icon are mandatory AppImage metadata; linuxdeploy places # them and generates AppRun. Icon basename must match the desktop's Icon= key. cat > "$TOOLS/tty7.desktop" <<'DESKTOP' @@ -75,6 +81,12 @@ Categories=System;TerminalEmulator; Terminal=false StartupWMClass=tty7 DESKTOP +# The release version, stamped where the in-app updater can read it back with +# one `--appimage-extract` and no mount: a downloaded image must state the +# version it claims before it may replace the installed one (`verify_update` +# in src/bin/tty7-updater.rs). X-AppImage-Version is the AppImage convention +# for exactly this. Appended outside the heredoc, which is quoted on purpose. +echo "X-AppImage-Version=${VERSION}" >> "$TOOLS/tty7.desktop" # linuxdeploy only accepts fixed icon resolutions (…256, 384, 512 — NOT the # source's 1024), so downscale to 256×256. convert assets/app-icon.png -resize 256x256 "$TOOLS/tty7.png" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fab3929..21ef47c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,7 +128,6 @@ jobs: run: cargo test --locked --target ${{ matrix.target }} - name: Test desktop updater - if: runner.os == 'macOS' || runner.os == 'Windows' timeout-minutes: 10 run: cargo test --locked --features updater --bin tty7-updater --target ${{ matrix.target }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9bad230b..c3bb4871 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -122,15 +122,15 @@ jobs: with: workspaces: tty7 + # The updater ships on every GUI platform now: inside the macOS bundle, + # beside the Windows app, and inside the Linux AppImage. - name: Build working-directory: tty7 shell: bash run: | cargo build --release --target "${{ matrix.target }}" - if [[ "${{ matrix.os }}" == "macos" || "${{ matrix.os }}" == "windows" ]]; then - cargo build --release --features updater \ - --bin tty7-updater --target "${{ matrix.target }}" - fi + cargo build --release --features updater \ + --bin tty7-updater --target "${{ matrix.target }}" - name: Bundle macOS DMG if: matrix.os == 'macos' @@ -174,6 +174,26 @@ jobs: working-directory: tty7 run: bash .github/scripts/bundle-appimage.sh "${{ matrix.target }}" "${{ matrix.arch }}" + # The same facts the in-app updater checks on the user's machine, + # checked here so a packaging mistake fails the nightly instead. See + # the twin step in release.yml. + - name: Verify Linux AppImage update package + if: matrix.os == 'linux' + working-directory: tty7 + shell: bash + run: | + set -euo pipefail + VERSION="${{ needs.plan.outputs.version }}" + IMAGE="$PWD/dist/tty7-${VERSION}-linux-${{ matrix.arch }}.AppImage" + VERIFY_ROOT="$RUNNER_TEMP/tty7-appimage-update-verify" + rm -rf "$VERIFY_ROOT" + mkdir -p "$VERIFY_ROOT" + (cd "$VERIFY_ROOT" && "$IMAGE" --appimage-extract >/dev/null) + test -x "$VERIFY_ROOT/squashfs-root/usr/bin/tty7-app" + test -x "$VERIFY_ROOT/squashfs-root/usr/bin/tty7-updater" + grep -Fxq "X-AppImage-Version=${VERSION}" \ + "$VERIFY_ROOT/squashfs-root/usr/share/applications/tty7.desktop" + # See release.yml for why this is best-effort rather than required. - name: Fetch the bundled Linux server if: matrix.os == 'windows' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 350faf24..921e4653 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,15 +74,15 @@ jobs: # 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. + # The updater ships on every GUI platform now: inside the macOS bundle, + # beside the Windows app, and inside the Linux AppImage. - name: Build working-directory: tty7 shell: bash run: | cargo build --release --locked --target "${{ matrix.target }}" - if [[ "${{ matrix.os }}" == "macos" || "${{ matrix.os }}" == "windows" ]]; then - cargo build --release --locked --features updater \ - --bin tty7-updater --target "${{ matrix.target }}" - fi + cargo build --release --locked --features updater \ + --bin tty7-updater --target "${{ matrix.target }}" # ---- Packaging: one step per OS ---------------------------------------- # macOS gets a signed + notarized drag-to-Applications DMG. Windows gets @@ -117,6 +117,26 @@ jobs: working-directory: tty7 run: bash .github/scripts/bundle-appimage.sh "${{ matrix.target }}" "${{ matrix.arch }}" + # The in-app updater refuses an image whose bundled helper or stamped + # version is wrong — on the user's machine, after the download. Check the + # same facts here so a packaging mistake fails the release instead. + - name: Verify Linux AppImage update package + if: matrix.os == 'linux' + working-directory: tty7 + shell: bash + run: | + set -euo pipefail + VERSION="$(grep -m1 '^version = "' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" + IMAGE="$PWD/dist/tty7-${VERSION}-linux-${{ matrix.arch }}.AppImage" + VERIFY_ROOT="$RUNNER_TEMP/tty7-appimage-update-verify" + rm -rf "$VERIFY_ROOT" + mkdir -p "$VERIFY_ROOT" + (cd "$VERIFY_ROOT" && "$IMAGE" --appimage-extract >/dev/null) + test -x "$VERIFY_ROOT/squashfs-root/usr/bin/tty7-app" + test -x "$VERIFY_ROOT/squashfs-root/usr/bin/tty7-updater" + grep -Fxq "X-AppImage-Version=${VERSION}" \ + "$VERIFY_ROOT/squashfs-root/usr/share/applications/tty7.desktop" + # The bundled server for WSL. `continue-on-error` mirrors `server-musl`'s # own probe step: if there is no server asset, the release still ships and # `bundle-windows.ps1` warns. It is not silent at runtime either — a WSL diff --git a/src/bin/tty7-updater.rs b/src/bin/tty7-updater.rs index be2a8824..d9841d16 100644 --- a/src/bin/tty7-updater.rs +++ b/src/bin/tty7-updater.rs @@ -2,7 +2,10 @@ all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows" )] -#![cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] +#![cfg_attr( + not(any(target_os = "macos", target_os = "windows", target_os = "linux")), + allow(dead_code) +)] #[cfg(target_os = "macos")] mod macos { @@ -590,6 +593,692 @@ mod macos { } } +/// The Linux half serves exactly one installation shape: an AppImage. The +/// installed artifact is a single file (the path `$APPIMAGE` names), so the +/// whole install is one atomic swap — move the running image aside, rename +/// the verified download into its place, and start it. Tarball and distro +/// installs never reach this program; `package_for_current_install` in +/// `core::update` hands them the release page instead. +/// +/// One Linux-specific constraint shapes the code: the image the GUI runs +/// from is a FUSE mount the AppImage runtime tears down when the app exits — +/// which is the moment `install` starts working. The GUI therefore copies +/// this helper out of the mount into the staging directory and runs the +/// copy, the same way the Windows path runs a private copy because Setup +/// replaces the installed one. +#[cfg(target_os = "linux")] +mod linux { + use std::fs::{self, OpenOptions}; + use std::io::{Read as _, Write as _}; + use std::os::unix::fs::PermissionsExt as _; + use std::path::{Path, PathBuf}; + use std::process::{Child, Command, Stdio}; + use std::thread; + use std::time::Duration; + + const PARENT_POLL: Duration = Duration::from_millis(100); + const LAUNCH_GRACE: Duration = Duration::from_secs(1); + + /// Where `bundle-appimage.sh` installs the desktop entry inside the + /// image. The root-level `tty7.desktop` is linuxdeploy's symlink to this + /// file, and extracting a symlink alone yields a dangling link — so the + /// real path is the one asked for. + const DESKTOP_ENTRY: &str = "usr/share/applications/tty7.desktop"; + /// The helper inside the image, beside the app the way every platform + /// ships it. + const BUNDLED_UPDATER: &str = "usr/bin/tty7-updater"; + /// The desktop-entry key `bundle-appimage.sh` stamps the release version + /// into — the AppImage convention for stating a version where tools can + /// read it without running the app. + const VERSION_KEY: &str = "X-AppImage-Version="; + + pub fn run() -> Result<(), String> { + let mut args = std::env::args_os().skip(1); + let command = args + .next() + .and_then(|arg| arg.into_string().ok()) + .ok_or_else(usage)?; + match command.as_str() { + "verify" => { + let archive = next_path(&mut args)?; + let checksums = next_path(&mut args)?; + let asset_name = next_string(&mut args)?; + let stage = next_path(&mut args)?; + let expected_version = next_string(&mut args)?; + reject_extra(args)?; + verify_archive(&archive, &checksums, &asset_name)?; + verify_update(&archive, &stage, &expected_version) + } + "install" => { + let parent_pid = next_string(&mut args)? + .parse::() + .map_err(|_| "parent pid is not an unsigned integer".to_string())?; + let current = next_path(&mut args)?; + let archive = next_path(&mut args)?; + let checksums = next_path(&mut args)?; + let asset_name = next_string(&mut args)?; + let stage = next_path(&mut args)?; + let expected_version = next_string(&mut args)?; + let log = next_path(&mut args)?; + let options = tail_options(args)?; + options.apply(); + install(InstallPlan { + parent_pid, + current, + archive, + checksums, + asset_name, + stage, + expected_version, + log, + result_file: options.result_file, + }) + } + _ => Err(usage()), + } + } + + fn usage() -> String { + "usage: tty7-updater verify \ + \n\ + or: tty7-updater install \ + \ + [--config-dir ] [--result-file ]" + .to_string() + } + + fn next_path(args: &mut impl Iterator) -> Result { + args.next().map(PathBuf::from).ok_or_else(usage) + } + + fn next_string(args: &mut impl Iterator) -> Result { + args.next() + .and_then(|arg| arg.into_string().ok()) + .ok_or_else(usage) + } + + fn reject_extra(mut args: impl Iterator) -> Result<(), String> { + if args.next().is_some() { + Err(usage()) + } else { + Ok(()) + } + } + + /// The named options an install verb takes after its positional + /// arguments. See the Windows half of this file for why these are + /// arguments and not the environment. + #[derive(Default)] + struct TailOptions { + config_dir: Option, + result_file: Option, + } + + fn tail_options( + mut args: impl Iterator, + ) -> Result { + let mut options = TailOptions::default(); + while let Some(arg) = args.next() { + match arg.to_str() { + Some("--config-dir") => options.config_dir = Some(next_path(&mut args)?), + Some("--result-file") => options.result_file = Some(next_path(&mut args)?), + _ => return Err(usage()), + } + } + Ok(options) + } + + impl TailOptions { + fn apply(&self) { + let Some(dir) = &self.config_dir else { return }; + tty7_core::core::config::set_config_dir(dir.clone()); + // Re-exported so the relaunched app — a child of this process — + // keeps answering for the same config directory. Safe here: + // argument parsing runs before any thread exists. + unsafe { std::env::set_var("TTY7_CONFIG_DIR", dir) }; + } + } + + /// The terminal outcome of the attempt, for the next GUI launch to merge + /// into the update state (#540). Best-effort, like every log line here. + fn report_outcome( + result_file: Option<&Path>, + log: &Path, + version: &str, + result: &Result<(), String>, + ) { + let Some(path) = result_file else { return }; + let outcome = tty7_core::daemon::install::outcome::UpdateOutcome { + version: version.to_string(), + ok: result.is_ok(), + detail: result.as_ref().err().cloned(), + }; + if let Err(error) = tty7_core::daemon::install::outcome::write_outcome(path, &outcome) { + log_line( + log, + &format!( + "could not record the update outcome at {}: {error}", + path.display() + ), + ); + } + } + + struct InstallPlan { + parent_pid: u32, + current: PathBuf, + archive: PathBuf, + checksums: PathBuf, + asset_name: String, + stage: PathBuf, + expected_version: String, + log: PathBuf, + result_file: Option, + } + + fn install(plan: InstallPlan) -> Result<(), String> { + install_inner(&plan) + } + + // The daemon is deliberately left running, exactly as on macOS: nothing + // locks a running executable's file on Linux, and the daemon serves its + // panes from the old mount until the user chooses to restart it — that + // is what keeps their shells alive across the update. + fn install_inner(plan: &InstallPlan) -> Result<(), String> { + wait_for_exit(plan.parent_pid); + log_line(&plan.log, "re-verifying the staged tty7 update"); + let verification = verify_archive(&plan.archive, &plan.checksums, &plan.asset_name) + .and_then(|()| verify_update(&plan.archive, &plan.stage, &plan.expected_version)); + if let Err(error) = verification { + log_line(&plan.log, &error); + let _ = fs::remove_dir_all(&plan.stage); + let result = Err(error); + // The outcome lands before the old app does: the relaunched GUI + // merges it at startup, and a write afterward races that merge + // (#540). + report_outcome( + plan.result_file.as_deref(), + &plan.log, + &plan.expected_version, + &result, + ); + let _ = launch_app(&plan.current); + return result; + } + log_line(&plan.log, &format!("replacing {}", plan.current.display())); + let report = |result: &Result<(), String>| { + report_outcome( + plan.result_file.as_deref(), + &plan.log, + &plan.expected_version, + result, + ); + }; + replace_and_relaunch( + &plan.current, + &plan.archive, + &plan.stage, + launch_app, + report, + ) + .inspect_err(|error| log_line(&plan.log, error)) + } + + fn verify_archive(archive: &Path, checksums: &Path, asset_name: &str) -> Result<(), String> { + let bytes = + fs::read(archive).map_err(|error| format!("reading {}: {error}", archive.display()))?; + let manifest = fs::read_to_string(checksums) + .map_err(|error| format!("reading {}: {error}", checksums.display()))?; + tty7_core::daemon::install::checksums::verify(&manifest, asset_name, &bytes) + .map_err(|error| error.to_string()) + } + + /// What the downloaded file has to prove before it may become the + /// installation: it is a type-2 AppImage at all, it states the version + /// this update was for, and it carries its own updater — an image + /// without one would install fine and then be the last version that + /// ever could. Runs only after `verify_archive` has pinned the bytes to + /// the release's checksums.txt; from there, running the image's own + /// `--appimage-extract` is running the released code, which is exactly + /// what the swap is about to do anyway. + fn verify_update(staged: &Path, stage: &Path, expected_version: &str) -> Result<(), String> { + if !is_appimage(&read_header(staged)?) { + return Err(format!("{} is not a type-2 AppImage", staged.display())); + } + // Downloaded bytes land without the execute bit; extraction needs the + // runtime to run. The definitive mode is set again at swap time, taken + // from the file being replaced. + make_executable(staged)?; + let desktop = extract_entry(staged, stage, DESKTOP_ENTRY)?; + let text = fs::read_to_string(&desktop) + .map_err(|error| format!("reading {}: {error}", desktop.display()))?; + let actual = version_from_desktop_entry(&text).ok_or_else(|| { + format!("the staged AppImage's desktop entry carries no {VERSION_KEY}") + })?; + if actual != expected_version { + return Err(format!( + "the staged AppImage reports version {actual}, expected {expected_version}" + )); + } + extract_entry(staged, stage, BUNDLED_UPDATER)?; + Ok(()) + } + + /// ELF with the AppImage type-2 marker (`AI\x02` at offset 8). The + /// runtime the swap is about to spawn only exists behind this shape; a + /// wrongly published asset — a tarball under the AppImage name, an HTML + /// error page — fails here with a name instead of at launch. + fn is_appimage(header: &[u8]) -> bool { + header.len() >= 11 + && header[..4] == [0x7f, b'E', b'L', b'F'] + && header[8..11] == [b'A', b'I', 0x02] + } + + fn read_header(path: &Path) -> Result, String> { + let mut file = + fs::File::open(path).map_err(|error| format!("reading {}: {error}", path.display()))?; + let mut header = [0u8; 16]; + let read = file + .read(&mut header) + .map_err(|error| format!("reading {}: {error}", path.display()))?; + Ok(header[..read].to_vec()) + } + + fn make_executable(path: &Path) -> Result<(), String> { + let mode = fs::metadata(path) + .map_err(|error| format!("reading the mode of {}: {error}", path.display()))? + .permissions() + .mode(); + fs::set_permissions(path, fs::Permissions::from_mode(mode | 0o755)) + .map_err(|error| format!("marking {} executable: {error}", path.display())) + } + + /// Unpacks one entry of the staged image into `/squashfs-root/` + /// and returns the extracted file's path. + /// + /// `--appimage-extract` is answered by the AppImage runtime itself, + /// before any application code, and unpacks without mounting — so it + /// works on machines whose FUSE setup the eventual launch will need but + /// this verification should not. Run from the staging directory so the + /// `squashfs-root` it creates lands beside the package and is removed + /// with it. The runtime exits zero even when nothing matched, which is + /// why the answer is the extracted file's existence rather than the + /// exit status. + fn extract_entry(appimage: &Path, stage: &Path, entry: &str) -> Result { + // Anchored before the spawn: exec resolves a relative program path + // against the child's working directory, which the line below moves — + // a hand-run `tty7-updater verify ./pkg.AppImage …` would otherwise + // fail with a bare "No such file or directory". + let appimage = std::path::absolute(appimage) + .map_err(|error| format!("resolving {}: {error}", appimage.display()))?; + run_checked( + Command::new(&appimage) + .args(["--appimage-extract", entry]) + .current_dir(stage) + .stdout(Stdio::null()), + "extracting from the staged AppImage", + )?; + let extracted = stage.join("squashfs-root").join(entry); + if !extracted.is_file() { + return Err(format!("the staged AppImage carries no {entry}")); + } + Ok(extracted) + } + + fn version_from_desktop_entry(text: &str) -> Option { + text.lines() + .find_map(|line| line.strip_prefix(VERSION_KEY)) + .map(|version| version.trim().to_string()) + .filter(|version| !version.is_empty()) + } + + fn replace_and_relaunch( + current: &Path, + replacement: &Path, + stage: &Path, + launch: impl Fn(&Path) -> Result<(), String>, + report: impl Fn(&Result<(), String>), + ) -> Result<(), String> { + // The staging directory is a fresh TempDir created beside the current + // image, so a backup here stays on the same filesystem without using a + // predictable sibling path. In particular, never delete a fixed-name + // path beside the image: it may be a recovery copy left by an + // interrupted update (or simply an unrelated user-owned path). + let backup = stage.join("previous.AppImage"); + if backup.exists() { + let result = Err(format!( + "the update staging backup already exists: {}", + backup.display() + )); + report(&result); + return result; + } + // The replacement wears the current image's own mode: a rename keeps + // the staged file's permissions, which are the download's, and the + // user's choice of who may run their tty7 is not this program's to + // revise. Owner execute is guaranteed on top — without it nothing can + // relaunch — and grants nobody else anything. + if let Err(error) = carry_mode(current, replacement) { + report(&Err(error.clone())); + return Err(error); + } + if let Err(error) = fs::rename(current, &backup) { + let result = Err(format!("moving the current AppImage aside: {error}")); + report(&result); + return result; + } + + if let Err(error) = fs::rename(replacement, current) { + let _ = fs::rename(&backup, current); + let _ = fs::remove_dir_all(stage); + let result = Err(format!("putting the staged AppImage in place: {error}")); + report(&result); + return result; + } + + match launch(current) { + Ok(()) => { + let _ = remove_path(&backup); + let _ = fs::remove_dir_all(stage); + let result = Ok(()); + report(&result); + result + } + Err(error) => { + let _ = remove_path(current); + let (result, relaunch) = match fs::rename(&backup, current) { + Ok(()) => { + let _ = fs::remove_dir_all(stage); + (Err(error), true) + } + Err(restore) => ( + Err(format!("{error}; restoring the previous image: {restore}")), + false, + ), + }; + // The outcome lands before the old app does: the relaunched + // GUI merges it at startup, and a write afterward races that + // merge (#540). + report(&result); + if relaunch { + let _ = launch(current); + } + result + } + } + } + + /// Puts the mode of the file being replaced onto its replacement, + /// with owner execute assured. Falls back to plain 0o755 when the + /// current image cannot answer — it is about to be renamed away, not + /// consulted as an authority. + fn carry_mode(current: &Path, replacement: &Path) -> Result<(), String> { + let mode = fs::metadata(current) + .map(|meta| meta.permissions().mode()) + .unwrap_or(0o755); + fs::set_permissions(replacement, fs::Permissions::from_mode(mode | 0o700)) + .map_err(|error| format!("setting the mode of {}: {error}", replacement.display())) + } + + /// Starts the image at its installed path. The runtime sets `$APPIMAGE` + /// and `$APPDIR` for the process it mounts, overwriting the stale pair + /// this process inherited from the app that spawned it. + fn launch_app(appimage: &Path) -> Result<(), String> { + let mut child = Command::new(appimage) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("launching {}: {error}", appimage.display()))?; + healthy_after_grace(&mut child) + } + + fn healthy_after_grace(child: &mut Child) -> Result<(), String> { + thread::sleep(LAUNCH_GRACE); + match child + .try_wait() + .map_err(|error| format!("checking the relaunched app: {error}"))? + { + None => Ok(()), + Some(status) => Err(format!( + "the relaunched app exited immediately with {status}" + )), + } + } + + fn wait_for_exit(pid: u32) { + // The updater is spawned directly by the app it waits for, so while + // that app lives it *is* this process's parent, and the kernel + // reparents us the moment it exits. Watching getppid() is therefore + // immune to pid reuse, which `kill(pid, 0)` is not: a recycled pid + // keeps answering 0 forever. Same reasoning as the macos module; + // Linux reparents to init or the nearest subreaper, and either way + // the answer stops being `pid`. + let pid = pid as libc::pid_t; + if unsafe { libc::getppid() } == pid { + while unsafe { libc::getppid() } == pid { + thread::sleep(PARENT_POLL); + } + return; + } + // Not our parent — a hand-run updater. The polling fallback keeps + // that invocation working, pid-reuse caveat and all. + while process_alive(pid) { + thread::sleep(PARENT_POLL); + } + } + + fn process_alive(pid: libc::pid_t) -> bool { + unsafe { libc::kill(pid, 0) == 0 } + } + + fn remove_path(path: &Path) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + } + .map_err(|error| format!("removing {}: {error}", path.display())) + } + + fn run_checked(command: &mut Command, context: &str) -> Result<(), String> { + let output = command + .output() + .map_err(|error| format!("{context}: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "{context}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } + } + + fn log_line(path: &Path, message: &str) { + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(file, "{message}"); + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn successful_launch_commits_the_replacement() { + let root = tempfile::tempdir().unwrap(); + let current = root.path().join("tty7.AppImage"); + let stage = root.path().join("stage"); + fs::create_dir(&stage).unwrap(); + let replacement = stage.join("tty7-new.AppImage"); + fs::write(¤t, b"old image").unwrap(); + fs::write(&replacement, b"new image").unwrap(); + + replace_and_relaunch(¤t, &replacement, &stage, |_| Ok(()), |_| ()).unwrap(); + + assert_eq!(fs::read(¤t).unwrap(), b"new image"); + assert!(!stage.exists()); + } + + #[test] + fn failed_launch_restores_and_relaunches_the_previous_image() { + let root = tempfile::tempdir().unwrap(); + let current = root.path().join("tty7.AppImage"); + let stage = root.path().join("stage"); + fs::create_dir(&stage).unwrap(); + let replacement = stage.join("tty7-new.AppImage"); + fs::write(¤t, b"old image").unwrap(); + fs::write(&replacement, b"new image").unwrap(); + let launches = std::cell::Cell::new(0); + let reported_after_launches = std::cell::Cell::new(usize::MAX); + + let error = replace_and_relaunch( + ¤t, + &replacement, + &stage, + |_| { + launches.set(launches.get() + 1); + if launches.get() == 1 { + Err("new app failed".to_string()) + } else { + Ok(()) + } + }, + |_| reported_after_launches.set(launches.get()), + ) + .unwrap_err(); + + assert_eq!(error, "new app failed"); + assert_eq!(launches.get(), 2); + // The outcome is reported after the failed first launch but before + // the old app comes back — the relaunched GUI must find it already + // on disk at startup (#540). + assert_eq!(reported_after_launches.get(), 1); + assert_eq!(fs::read(¤t).unwrap(), b"old image"); + assert!(!stage.exists()); + } + + /// The installed image keeps the mode the user gave it — a 0700 + /// image stays private — while the download's missing execute bit + /// never survives into the installation. + #[test] + fn the_replacement_wears_the_current_images_mode() { + let root = tempfile::tempdir().unwrap(); + let current = root.path().join("tty7.AppImage"); + let stage = root.path().join("stage"); + fs::create_dir(&stage).unwrap(); + let replacement = stage.join("tty7-new.AppImage"); + fs::write(¤t, b"old image").unwrap(); + fs::write(&replacement, b"new image").unwrap(); + fs::set_permissions(¤t, fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(&replacement, fs::Permissions::from_mode(0o644)).unwrap(); + + replace_and_relaunch(¤t, &replacement, &stage, |_| Ok(()), |_| ()).unwrap(); + + let mode = fs::metadata(¤t).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "mode {mode:o}"); + } + + /// A leftover backup means an earlier attempt stopped between its two + /// renames; installing over it would overwrite the one copy of the + /// previous version. + #[test] + fn an_existing_backup_stops_the_replacement() { + let root = tempfile::tempdir().unwrap(); + let current = root.path().join("tty7.AppImage"); + let stage = root.path().join("stage"); + fs::create_dir(&stage).unwrap(); + let replacement = stage.join("tty7-new.AppImage"); + fs::write(¤t, b"old image").unwrap(); + fs::write(&replacement, b"new image").unwrap(); + fs::write(stage.join("previous.AppImage"), b"earlier backup").unwrap(); + + let error = replace_and_relaunch( + ¤t, + &replacement, + &stage, + |_| panic!("nothing may launch when the backup path is taken"), + |_| (), + ) + .unwrap_err(); + + assert!(error.contains("backup already exists"), "{error}"); + assert_eq!(fs::read(¤t).unwrap(), b"old image"); + } + + #[test] + fn archive_verification_rejects_bytes_that_do_not_match_the_manifest() { + let root = tempfile::tempdir().unwrap(); + let archive = root.path().join("tty7.AppImage"); + let manifest = root.path().join("checksums.txt"); + fs::write(&archive, b"downloaded bytes").unwrap(); + fs::write( + &manifest, + format!( + "{} tty7.AppImage\n", + tty7_core::daemon::install::checksums::hex( + &tty7_core::daemon::install::checksums::sha256(b"published bytes") + ) + ), + ) + .unwrap(); + + let error = verify_archive(&archive, &manifest, "tty7.AppImage").unwrap_err(); + assert!(error.contains("failed sha256 verification"), "{error}"); + } + + /// The magic check runs before anything executes the download, so a + /// mis-published asset is named without being run. + #[test] + fn verification_rejects_a_file_that_is_not_an_appimage() { + let mut elf_with_marker = vec![0x7f, b'E', b'L', b'F', 2, 1, 1, 0, b'A', b'I', 0x02]; + elf_with_marker.resize(16, 0); + assert!(is_appimage(&elf_with_marker)); + // A plain ELF — the tarball's binary, say — is not an AppImage. + let mut bare_elf = vec![0x7f, b'E', b'L', b'F', 2, 1, 1, 0, 0, 0, 0]; + bare_elf.resize(16, 0); + assert!(!is_appimage(&bare_elf)); + assert!(!is_appimage(b"Not Found")); + assert!(!is_appimage(b"")); + assert!(!is_appimage(&[0x7f, b'E', b'L', b'F'])); + + let root = tempfile::tempdir().unwrap(); + let staged = root.path().join("tty7.AppImage"); + fs::write(&staged, b"Not Found").unwrap(); + let error = verify_update(&staged, root.path(), "27.1.0").unwrap_err(); + assert!(error.contains("not a type-2 AppImage"), "{error}"); + } + + #[test] + fn the_desktop_entry_states_the_version() { + let text = "[Desktop Entry]\nType=Application\nName=tty7\nExec=tty7-app\n\ + Icon=tty7\nX-AppImage-Version=26.8.4\n"; + assert_eq!(version_from_desktop_entry(text).as_deref(), Some("26.8.4")); + // The nightly stamp survives whole — the identity the GUI + // compares against carries the prerelease tail. + assert_eq!( + version_from_desktop_entry("X-AppImage-Version=26.8.4-nightly.202608140200\n") + .as_deref(), + Some("26.8.4-nightly.202608140200") + ); + assert_eq!( + version_from_desktop_entry("[Desktop Entry]\nName=tty7\n"), + None + ); + // A stated nothing is not a version. + assert_eq!(version_from_desktop_entry("X-AppImage-Version=\n"), None); + assert_eq!(version_from_desktop_entry("X-AppImage-Version= \n"), None); + } + } +} + #[cfg(target_os = "windows")] mod windows { use std::collections::HashSet; @@ -3366,8 +4055,16 @@ fn main() { } } -#[cfg(not(any(target_os = "macos", target_os = "windows")))] +#[cfg(target_os = "linux")] fn main() { - eprintln!("tty7-updater is only available on macOS and Windows"); + if let Err(error) = linux::run() { + eprintln!("tty7-updater: {error}"); + std::process::exit(1); + } +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +fn main() { + eprintln!("tty7-updater is only available on macOS, Windows and Linux"); std::process::exit(1); } diff --git a/src/core/update.rs b/src/core/update.rs index bfdcf63d..1d4ed849 100644 --- a/src/core/update.rs +++ b/src/core/update.rs @@ -132,8 +132,10 @@ pub enum UpdateInstallHint { UnsupportedMacos, #[cfg(target_os = "linux")] UnsupportedLinux, - /// Linux updates by hand, but "use the release page" leaves the user to - /// work out which of five files is theirs. This names it. + /// The Linux shapes that still update by hand — a tarball install, or an + /// AppImage that cannot replace itself (read-only directory, no bundled + /// helper). "Use the release page" would leave the user to work out which + /// of five files is theirs; this names it. #[cfg(target_os = "linux")] LinuxManualPackage(String), #[cfg(target_os = "windows")] @@ -1170,9 +1172,10 @@ fn sweep_orphaned_stages(keep: Option) { } } -/// Where each platform's staging directories are created — beside the bundle on -/// macOS (it has to be on the app's own volume to rename into place), and the -/// per-user temp directory on Windows. +/// Where each platform's staging directories are created — beside the bundle +/// on macOS and beside the image on Linux (both have to be on the installed +/// file's own volume to rename into place), and the per-user temp directory +/// on Windows. // The `return`s are what let one cfg block win per platform; clippy sees only // the surviving one and reads it as redundant. #[allow(clippy::needless_return)] @@ -1184,11 +1187,18 @@ fn stage_roots() -> Vec { .into_iter() .collect(); } + #[cfg(target_os = "linux")] + { + return current_appimage() + .and_then(|appimage| appimage.parent().map(Path::to_path_buf)) + .into_iter() + .collect(); + } #[cfg(target_os = "windows")] { return vec![std::env::temp_dir()]; } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] Vec::new() } @@ -1970,6 +1980,7 @@ fn select_release_asset_for( /// The release package this installation can replace itself with. Split from /// the bare filename so the Windows Inno layout can carry "yes, but the /// install needs a UAC prompt" alongside it (#504). +#[derive(Debug)] struct PackageOffer { name: String, #[cfg(target_os = "windows")] @@ -2017,14 +2028,16 @@ fn package_for_current_install(version: &str) -> Result, outcome: Option<&Path>) -> Vec Result { tempfile::Builder::new() .prefix(".tty7-update-") @@ -2304,6 +2321,70 @@ fn prepare_macos_update( }) } +/// Stages a downloaded AppImage beside the installed one and has the bundled +/// updater verify it while this process is still around to show a failure. +/// +/// Staging lives in the image's own directory for the same reason macOS +/// stages beside the bundle: the swap is two renames, and renames only stay +/// atomic on one filesystem. The helper that performs them is *copied* into +/// staging rather than run from the image — the image is a FUSE mount the +/// runtime tears down when the GUI exits, which is precisely the moment the +/// installer starts working. +#[cfg(target_os = "linux")] +fn prepare_linux_update( + version: &str, + asset_name: &str, + archive: &[u8], + checksums: &[u8], +) -> Result { + let current = current_appimage().context("tty7 is not running from an AppImage")?; + let parent = current + .parent() + .context("the AppImage has no parent directory")?; + let bundled = bundled_updater().context("tty7-updater is not bundled with this image")?; + let staging = update_staging_dir(parent)?; + let dir = staging.path().to_path_buf(); + let archive = write_staged_asset(&dir, asset_name, archive)?; + let checksums = write_staged_asset(&dir, "checksums.txt", checksums)?; + run_updater( + &bundled, + [ + PathBuf::from("verify"), + archive.clone(), + checksums.clone(), + PathBuf::from(asset_name), + dir.clone(), + PathBuf::from(version), + ], + )?; + let updater = dir.join("tty7-updater"); + std::fs::copy(&bundled, &updater) + .with_context(|| format!("copying the updater to {}", updater.display()))?; + let log = + crate::core::config::config_path("update.log").unwrap_or_else(|| dir.join("update.log")); + if let Some(parent) = log.parent() { + std::fs::create_dir_all(parent).context("creating the update log directory")?; + } + let dir = staging.keep(); + Ok(PreparedUpdate { + updater, + command: "install".to_string(), + rest: vec![ + current, + archive, + checksums, + PathBuf::from(asset_name), + dir.clone(), + PathBuf::from(version), + log, + ], + config_dir: crate::core::config::config_dir_path(), + stage: dir, + needs_elevation: false, + expected_sha256: None, + }) +} + #[cfg(target_os = "windows")] fn prepare_windows_update( version: &str, @@ -2441,11 +2522,63 @@ fn bundled_updater() -> Option { updater.is_file().then_some(updater) } -#[cfg(not(any(target_os = "macos", target_os = "windows")))] +/// The updater shipped inside the mounted image, beside this executable at +/// `usr/bin`. Resolved through `current_exe` rather than `$APPDIR` so a +/// stale or hand-set variable cannot point the update machinery at a binary +/// that is not the one this process actually runs beside. +#[cfg(target_os = "linux")] +fn bundled_updater() -> Option { + let updater = std::env::current_exe().ok()?.parent()?.join("tty7-updater"); + updater.is_file().then_some(updater) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] fn bundled_updater() -> Option { None } +/// The image this process is running from. The AppImage runtime exports +/// `$APPIMAGE`, pointing at the file it mounted; nothing else identifies +/// which of the Linux packages is installed. Required to name an existing +/// absolute path — the variable outlives moves and deletions, and every +/// answer given here is a file the updater will later rename. +#[cfg(target_os = "linux")] +fn current_appimage() -> Option { + let appimage = PathBuf::from(std::env::var_os("APPIMAGE")?); + (appimage.is_absolute() && appimage.is_file()).then_some(appimage) +} + +#[cfg(target_os = "linux")] +fn is_appimage_update_writable(appimage: &Path) -> bool { + appimage.parent().is_some_and(can_stage_replacement_in) +} + +/// The Linux answer, split from the probing so the policy is testable +/// without an AppImage runtime setting variables. Only an AppImage that can +/// stage and swap itself is offered an install; every other shape keeps the +/// named-package hint it always had — a tarball unpacks wherever the user +/// chose, a distro package belongs to its package manager, and guessing at +/// either is exactly what `package_for_current_install` must never do. +#[cfg(target_os = "linux")] +fn linux_package_for( + version: &str, + arch: &str, + is_appimage: bool, + can_self_update: bool, +) -> Result { + if !is_appimage { + return Err(UpdateInstallHint::LinuxManualPackage(format!( + "tty7-{version}-linux-{arch}.tar.gz" + ))); + } + let name = format!("tty7-{version}-linux-{arch}.AppImage"); + if can_self_update { + Ok(PackageOffer::plain(name)) + } else { + Err(UpdateInstallHint::LinuxManualPackage(name)) + } +} + #[cfg(target_os = "windows")] #[derive(Clone, Debug, PartialEq, Eq)] enum WindowsUpdateLayout { @@ -2685,7 +2818,7 @@ fn windows_all_users_install_path() -> Option { (!value.is_empty()).then(|| PathBuf::from(std::ffi::OsString::from_wide(&value))) } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] fn can_stage_replacement_in(dir: &Path) -> bool { tempfile::Builder::new() .prefix(".tty7-update-write-test-") @@ -2816,6 +2949,64 @@ mod tests { ); } + /// The one Linux shape that installs itself: an AppImage whose directory + /// takes a staging directory and whose image carries the helper. + #[cfg(target_os = "linux")] + #[test] + fn an_appimage_that_can_replace_itself_is_offered_the_appimage() { + let offer = linux_package_for("27.1.0", "x86_64", true, true) + .expect("a self-updating AppImage yields an offer"); + assert_eq!(offer.name, "tty7-27.1.0-linux-x86_64.AppImage"); + } + + /// Everything else keeps the manual hint, and the hint names the exact + /// package for the installation shape rather than pointing at a release + /// page with five files on it. + #[cfg(target_os = "linux")] + #[test] + fn linux_installs_that_cannot_self_update_name_their_package() { + // An AppImage in a read-only directory, or one from before the + // helper shipped: still an AppImage, still updated by hand. + assert_eq!( + linux_package_for("27.1.0", "x86_64", true, false).unwrap_err(), + UpdateInstallHint::LinuxManualPackage("tty7-27.1.0-linux-x86_64.AppImage".to_string()) + ); + // A tarball (or distro-packaged) install is never guessed at. + assert_eq!( + linux_package_for("27.1.0", "x86_64", false, false).unwrap_err(), + UpdateInstallHint::LinuxManualPackage("tty7-27.1.0-linux-x86_64.tar.gz".to_string()) + ); + // `can_self_update` without an AppImage cannot happen (the probe is + // gated on the variable), but the policy must not invent an offer if + // it ever does. + assert!(linux_package_for("27.1.0", "x86_64", false, true).is_err()); + } + + /// The offered AppImage name must match what the release actually + /// publishes, checksums manifest included — the same end-to-end shape the + /// macOS selection test pins. + #[cfg(target_os = "linux")] + #[test] + fn appimage_selection_matches_the_published_asset_names() { + let name = "tty7-27.1.0-linux-x86_64.AppImage"; + let offer = linux_package_for("27.1.0", "x86_64", true, true).unwrap(); + let assets = [ + github_asset("tty7-27.1.0-linux-x86_64.tar.gz"), + github_asset(name), + github_asset("checksums.txt"), + ]; + let selected = select_release_asset_for(Ok(offer), &assets); + assert_eq!( + selected.asset, + Some(ReleaseAsset { + name: name.to_string(), + url: format!("https://example.test/{name}"), + checksums_url: "https://example.test/checksums.txt".to_string(), + }) + ); + assert_eq!(selected.reason, None); + } + #[cfg(target_os = "windows")] #[test] fn portable_backup_scan_separates_interrupted_from_finished() { From ccd21fe97de4dc09cc5ea4600ef5c90543b248d5 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Sun, 16 Aug 2026 17:49:54 +0800 Subject: [PATCH 03/33] fix(windows): keep a restored screen out of ConPTY's viewport, and stop Restart Server crashing the window (#657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(restore): keep a restored screen out of ConPTY's viewport On Windows a restored pane came back with its shell drawing in the wrong place: the prompt stopped responding where it stood and the restored text filled with fragments of whatever was being typed. A ConPTY does not hand the terminal a stream, it hands it a rendering of a screen buffer conhost owns, addressed absolutely and counted from that buffer's top-left, which starts blank with the cursor at (0,0). PSReadLine redraws the line being typed as `ESC[6;20H ... ESC[6;26H` on every keystroke, and conhost frames what it paints the same way. Those row numbers are only right if the client's viewport is conhost's buffer, row for row. Restored output is output conhost never produced and knows nothing about. Left on screen it shifts every row conhost names, so the first repaint of the input line lands on the old text. Nothing the client can do fixes it afterwards: the offset is not constant, and it would have to be unpicked from every absolute address in the stream. So the restore preamble now ends by scrolling the restored screen out of the way. `ESC[2J` on the primary screen scrolls the viewport into history rather than erasing it, so the screen the daemon restored is one scroll up rather than gone, and `ESC[H` leaves the cursor where a fresh ConPTY expects to find it. Unix keeps the old behaviour: a shell there positions itself relatively, so the restored screen can stay where it can be seen. * fix(restart): stop Restart Server taking the window with it Clicking Restart Server made the whole app disappear, with a double-lease panic in the crash log: cannot read Tty7App while it is already being updated. The work that puts the window back together after the restart ran inside `update_in` on this window's own entity, and it ends by rebuilding every local window from the machine tree. The first thing that rebuild asks each window is which tabs it is showing, which it reads back out of the window registry — so the first window it reaches for is the one the closure already holds leased, and gpui answers a double lease by panicking, which on the main thread is the process. Split into `settle_after_restart`: the window's own state first, then the resync outside the lease, then the focus. The resync still runs either way the restart went, because a refused handoff leaves the daemon serving the panes this window already dropped (#554). --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- crates/tty7-core/src/daemon/pane.rs | 64 ++++++++++- src/terminal/remote.rs | 106 ++++++++++++++++- src/ui/app.rs | 171 ++++++++++++++++++++-------- 3 files changed, 294 insertions(+), 47 deletions(-) diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 67a05d1b..ad6fc8ad 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -1255,7 +1255,11 @@ pub struct Restore { /// for and cannot see. Leaving the alternate screen also does the useful thing /// in the common case: the primary buffer still holds the pre-`vim` scrollback /// from earlier in the same snapshot. -fn restore_preamble(banner: Option<&str>) -> Vec { +/// +/// On Windows it ends by scrolling the restored screen out of the viewport +/// ([`SCROLL_RESTORED_AWAY`]), which is a correctness requirement rather than a +/// matter of taste — see that constant. +pub fn restore_preamble(banner: Option<&str>) -> Vec { let mut out = Vec::new(); out.extend_from_slice(b"\x1b[?1049l\x1b[?25h\x1b[?7h\x1b[0m"); if let Some(banner) = banner.map(str::trim).filter(|b| !b.is_empty()) { @@ -1265,9 +1269,40 @@ fn restore_preamble(banner: Option<&str>) -> Vec { out.extend_from_slice(banner.replace(['\r', '\n'], " ").as_bytes()); out.extend_from_slice(b" \xe2\x94\x80\xe2\x94\x80\x1b[0m\r\n"); } + if cfg!(windows) { + out.extend_from_slice(SCROLL_RESTORED_AWAY); + } out } +/// Push the restored screen into the client's scrollback and put the cursor +/// back at the top-left, so the incoming shell starts on a blank viewport. +/// +/// A pty on unix hands the terminal a stream; a ConPTY hands it a *rendering of +/// a screen buffer it owns*. That buffer starts blank with its cursor at the +/// top-left, and conhost addresses it absolutely: PSReadLine redrawing the line +/// being typed emits `ESC[6;20H`, meaning row 6 of conhost's buffer, and every +/// frame conhost paints is positioned the same way. Those row numbers are only +/// correct if the client's viewport is conhost's buffer, row for row. +/// +/// Restored output breaks exactly that. It is output conhost never produced and +/// knows nothing about, so leaving it on screen puts the shell's first prompt +/// some rows below where conhost believes it is, and the first keystroke +/// repaints the input line *over the restored text* — the prompt stops +/// responding and the old screen fills with fragments of what is being typed. +/// Nothing the client can do fixes that after the fact: the offset is not a +/// constant (the screen scrolls) and it would have to be unpicked from every +/// absolute address in the stream. +/// +/// So the restored screen goes where it can be kept without claiming a row: +/// `ESC[2J` on the primary screen scrolls the viewport into history rather than +/// erasing it, so it is a scroll away, and `ESC[H` leaves the cursor where a +/// fresh ConPTY expects to find it. +/// +/// Not done on unix, where the shell positions itself relatively and the +/// restored screen can simply stay where the user can see it. +pub const SCROLL_RESTORED_AWAY: &[u8] = b"\x1b[2J\x1b[H"; + impl DaemonPane { pub fn spawn( id: u64, @@ -3626,6 +3661,33 @@ mod tests { assert!(text.contains("this shell is new")); } + /// The ConPTY constraint, from the daemon's side. A pane whose shell runs + /// on a ConPTY must open with an empty viewport and the cursor at the + /// top-left, because that is the state conhost's own screen buffer starts + /// in and every row it names afterwards is counted from there. Restored + /// output left on screen shifts all of them, and the shell's first repaint + /// of the line being typed lands on the old text — see + /// [`SCROLL_RESTORED_AWAY`]. + #[test] + fn the_preamble_clears_the_way_for_conpty_and_only_for_conpty() { + let text = String::from_utf8(restore_preamble(Some("this shell is new"))).unwrap(); + if cfg!(windows) { + assert!( + text.ends_with("\x1b[2J\x1b[H"), + "the restored screen has to be scrolled into history and the cursor \ + homed *last*, after the banner: anything printed afterwards would \ + take back the row conhost counts from. The preamble ends {:?}", + &text[text.len().saturating_sub(16)..] + ); + } else { + assert!( + !text.contains("\x1b[2J"), + "on a real pty the shell positions itself relatively, so the screen \ + the user asked to have back stays where they can see it" + ); + } + } + #[test] fn a_banner_cannot_smuggle_extra_lines_into_the_pane() { let text = String::from_utf8(restore_preamble(Some("first\r\nsecond"))).unwrap(); diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index a58191c1..15f78582 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -2246,7 +2246,7 @@ fn win_size(size: TermSize, cell_w: u16, cell_h: u16) -> WinSize { } #[cfg(all(test, windows))] -mod windows_teardown_tests { +mod windows_tests { use super::*; fn tcp_pair() -> (std::net::TcpStream, std::net::TcpStream) { @@ -2346,6 +2346,110 @@ mod windows_teardown_tests { "the abandoned link must not tear the adopted pane down" ); } + + /// What the daemon replays into a pane restored from a stored screen, in + /// the frames and the order it sends them: the dead pane's screen, then the + /// preamble, then the new shell's own first output. + fn replay_restore_into(daemon: &mut std::net::TcpStream, old_screen: &[u8], shell: &[u8]) { + let size = WinSize { + cols: 40, + rows: 10, + cell_w: 8, + cell_h: 17, + }; + DaemonMsg::Size(size).encode(daemon).unwrap(); + DaemonMsg::Snapshot(old_screen.to_vec()) + .encode(daemon) + .unwrap(); + DaemonMsg::Size(size).encode(daemon).unwrap(); + DaemonMsg::Snapshot(crate::daemon::pane::restore_preamble(Some( + "the shell below is new", + ))) + .encode(daemon) + .unwrap(); + DaemonMsg::Output(shell.to_vec()).encode(daemon).unwrap(); + } + + fn row(term: &RemoteTerminal, line: i32) -> String { + use alacritty_terminal::grid::Dimensions as _; + use alacritty_terminal::index::{Column, Line}; + let term = term.term.lock(); + let grid = term.grid(); + (0..grid.columns()) + .map(|c| grid[Line(line)][Column(c)].c) + .collect::() + .trim_end() + .to_string() + } + + /// A restored screen must not be sitting in the viewport when the new + /// shell's ConPTY starts drawing on it. + /// + /// conhost addresses its own screen buffer absolutely — PSReadLine repaints + /// the line being typed with `ESC[6;20H` and conhost frames it the same way + /// — and that buffer starts blank with the cursor at the top-left. Restored + /// output is output conhost never produced: left on screen it shifts every + /// row conhost names, so the first keystroke repaints the input line on top + /// of the old text instead of at the prompt. The restored screen belongs in + /// scrollback, where it survives without claiming a row. + #[test] + fn a_restored_screen_leaves_the_new_shell_the_viewport_conpty_thinks_it_has() { + crate::core::config::pin_test_config_dir(); + let (client_side, mut daemon_side) = tcp_pair(); + let term = RemoteTerminal::from_stream(client_side, TermSize::new(40, 10)).unwrap(); + + // Five lines of the dead pane, then the shell painting its prompt the + // way conhost does: at row 1 of a buffer it believes is blank. + replay_restore_into( + &mut daemon_side, + b"line one\r\nline two\r\nline three\r\nline four\r\nline five\r\n", + b"\x1b[?25l\x1b[1;1HPS C:\\> \x1b[?25h", + ); + + let mut top = String::new(); + for _ in 0..400 { + top = row(&term, 0); + if top.starts_with("PS C:") { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!( + top, "PS C:\\>", + "the shell's prompt paints where conhost put it" + ); + + for line in 1..10 { + assert_eq!( + row(&term, line), + "", + "row {line} still holds restored output, so conhost and the client \ + disagree about which row is which: the next repaint of the input \ + line lands on the old screen instead of at the prompt" + ); + } + + // Kept, not erased: `ESC[2J` on the primary screen scrolls the viewport + // into history, so the screen the daemon restored is one scroll away. + let depth = { + use alacritty_terminal::grid::Dimensions as _; + term.term.lock().grid().history_size() as i32 + }; + let history: Vec = (-depth..0).map(|line| row(&term, line)).collect(); + for wanted in [ + "line one", + "line two", + "line three", + "line four", + "line five", + ] { + assert!( + history.iter().any(|row| row == wanted), + "{wanted:?} is not in the scrollback; the restored screen was erased \ + rather than scrolled away. History holds {history:?}" + ); + } + } } #[cfg(all(test, unix))] diff --git a/src/ui/app.rs b/src/ui/app.rs index abb601d9..f618b2d3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1778,55 +1778,73 @@ impl Tty7App { } }) .await; - let _ = this.update_in(cx, |this, window, cx| { - match &restarted { - Ok(()) => { - // The link we held pointed at the server we just killed; - // the reconnect finds a new process whose registry knows - // nothing about these panes. The helper drops the dead - // link first — a pull sent down it dies on a dead socket - // before the reader notices — and rebuilds every local - // window from the tree. - crate::ui::tree_sync::resync_after_local_daemon_change(cx); - } - Err(e) => { - // A refused handoff leaves the daemon exactly as it was, - // still serving the panes this window just dropped. Leaving - // it at the error here strands them: every restore path is - // tree-driven, and the next sync of this emptied window - // would diff into "close every tab" against the mirror — - // deleting the pane records under the still-running shells, - // or the whole workspace if the user simply closes the - // window first (#554). Pull the layout back instead; where - // the failure really did take the daemon away (an exec that - // never re-listened), the pull misses and the rehydration - // debt keeps the empty window from being pushed up. - // - // The invalidating helper, not the one the reconnect uses: - // nothing here handshaked a link. Half of `hand_off`'s - // failures happen *after* the exec — a daemon that never - // started listening again is gone, and the client we still - // hold points at its socket, which `is_connected` keeps - // calling good until its reader sees the EOF. - log::error!( - "restart background service failed, resyncing from the tree: {e}" - ); - let text = t_fmt( - L10nKey::AppRestartServerFailed, - &[("error", &e.to_string())], - ); - this.startup_error = Some(gpui::SharedString::from(text.clone())); - window.push_notification(text, cx); - crate::ui::tree_sync::resync_after_local_daemon_change(cx); - } - } - this.focus_active(window, cx); - cx.notify(); - }); + Self::settle_after_restart(this, restarted, cx).await; }) .detach(); } + /// Put the window back together once the restart has been attempted, either + /// way it went. + /// + /// Split into three steps because the middle one must not run with this + /// window's entity leased. `resync_after_local_daemon_change` takes the + /// whole `App` and rebuilds *every* local window from the tree, and the + /// first thing it asks each one is which tabs it is showing — which it gets + /// by reading that window's `Tty7App` back out of the registry. Called from + /// inside `update_in`, the window it reaches for first is the one already + /// leased to the closure, and gpui's answer to a double lease is a panic + /// that takes the process with it: clicking Restart Server made the whole + /// app vanish. + async fn settle_after_restart( + this: gpui::WeakEntity, + restarted: anyhow::Result<()>, + cx: &mut gpui::AsyncApp, + ) { + // A refused handoff leaves the daemon exactly as it was, still serving + // the panes this window just dropped. Leaving it at the error here + // strands them: every restore path is tree-driven, and the next sync of + // this emptied window would diff into "close every tab" against the + // mirror — deleting the pane records under the still-running shells, or + // the whole workspace if the user simply closes the window first (#554). + // So the resync below runs either way; this step only says so on screen. + if this + .update_in(cx, |this, window, cx| { + if let Err(e) = &restarted { + log::error!("restart background service failed, resyncing from the tree: {e}"); + let text = t_fmt( + L10nKey::AppRestartServerFailed, + &[("error", &e.to_string())], + ); + this.startup_error = Some(gpui::SharedString::from(text.clone())); + window.push_notification(text, cx); + } + }) + .is_err() + { + return; + } + + // The link we held pointed at the server we just killed; the reconnect + // finds a new process whose registry knows nothing about these panes. + // The helper drops the dead link first — a pull sent down it dies on a + // dead socket before the reader notices — and rebuilds every local + // window from the tree. Where the restart failed and the daemon is + // really gone, the pull misses and the rehydration debt keeps the empty + // window from being pushed back up. + // + // The invalidating helper, not the one the reconnect uses: nothing here + // handshaked a link. Half of `hand_off`'s failures happen *after* the + // exec — a daemon that never started listening again is gone, and the + // client we still hold points at its socket, which `is_connected` keeps + // calling good until its reader sees the EOF. + let _ = cx.update(crate::ui::tree_sync::resync_after_local_daemon_change); + + let _ = this.update_in(cx, |this, window, cx| { + this.focus_active(window, cx); + cx.notify(); + }); + } + fn set_font_size(&mut self, size: f32, cx: &mut Context) { let size = size.clamp(FONT_SIZE_MIN, FONT_SIZE_MAX); self.font_size = size; @@ -9253,6 +9271,69 @@ mod keybinding_gpui_tests { } } +#[cfg(test)] +mod restart_server_gpui_tests { + use crate::core::config::Config; + use crate::core::session::Session; + use crate::ui::app::Tty7App; + use gpui::{AppContext, TestAppContext}; + + /// Clicking Restart Server made the whole app disappear. + /// + /// The work that puts the window back together after the restart ends by + /// rebuilding every local window from the machine tree, and the first thing + /// that rebuild asks each window is which tabs it is showing — which it + /// reads back out of the window registry. Run from inside `update_in` on + /// this window's own entity, the first window it reaches for is the one the + /// closure already holds leased, and gpui answers a double lease by + /// panicking, which on the main thread is the process. + /// + /// Driven through `settle_after_restart` with a restart that "succeeded", + /// because the crash is in the part that runs either way, not in the + /// restart itself. + #[gpui::test] + async fn settling_after_a_restart_does_not_lease_the_window_twice(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + cx.executor().allow_parking(); + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(Config::default()); + crate::ui::keymap::init(cx); + crate::ui::windows::WindowRegistry::init(cx); + }); + let window = cx.add_window(|window, cx| { + let app = + cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx)); + gpui_component::Root::new(app, window, cx) + }); + let app = window + .update(cx, |root, _, _| { + root.view() + .clone() + .downcast::() + .ok() + .expect("window root wraps a Tty7App") + }) + .unwrap(); + + // Registered the way an opened window registers itself: without this + // the rebuild finds no window to ask and never reaches for the entity, + // which is the whole thing under test. + let handle = window.into(); + let weak = app.downgrade(); + app.update(cx, |app, cx| { + crate::ui::windows::WindowRegistry::register(cx, app.workspace, handle, weak); + }); + + Tty7App::settle_after_restart(app.downgrade(), Ok(()), &mut cx.to_async()).await; + + assert!( + app.update(cx, |app, _| app.startup_error.is_none()), + "a restart reported as successful must not leave an error banner" + ); + } +} + #[cfg(test)] mod shell_menu_gpui_tests { use crate::core::config::Config; From 6e193c404f9fdd506012faaf5f3e9dc4e79ecee6 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Sun, 16 Aug 2026 18:17:59 +0800 Subject: [PATCH 04/33] fix(editor): kill paths one component at a time on ctrl-w (#658) (#659) The built-in command editor intercepts ctrl-w before the shell sees it, and its whitespace-only word boundaries killed a whole path in one stroke. fish binds ctrl-w to backward-kill-path-component, so users coming from kitty or Terminal.app expect /usr/local/bin to go one segment at a time. Mirror fish's path-component word motion: at most one run per character class, separators (slash, equals, quotes, ...) end a kill next to whitespace on their own. alt-backspace keeps the coarse whitespace-delimited kill, matching fish's split between the two chords. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- src/terminal/cmd_editor.rs | 76 ++++++++++++++++++++++++++++++++++++++ src/terminal/view.rs | 2 +- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/terminal/cmd_editor.rs b/src/terminal/cmd_editor.rs index 69f795c2..4b207795 100644 --- a/src/terminal/cmd_editor.rs +++ b/src/terminal/cmd_editor.rs @@ -320,6 +320,50 @@ impl CmdEditor { self.kill_range(self.cursor, end); } + fn eat_left(&mut self, pred: impl Fn(char) -> bool) { + while self.cursor > 0 && pred(self.chars[self.cursor - 1]) { + self.cursor -= 1; + } + } + + /// fish's ⌃W (`backward-kill-path-component`): stop at `/` and friends + /// instead of eating a whole whitespace-delimited token, so a path is + /// killed one component at a time (#658). Mirrors fish's word-motion + /// state machine: at most one run of each character class, and a + /// separator run next to whitespace ends the kill on its own. + pub fn delete_path_component_left(&mut self) { + fn sep(c: char) -> bool { + !c.is_whitespace() && "/={,}'\":@|;<>&".contains(c) + } + fn word(c: char) -> bool { + !c.is_whitespace() && !sep(c) + } + self.checkpoint(); + let end = self.cursor; + let before = |cursor: usize, chars: &[char]| (cursor > 0).then(|| chars[cursor - 1]); + match before(self.cursor, &self.chars) { + Some(c) if c.is_whitespace() => { + self.eat_left(char::is_whitespace); + match before(self.cursor, &self.chars) { + Some('/') => { + self.eat_left(|c| c == '/'); + self.eat_left(word); + } + Some(c) if word(c) => self.eat_left(word), + Some(_) => self.eat_left(sep), + None => {} + } + } + Some(c) if word(c) => self.eat_left(word), + Some(_) => { + self.eat_left(sep); + self.eat_left(word); + } + None => {} + } + self.kill_range(self.cursor, end); + } + pub fn delete_to_start(&mut self) { self.checkpoint(); let end = self.cursor; @@ -452,6 +496,38 @@ mod tests { assert_eq!(d.cursor(), 9); } + #[test] + fn path_component_delete_walks_a_path_one_segment_at_a_time() { + let mut e = ed("ls /usr/local/bin", 17); + e.delete_path_component_left(); + assert_eq!(e.text(), "ls /usr/local/"); + e.delete_path_component_left(); + assert_eq!(e.text(), "ls /usr/"); + e.delete_path_component_left(); + assert_eq!(e.text(), "ls /"); + e.delete_path_component_left(); + assert_eq!(e.text(), "ls "); + e.delete_path_component_left(); + assert_eq!((e.text().as_str(), e.cursor()), ("", 0)); + e.yank(); + assert_eq!(e.text(), "ls "); + + let mut f = ed("--out=/tmp/x", 12); + f.delete_path_component_left(); + assert_eq!(f.text(), "--out=/tmp/"); + f.delete_path_component_left(); + assert_eq!(f.text(), "--out=/"); + f.delete_path_component_left(); + assert_eq!(f.text(), ""); + } + + #[test] + fn path_component_delete_matches_word_delete_on_plain_words() { + let mut e = ed("echo hello world", 16); + e.delete_path_component_left(); + assert_eq!((e.text().as_str(), e.cursor()), ("echo hello ", 11)); + } + #[test] fn kills_fill_the_kill_buffer_and_yank_puts_it_back() { let mut e = ed("git push origin", 15); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 48247876..196f4887 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2251,7 +2251,7 @@ impl TerminalView { } "w" => { if !self.cmd.delete_selection() { - self.cmd.delete_word_left(); + self.cmd.delete_path_component_left(); } } "u" => { From 7b0660bd422e5076d28773e2f5d8a1f1206ba734 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Sun, 16 Aug 2026 18:44:08 +0800 Subject: [PATCH 05/33] fix(sidebar): give the workspace head a width that always resolves (#662) Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- src/ui/tab_sidebar.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index bd9f9cf1..fc916258 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -990,13 +990,17 @@ impl Tty7App { ), ); // The tile inside asks for `w_full`, and a percentage is only a width - // while every box above it has one. This row had none of its own — it - // borrowed the column's by cross-axis stretch — so on any pass that - // sizes the column from its content the chain resolves against nothing - // and the tile falls back to hugging the workspace name. Declaring the - // width here anchors it to the rail, which is a fixed `w(px(width))`. + // while some box above it has a real one. This row used to have none of + // its own and borrowed the column's by cross-axis stretch, which did not + // always hold; `w_full` here swapped that for a second percentage, and a + // row whose width is `Percent` is no longer `auto`, so it lost stretch + // as well — on the passes that size the column from its content there + // was still nothing to resolve against and the tile fell back to hugging + // the workspace name. Hand the row real pixels: the rail is + // `w(px(width))` and layout is border-box, so its content is one pixel + // narrower than that because of the right border. let workspace_head = h_flex() - .w_full() + .w(px(width - 1.)) .flex_shrink_0() .px(px(crate::ui::app::CONTENT_INSET - 7.)) .pt(px(4.)) From 0295a98915d6255081a6ded777eaa60449cf6384 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Sun, 16 Aug 2026 18:53:27 +0800 Subject: [PATCH 06/33] feat(sftp): open remote text files in the built-in editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A click on a file in the SSH Files panel used to start a download; the only way to change a remote file was download, edit, re-upload. Now a click opens it in the built-in editor and Cmd-S saves straight back over the pane's own SFTP channel, matching what the Files panel already does locally and over a remote workspace. - protocol: SftpOp::ReadFile/WriteFile and SftpOpResult::File, bytes as base64; the reply carries the body plus the stat it was read under - daemon: ReadFile enforces the caller's size ceiling before and during the read; WriteFile rewrites in place (truncate, not temp-and-rename) so the file keeps its mode and ownership - SftpHost: a Host over the pane's SFTP route, so the editor's existing open/save path works unchanged; git/search/watch honestly Unsupported - editor: an open buffer holds the host it was read from, and save/reload/dedup/watch key on (host, path) instead of the active host - panel: single click opens (dirs navigate, text files edit), the same gesture as the local tree; binary or oversized files get the local tree's toast, and Download moves to the context menu Review follow-ups, in this PR: the SFTP host stays out of HostRegistry, which means "a machine this window has a link to" and is swept as such — filing the pane's channel there made Cmd-S return silently once a workspace deletion took it back out. The cursor-jump lookup, the status bar's path, and the SCM panel's repository all key on the buffer's own host now. Closes #656. --- crates/tty7-core/src/daemon/protocol.rs | 88 ++++++- crates/tty7-core/src/daemon/ssh/sftp.rs | 62 +++++ docs/remote/sftp.mdx | 16 +- src/terminal/git_data.rs | 6 +- src/ui/code_editor.rs | 95 +++++-- src/ui/file_tree.rs | 9 +- src/ui/i18n/en.rs | 1 + src/ui/i18n/ja.rs | 1 + src/ui/i18n/mod.rs | 1 + src/ui/i18n/zh.rs | 1 + src/ui/mod.rs | 1 + src/ui/sftp.rs | 82 +++++-- src/ui/sftp_host.rs | 314 ++++++++++++++++++++++++ 13 files changed, 623 insertions(+), 54 deletions(-) create mode 100644 src/ui/sftp_host.rs diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 3eda0452..66f91615 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -294,15 +294,50 @@ pub struct SftpEntry { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SftpOp { - Stat { path: String }, - Mkdir { path: String }, - CreateFile { path: String }, - RemoveFile { path: String }, - RemoveDir { path: String }, - Rename { from: String, to: String }, - Chmod { path: String, mode: u32 }, - Readlink { path: String }, - Realpath { path: String }, + Stat { + path: String, + }, + Mkdir { + path: String, + }, + CreateFile { + path: String, + }, + RemoveFile { + path: String, + }, + RemoveDir { + path: String, + }, + Rename { + from: String, + to: String, + }, + Chmod { + path: String, + mode: u32, + }, + Readlink { + path: String, + }, + Realpath { + path: String, + }, + /// Whole-file read, for the built-in editor. `max_bytes` is the reader's + /// own ceiling; a file larger than it answers with an error instead of a + /// truncated body that would later be saved back short. + ReadFile { + path: String, + max_bytes: u64, + }, + /// Whole-file write, in place (truncate + write, no temp-and-rename): the + /// editor saves over a file the user already has open, and replacing the + /// inode would silently drop its mode and ownership. + WriteFile { + path: String, + #[serde(with = "crate::host::b64")] + bytes: Vec, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -312,6 +347,16 @@ pub enum SftpOpResult { Stat(SftpEntry), Link(String), Error(String), + /// Reply to [`SftpOp::ReadFile`]: the bytes, plus the stat they were + /// actually read under. The stat costs nothing — the size check before + /// the read has it in hand either way — and it is the only description of + /// the body that cannot disagree with it, which a separate `Stat` round + /// trip either side of the read can. + File { + entry: SftpEntry, + #[serde(with = "crate::host::b64")] + bytes: Vec, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -1572,6 +1617,20 @@ mod tests { pane_id: 4, op: SftpOp::Realpath { path: ".".into() }, }, + ClientMsg::SftpOp { + pane_id: 4, + op: SftpOp::ReadFile { + path: "/etc/nginx/nginx.conf".into(), + max_bytes: 4 * 1024 * 1024, + }, + }, + ClientMsg::SftpOp { + pane_id: 4, + op: SftpOp::WriteFile { + path: "/home/deploy/笔记.md".into(), + bytes: vec![0x00, 0xff, b'h', b'i'], + }, + }, ClientMsg::SftpTransferStart(SftpTransferSpec { pane_id: 4, kind: SftpTransferKind::Upload, @@ -1741,6 +1800,17 @@ mod tests { permissions: 0o100644, target_is_dir: false, })), + DaemonMsg::SftpOpResult(SftpOpResult::File { + entry: SftpEntry { + name: "nginx.conf".into(), + kind: SftpEntryKind::File, + size: 4, + mtime: 1_700_000_000, + permissions: 0o100644, + target_is_dir: false, + }, + bytes: vec![0x00, 0xff, 0x80, b'!'], + }), DaemonMsg::SftpTransferStarted { job_id: 3 }, DaemonMsg::SftpTransferProgress(vec![SftpJobProgress { job_id: 3, diff --git a/crates/tty7-core/src/daemon/ssh/sftp.rs b/crates/tty7-core/src/daemon/ssh/sftp.rs index 656f0744..1fe3a38a 100644 --- a/crates/tty7-core/src/daemon/ssh/sftp.rs +++ b/crates/tty7-core/src/daemon/ssh/sftp.rs @@ -531,6 +531,68 @@ async fn run_op(sftp: &SftpSession, op: &SftpOp) -> Result .map_err(|e| format!("{e}"))?; SftpOpResult::Link(resolved) } + SftpOp::ReadFile { path, max_bytes } => { + let attrs = sftp + .metadata(path.clone()) + .await + .map_err(|e| format!("{e}"))?; + if attrs.is_dir() { + return Err("is a directory".to_string()); + } + // Checked before the read, not after: a body clipped at the limit + // would round-trip through the editor and be saved back short. + let size = attrs.size.unwrap_or(0); + if size > *max_bytes { + return Err(format!( + "file is {size} bytes, larger than the {max_bytes}-byte limit" + )); + } + let mut file = sftp.open(path.clone()).await.map_err(|e| format!("{e}"))?; + let mut bytes = Vec::with_capacity(size.min(*max_bytes) as usize); + let mut buf = vec![0u8; CHUNK]; + loop { + let n = file.read(&mut buf).await.map_err(|e| format!("{e}"))?; + if n == 0 { + break; + } + bytes.extend_from_slice(&buf[..n]); + // The size the stat reported is a moment old; the file may + // have grown since. The limit holds either way. + if bytes.len() as u64 > *max_bytes { + return Err(format!( + "file grew past the {max_bytes}-byte limit mid-read" + )); + } + } + SftpOpResult::File { + entry: entry_from_attrs(&remote_basename(path), &attrs), + bytes, + } + } + SftpOp::WriteFile { path, bytes } => { + // In place on purpose — truncate and rewrite the same inode. The + // temp-and-rename dance the transfer path does would hand the file + // fresh default permissions and ownership, and this op overwrites + // a file the user just had open in the editor, so its identity is + // worth more than crash-atomicity here. + let flags = OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::TRUNCATE; + let mut file = sftp + .open_with_flags(path.clone(), flags) + .await + .map_err(|e| format!("{e}"))?; + for chunk in bytes.chunks(CHUNK) { + file.write_all(chunk).await.map_err(|e| format!("{e}"))?; + } + file.flush().await.map_err(|e| format!("{e}"))?; + file.shutdown().await.map_err(|e| format!("{e}"))?; + // The fresh stat is the editor's baseline for spotting external + // changes; without it every save would read as a conflict. + let attrs = sftp + .metadata(path.clone()) + .await + .map_err(|e| format!("written, but stat after failed: {e}"))?; + SftpOpResult::Stat(entry_from_attrs(&remote_basename(path), &attrs)) + } }) } diff --git a/docs/remote/sftp.mdx b/docs/remote/sftp.mdx index b7963868..b983b3a1 100644 --- a/docs/remote/sftp.mdx +++ b/docs/remote/sftp.mdx @@ -17,15 +17,23 @@ The panel opens on the remote home directory. **Go to Shell Directory** in the overflow menu jumps it to wherever the pane's shell currently is, which is usually where you actually want to be. -Right-click a row for **Open**, **Follow Symlink**, **Rename**, **chmod…**, and -delete. The overflow menu adds **New Folder**, **New File**, **Upload…**, and -**Refresh**. +Right-click a row for **Edit** (**Open** on a directory), **Download**, +**Follow Symlink**, **Rename**, **chmod…**, and delete. The overflow menu adds +**New Folder**, **New File**, **Upload…**, and **Refresh**. + +## Editing + +Click a text file and it opens in the [built-in +editor](/window/side-panel), the same gesture as the local file tree; saving +writes straight back over the same connection. Files the editor cannot hold — +binary, or over its 4 MB limit — say so instead; right-click → **Download** +for those. ## Transferring | Direction | How | |---|---| -| Download | Drag a file out of the panel into Finder or Explorer | +| Download | Right-click → **Download** | | Upload | **Upload…**, or drag files into the panel | Uploads are written under a temporary name and renamed into place at the end, so diff --git a/src/terminal/git_data.rs b/src/terminal/git_data.rs index 42e4c25c..f69da8ab 100644 --- a/src/terminal/git_data.rs +++ b/src/terminal/git_data.rs @@ -885,7 +885,11 @@ impl Tty7App { return None; } let open = code.active_file()?; - let host = self.spawn_host(cx); + // The file's host, not the window's. They are the same for everything + // the tree can open, but a buffer read over SFTP carries a path from + // another machine, and pairing it with this one's host would resolve + // it against a local repository that merely shares the path. + let host = open.host.id(); let root = cx .try_global::()? .repo_root_for(host, open.path.parent()?)?; diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 086f45d8..150b49b4 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -14,7 +14,7 @@ use gpui_component::{ }; use crate::ui::app::Tty7App; -use crate::ui::host_ops::{HostOps, MTime, SharedHost, WatchSub}; +use crate::ui::host_ops::{HostId, HostOps, MTime, SharedHost, WatchSub}; use crate::ui::i18n::{L10nKey, t, t_fmt}; const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; @@ -23,6 +23,12 @@ const RELOAD_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(20 pub(crate) struct OpenFile { pub(crate) path: PathBuf, + /// The machine `path` lives on, held rather than looked up. Saves, + /// reloads and duplicate detection all key on its id — an SFTP file and a + /// local file can share the string `/etc/hosts` without being the same + /// file — and saving goes straight through this handle, so a buffer stays + /// saveable however the window's own machine has changed underneath it. + pub(crate) host: SharedHost, pub(crate) input: Entity, pub(crate) dirty: bool, disk_mtime: Option, @@ -286,11 +292,24 @@ impl Tty7App { } fn editor_rebuild_watcher(&mut self, cx: &mut Context) { + // Only files on the host the watch itself runs on. A path from + // another machine — an SFTP file, say — does not exist under that + // watcher's feet, and would either miss or, worse, match a local file + // that happens to share its name. + // + // A host that cannot watch therefore gets no external-change + // detection at all: an SFTP buffer will not notice the file changing + // underneath it, and saving overwrites whatever is there. Catching + // that at save time needs a "keep mine" that survives to the next + // save, which the conflict banner does not have yet. + let watch_host = self.spawn_host(cx); let files: HashSet = self .tabs .iter() .filter_map(|t| t.code.as_deref()) - .flat_map(|c| c.files.iter().map(|f| f.path.clone())) + .flat_map(|c| c.files.iter()) + .filter(|f| f.host.id() == watch_host) + .map(|f| f.path.clone()) .collect(); let dirs: HashSet = files .iter() @@ -431,6 +450,7 @@ impl Tty7App { /// than throwing the cursor somewhere it was never meant to go. fn apply_pending_cursor( &mut self, + host: HostId, requested: &Path, opened: &Path, window: &mut Window, @@ -449,10 +469,11 @@ impl Tty7App { let Some((_, line, column)) = self.editor.pending_cursor.take() else { return; }; - let Some(file) = self - .tab_code() - .and_then(|c| c.files.iter().find(|f| f.path == *opened)) - else { + let Some(file) = self.tab_code().and_then(|c| { + c.files + .iter() + .find(|f| f.host.id() == host && f.path == *opened) + }) else { return; }; let input = file.input.clone(); @@ -470,21 +491,34 @@ impl Tty7App { path: &Path, window: &mut Window, cx: &mut Context, + ) { + let Some(host) = self.active_host(cx) else { + return; + }; + self.editor_open_on_host(host, path, window, cx); + } + + /// [`Self::open_file_in_editor`] against an explicit host — the SFTP + /// browser's files live on a host that is never the active one. + pub(crate) fn editor_open_on_host( + &mut self, + host: SharedHost, + path: &Path, + window: &mut Window, + cx: &mut Context, ) { if self.tabs.get(self.active).is_none() { return; } self.raise_code_overlay(); - if self.editor_activate_open(path, window, cx) { + if self.editor_activate_open(host.id(), path, window, cx) { return; } - let Some(host) = self.active_host(cx) else { - return; - }; + let host_id = host.id(); let p = path.to_path_buf(); let requested = p.clone(); HostOps::run_in( - host, + host.clone(), window, cx, move |h| -> Result<(PathBuf, String, Option), String> { @@ -532,11 +566,11 @@ impl Tty7App { }, move |app, opened, window, cx| match opened { Ok((path, text, mtime)) => { - app.editor_install_file(path.clone(), text, mtime, window, cx); + app.editor_install_file(host, path.clone(), text, mtime, window, cx); // Against `requested`, not `path`: the host canonicalised // it on the way through, and a link that named a symlink // would otherwise lose the line it asked for. - app.apply_pending_cursor(&requested, &path, window, cx); + app.apply_pending_cursor(host_id, &requested, &path, window, cx); } Err(message) => window.push_notification(message, cx), }, @@ -545,6 +579,7 @@ impl Tty7App { fn editor_activate_open( &mut self, + host: HostId, path: &Path, window: &mut Window, cx: &mut Context, @@ -552,7 +587,11 @@ impl Tty7App { let Some(code) = self.tab_code_mut() else { return false; }; - let Some(ix) = code.files.iter().position(|f| f.path == *path) else { + let Some(ix) = code + .files + .iter() + .position(|f| f.host.id() == host && f.path == *path) + else { return false; }; code.visible = true; @@ -560,20 +599,22 @@ impl Tty7App { code.files.insert(0, f); code.active = 0; self.focus_editor(window, cx); - self.apply_pending_cursor(path, path, window, cx); + self.apply_pending_cursor(host, path, path, window, cx); cx.notify(); true } fn editor_install_file( &mut self, + host: SharedHost, path: PathBuf, text: String, mtime: Option, window: &mut Window, cx: &mut Context, ) { - if self.editor_activate_open(&path, window, cx) { + let host_id = host.id(); + if self.editor_activate_open(host_id, &path, window, cx) { return; } if self.tabs.get(self.active).is_none() { @@ -604,7 +645,7 @@ impl Tty7App { .iter_mut() .filter_map(|t| t.code.as_deref_mut()) .flat_map(|c| c.files.iter_mut()) - .find(|f| f.path == path) + .find(|f| f.host.id() == host_id && f.path == path) else { return; }; @@ -624,6 +665,7 @@ impl Tty7App { 0, OpenFile { path, + host, input, dirty: false, disk_mtime: mtime, @@ -732,7 +774,9 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - let Some(host) = self.active_host(cx) else { + // The file's own host, not the active one: the buffer keeps pointing + // at the machine it was read from, however the focus has moved since. + let Some(host) = self.editor_file_mut(id).map(|f| f.host.clone()) else { return; }; let Some(f) = self.editor_file_mut(id) else { @@ -927,6 +971,7 @@ impl Tty7App { let Some(host) = self.active_host(cx) else { return; }; + let host_id = host.id(); let p = path.to_path_buf(); let landed = p.clone(); HostOps::run_in( @@ -935,13 +980,14 @@ impl Tty7App { cx, move |h| h.stat(&p).ok().and_then(|m| m.mtime), move |app, mtime, window, cx| { - app.editor_apply_external_change(&landed, mtime, window, cx) + app.editor_apply_external_change(host_id, &landed, mtime, window, cx) }, ); } fn editor_apply_external_change( &mut self, + host: HostId, path: &Path, mtime: Option, window: &mut Window, @@ -954,7 +1000,7 @@ impl Tty7App { continue; }; for (ix, f) in code.files.iter_mut().enumerate() { - if f.path != *path { + if f.host.id() != host || f.path != *path { continue; } match classify_external_change(f.saving.is_some(), f.dirty, f.disk_mtime, mtime) { @@ -991,12 +1037,10 @@ impl Tty7App { return; }; let target = f.path.clone(); + let host = f.host.clone(); let id = f.input.entity_id(); f.reload_seq = f.reload_seq.wrapping_add(1); let seq = f.reload_seq; - let Some(host) = self.active_host(cx) else { - return; - }; HostOps::run_in( host, window, @@ -1185,6 +1229,10 @@ impl Tty7App { } fn render_code_status_bar(&self, _window: &Window, cx: &mut Context) -> gpui::Div { + // The roots below belong to this window's own machine. A file read + // over SFTP is on another one, where they mean nothing, so it shows + // its own full path rather than borrowing the local repo's name. + let tree_host = self.spawn_host(cx); let code = self.tab_code(); let muted = cx.theme().muted_foreground; let path_text: Option = code.map(|c| { @@ -1195,6 +1243,7 @@ impl Tty7App { .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); match c.active_file() { + Some(f) if f.host.id() != tree_host => f.path.display().to_string().into(), Some(f) => { let rel = c .roots diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 6914a308..6a00248c 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1774,9 +1774,12 @@ impl Tty7App { } let sf = cx.global::().popover; - let dirty = self - .tab_code() - .is_some_and(|c| c.files.iter().any(|f| f.dirty && f.path == *path)); + let tree_host = self.spawn_host(cx); + let dirty = self.tab_code().is_some_and(|c| { + c.files + .iter() + .any(|f| f.dirty && f.host.id() == tree_host && f.path == *path) + }); let renaming = matches!( &self.file_tree.editing, diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 6814bf9a..158b27d0 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -896,6 +896,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SftpLoading => "Loading…", L10nKey::SftpEmptyDirectory => "Empty directory.", L10nKey::SftpContextOpen => "Open", + L10nKey::SftpContextEdit => "Edit", L10nKey::SftpContextFollowSymlink => "Follow Symlink", L10nKey::SftpContextRename => "Rename", L10nKey::SftpContextChmod => "chmod…", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 86b7d61d..bd963bad 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -945,6 +945,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SftpLoading => "読み込み中…", L10nKey::SftpEmptyDirectory => "空のディレクトリです", L10nKey::SftpContextOpen => "開く", + L10nKey::SftpContextEdit => "編集", L10nKey::SftpContextFollowSymlink => "シンボリックリンクを辿る", L10nKey::SftpContextRename => "名前を変更", L10nKey::SftpContextChmod => "chmod…", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 9e803afa..7ac2174e 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -657,6 +657,7 @@ l10n_keys! { SftpLoading, SftpEmptyDirectory, SftpContextOpen, + SftpContextEdit, SftpContextFollowSymlink, SftpContextRename, SftpContextChmod, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 183412a4..b6312ea9 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -851,6 +851,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SftpLoading => "加载中…", L10nKey::SftpEmptyDirectory => "空文件夹。", L10nKey::SftpContextOpen => "打开", + L10nKey::SftpContextEdit => "编辑", L10nKey::SftpContextFollowSymlink => "跟随符号链接", L10nKey::SftpContextRename => "重命名", L10nKey::SftpContextChmod => "权限…", diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ee86b958..df5799f4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -33,6 +33,7 @@ pub mod scm; pub mod scrollbar; pub mod settings; pub mod sftp; +pub mod sftp_host; pub mod ssh_connect; pub mod ssh_prompt; pub mod switcher; diff --git a/src/ui/sftp.rs b/src/ui/sftp.rs index e723fd15..7f013199 100644 --- a/src/ui/sftp.rs +++ b/src/ui/sftp.rs @@ -58,6 +58,20 @@ impl SftpRoute { Self { pane_id, workspace } } + /// What makes this route *this* route, for a [`HostId`]. Keyed by pane — + /// two panes into the same server are two routes, because every op is + /// addressed to a pane's SSH connection — and by workspace, because a + /// workspace pane's id was minted by the far daemon and can collide with + /// a local one. + /// + /// [`HostId`]: crate::ui::host_registry::HostId + pub(crate) fn connection_key(&self) -> String { + match &self.workspace { + Some(ws) => format!("sftp:{}:{}", ws.workspace, self.pane_id), + None => format!("sftp:{}", self.pane_id), + } + } + fn workspace_op( &self, op: crate::daemon::protocol::WorkspaceOp, @@ -572,21 +586,42 @@ impl Tty7App { cx.notify(); } - /// The double-click gesture and the row menu's first item both land here. - /// They used to differ: double-click ran a directory-only handler, so - /// double-clicking a file — the gesture every file browser answers by - /// opening it — did nothing at all, with no cursor change or message to - /// say why. A download is visible in the transfers tray and cancellable - /// from it, so the worst case is a click you can take back. - pub(crate) fn sftp_open_entry(&mut self, entry: SftpEntry, cx: &mut Context) { + /// A click on a row and the row menu's first item both land here. + /// A directory opens in place; a file opens in the built-in editor, the + /// way it already does on a local or remote-workspace tree (#656). What + /// the editor cannot hold — binary, oversized — gets the same toast the + /// local tree gives it; a single click must never start a transfer, so + /// downloading lives in the row menu and nowhere else. + pub(crate) fn sftp_open_entry( + &mut self, + entry: SftpEntry, + window: &mut Window, + cx: &mut Context, + ) { let target = remote_join(&self.sftp_panel.cwd, &entry.name); if is_dir_like(&entry) { self.sftp_navigate(target, cx); - } else { - self.sftp_download_entry(entry, cx); + } else if let Some(host) = self.sftp_editor_host() { + self.editor_open_on_host(host, Path::new(&target), window, cx); } } + /// The [`Host`] the editor reads and saves this pane's files through. + /// + /// Handed to the editor rather than filed in `HostRegistry`: that table + /// means "a machine this window has a link to", and its entries are + /// listed as machines and swept when no workspace is left holding one. + /// An SFTP channel borrowed from a pane is neither, so the buffer holds + /// the host itself and stays saveable for as long as it is open. + /// + /// [`Host`]: crate::ui::host_ops::Host + fn sftp_editor_host(&self) -> Option { + self.sftp_panel.open_pane_id?; + Some(std::sync::Arc::new(crate::ui::sftp_host::SftpHost::new( + self.sftp_route(), + ))) + } + pub(crate) fn sftp_download_entry(&mut self, entry: SftpEntry, cx: &mut Context) { let Some(pane_id) = self.sftp_panel.open_pane_id else { return; @@ -1502,8 +1537,14 @@ impl Tty7App { .rounded(cx.theme().radius) .cursor_pointer() .hover(|s| s.bg(list_hover)) - .on_double_click( - cx.listener(move |this, _, _w, cx| this.sftp_open_entry(open_entry.clone(), cx)), + // Single click, the same gesture the local file tree answers — + // this panel used to demand a double click because its open + // action was a download, and that caution outlived the download. + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |this, _, window, cx| { + this.sftp_open_entry(open_entry.clone(), window, cx) + }), ) .child( Icon::new(icon) @@ -1540,17 +1581,30 @@ impl Tty7App { let primary_label = if dir_like { t(L10nKey::SftpContextOpen) } else { - t(L10nKey::Download) + t(L10nKey::SftpContextEdit) }; menu = menu.item(PopupMenuItem::new(primary_label).on_click({ let app = app.clone(); let entry = entry.clone(); - move |_, _window, cx| { + move |_, window, cx| { let entry = entry.clone(); - let _ = app.update(cx, |this, cx| this.sftp_open_entry(entry, cx)); + let _ = app.update(cx, |this, cx| this.sftp_open_entry(entry, window, cx)); } })); + // Editing is the double-click now, but a copy in ~/Downloads is still + // a thing people come to this menu for. + if !dir_like { + menu = menu.item(PopupMenuItem::new(t(L10nKey::Download)).on_click({ + let app = app.clone(); + let entry = entry.clone(); + move |_, _window, cx| { + let entry = entry.clone(); + let _ = app.update(cx, |this, cx| this.sftp_download_entry(entry, cx)); + } + })); + } + if is_symlink { menu = menu.item( PopupMenuItem::new(t(L10nKey::SftpContextFollowSymlink)).on_click({ diff --git a/src/ui/sftp_host.rs b/src/ui/sftp_host.rs new file mode 100644 index 00000000..e90cd133 --- /dev/null +++ b/src/ui/sftp_host.rs @@ -0,0 +1,314 @@ +//! The SFTP browser's file access, shaped like a [`Host`]. +//! +//! The built-in editor speaks `Host` and nothing else — that is how it opens +//! and saves files on the local machine and over a remote workspace. An SSH +//! pane's files come over SFTP instead, which had no `Host`, so double-click +//! could only download (#656). This adapter closes that gap: the file +//! operations map one-to-one onto [`SftpOp`]s, and everything a bare SFTP +//! channel cannot do — git, search, shells, watching — says so honestly +//! instead of pretending. +//! +//! Calls block on a daemon round trip, so they must stay off the UI thread; +//! `HostOps` already guarantees that for every `Host`. + +use std::io; +use std::path::{Path, PathBuf}; + +use tty7_core::host::ShellInventory; + +use crate::daemon::protocol::{SftpEntry, SftpEntryKind, SftpOp, SftpOpResult}; +use crate::daemon::ssh::sftp::remote_parent; +use crate::ui::host_ops::{Entry, Host, HostId, MTime, Meta, Output, SearchHit, WatchSub}; +use crate::ui::sftp::SftpRoute; + +pub(crate) struct SftpHost { + id: HostId, + route: SftpRoute, +} + +impl SftpHost { + pub(crate) fn new(route: SftpRoute) -> Self { + let id = HostId::from_connection_key(&route.connection_key()); + SftpHost { id, route } + } + + fn op(&self, op: SftpOp) -> io::Result { + match self.route.op(op) { + SftpOpResult::Error(e) => Err(sftp_io(e)), + other => Ok(other), + } + } +} + +fn rpath(p: &Path) -> String { + p.to_string_lossy().into_owned() +} + +/// An SFTP error is a string by the time it crosses the daemon socket. The +/// editor's error toasts speak in `io::ErrorKind`, so the two failures a +/// person can act on are picked back out of the text; everything else stays +/// verbatim. +fn sftp_io(msg: String) -> io::Error { + let lower = msg.to_lowercase(); + let kind = if lower.contains("no such file") { + io::ErrorKind::NotFound + } else if lower.contains("permission denied") { + io::ErrorKind::PermissionDenied + } else { + io::ErrorKind::Other + }; + io::Error::new(kind, msg) +} + +fn unsupported(what: &str) -> io::Error { + io::Error::new( + io::ErrorKind::Unsupported, + format!("{what} is not available over SFTP"), + ) +} + +fn unexpected(op: &str) -> io::Error { + io::Error::other(format!("unexpected SFTP reply to {op}")) +} + +fn meta_from_entry(e: &SftpEntry) -> Meta { + Meta { + is_dir: matches!(e.kind, SftpEntryKind::Dir), + is_symlink: matches!(e.kind, SftpEntryKind::Symlink), + len: e.size, + // An absent mtime comes across as 0; epoch-0 files are a curiosity, + // "no answer" is what 0 actually means here. + mtime: (e.mtime != 0).then_some(MTime { + secs: e.mtime as i64, + nanos: 0, + }), + readonly: false, + } +} + +impl Host for SftpHost { + fn id(&self) -> HostId { + self.id + } + + fn separator(&self) -> char { + '/' + } + + fn is_absolute(&self, p: &Path) -> bool { + p.to_string_lossy().starts_with('/') + } + + fn read_dir(&self, dir: &Path, _root: Option<&Path>) -> io::Result> { + let entries = self.route.list(&rpath(dir)).map_err(sftp_io)?; + Ok(entries + .iter() + .map(|e| Entry { + name: e.name.clone(), + is_dir: matches!(e.kind, SftpEntryKind::Dir) + || (matches!(e.kind, SftpEntryKind::Symlink) && e.target_is_dir), + is_symlink: matches!(e.kind, SftpEntryKind::Symlink), + ignored: false, + }) + .collect()) + } + + fn stat(&self, p: &Path) -> io::Result { + match self.op(SftpOp::Stat { path: rpath(p) })? { + SftpOpResult::Stat(entry) => Ok(meta_from_entry(&entry)), + _ => Err(unexpected("Stat")), + } + } + + fn read_file(&self, p: &Path, max_bytes: u64) -> io::Result> { + match self.op(SftpOp::ReadFile { + path: rpath(p), + max_bytes, + })? { + SftpOpResult::File { bytes, .. } => Ok(bytes), + _ => Err(unexpected("ReadFile")), + } + } + + fn canonicalize(&self, p: &Path) -> io::Result { + match self.op(SftpOp::Realpath { path: rpath(p) })? { + SftpOpResult::Link(resolved) => Ok(PathBuf::from(resolved)), + _ => Err(unexpected("Realpath")), + } + } + + fn search( + &self, + _roots: &[PathBuf], + _query: &str, + _limit: usize, + _max_dirs: usize, + _show_hidden: bool, + ) -> io::Result> { + Err(unsupported("search")) + } + + fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result { + match self.op(SftpOp::WriteFile { + path: rpath(p), + bytes: bytes.to_vec(), + })? { + SftpOpResult::Stat(entry) => Ok(meta_from_entry(&entry)), + _ => Err(unexpected("WriteFile")), + } + } + + fn create_file_new(&self, p: &Path) -> io::Result<()> { + self.op(SftpOp::CreateFile { path: rpath(p) })?; + Ok(()) + } + + fn create_dir(&self, p: &Path, recursive: bool) -> io::Result<()> { + let path = rpath(p); + if !recursive { + self.op(SftpOp::Mkdir { path })?; + return Ok(()); + } + // SFTP mkdir has no `-p`; walk down from the root, tolerating every + // prefix that already exists, then let a stat of the full path be the + // judge of whether the walk actually arrived. + let mut prefixes = vec![path.clone()]; + let mut cursor = path.clone(); + loop { + let parent = remote_parent(&cursor); + if parent == cursor || parent == "/" { + break; + } + prefixes.push(parent.clone()); + cursor = parent; + } + for prefix in prefixes.into_iter().rev() { + let _ = self.op(SftpOp::Mkdir { path: prefix }); + } + if self.stat(p)?.is_dir { + Ok(()) + } else { + Err(io::Error::other(format!("{path} is not a directory"))) + } + } + + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { + self.op(SftpOp::Rename { + from: rpath(from), + to: rpath(to), + })?; + Ok(()) + } + + fn remove(&self, p: &Path, recursive: bool) -> io::Result<()> { + // Unlink first. It is the right call for files and for symlinks — + // including a symlink to a directory, which stat would follow and a + // stat-then-recurse would wrongly descend into. Only something unlink + // cannot take down, a real directory, moves on to the next question. + let file_err = match self.op(SftpOp::RemoveFile { path: rpath(p) }) { + Ok(_) => return Ok(()), + Err(e) => e, + }; + match self.stat(p) { + Ok(meta) if meta.is_dir => { + if !recursive { + return Err(unsupported("non-recursive directory removal")); + } + self.op(SftpOp::RemoveDir { path: rpath(p) })?; + Ok(()) + } + _ => Err(file_err), + } + } + + fn repo_root(&self, _p: &Path) -> io::Result> { + Ok(None) + } + + fn git(&self, _cwd: &Path, _args: &[&str]) -> io::Result { + Err(unsupported("git")) + } + + fn shells(&self) -> io::Result { + Err(unsupported("shell discovery")) + } + + fn watch(&self, _dirs: &[PathBuf]) -> io::Result { + Err(unsupported("file watching")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_host_id_is_stable_and_never_local() { + let a = SftpHost::new(SftpRoute::new(4, None)); + let b = SftpHost::new(SftpRoute::new(4, None)); + let c = SftpHost::new(SftpRoute::new(5, None)); + assert_eq!(a.id(), b.id()); + assert_ne!(a.id(), c.id()); + assert!(!a.id().is_local()); + } + + #[test] + fn meta_mapping_reads_kind_size_and_mtime() { + let m = meta_from_entry(&SftpEntry { + name: "notes.md".into(), + kind: SftpEntryKind::File, + size: 42, + mtime: 1_700_000_000, + permissions: 0o100644, + target_is_dir: false, + }); + assert!(!m.is_dir); + assert!(!m.is_symlink); + assert_eq!(m.len, 42); + assert_eq!( + m.mtime, + Some(MTime { + secs: 1_700_000_000, + nanos: 0 + }) + ); + + let dir = meta_from_entry(&SftpEntry { + name: "src".into(), + kind: SftpEntryKind::Dir, + size: 4096, + mtime: 0, + permissions: 0o40755, + target_is_dir: false, + }); + assert!(dir.is_dir); + assert_eq!(dir.mtime, None, "an absent mtime crosses the wire as 0"); + } + + #[test] + fn actionable_failures_get_their_io_kind_back() { + assert_eq!( + sftp_io("2: No such file or directory".into()).kind(), + io::ErrorKind::NotFound + ); + assert_eq!( + sftp_io("3: Permission denied".into()).kind(), + io::ErrorKind::PermissionDenied + ); + let other = sftp_io("I/O: broken pipe".into()); + assert_eq!(other.kind(), io::ErrorKind::Other); + assert_eq!(other.to_string(), "I/O: broken pipe"); + } + + #[test] + fn paths_are_posix_regardless_of_the_local_platform() { + let host = SftpHost::new(SftpRoute::new(1, None)); + assert_eq!(host.separator(), '/'); + assert!(host.is_absolute(Path::new("/home/deploy"))); + assert!(!host.is_absolute(Path::new("relative/path"))); + assert_eq!( + host.join(Path::new("/home/deploy"), "notes.md"), + PathBuf::from("/home/deploy/notes.md") + ); + } +} From 95305d50dd9927d36c712df5c7901ca23b49765b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:05:29 +0800 Subject: [PATCH 07/33] fix(sidebar): give the tab rows a width that resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rows, their group blocks and the scroll area all ask for `w_full`, and a percentage is only a width while some box above it has a real one. The column inside the rail declared `size_full`, which is another percentage: on the passes that size that column from its content there was nothing for any of them to resolve against, so every row fell back to hugging the longest tab name and the active row's capsule stopped well short of the rail's edge. Hand that column real pixels instead. The rail is `w(px(width))` and layout is border-box, so its content is one pixel narrower because of the right border. With a definite width there, the whole chain below resolves — which also makes the same trick on `workspace_head` redundant, though it is left in place as a harmless explicit width. `w_full` on the scroll area itself is the second half: a stretched width sizes it the same but not definitely, and the rows inside need a definite one to be a percentage of. --- src/ui/tab_sidebar.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index fc916258..449523ff 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -145,6 +145,7 @@ impl Tty7App { .track_scroll(&self.sidebar_scroll) .flex_1() .min_h_0() + .w_full() .overflow_y_scroll() .px_1() .py_1p5() @@ -1132,8 +1133,17 @@ impl Tty7App { .border_color(cx.theme().sidebar_border) .child(backing) .child( + // Real pixels, not `size_full`: the rail's own width is a + // definite `px`, but a percentage off it is still a percentage, + // and on the passes that size this column from its content it + // resolves against nothing. Everything below asks for `w_full` + // — the tab rows, their group blocks, the scroll area — so one + // unresolved link here collapsed the whole chain and every row + // fell back to hugging the longest tab name. Border-box takes + // the rail's 1px right border off the content width. v_flex() - .size_full() + .w(px(width - 1.)) + .h_full() .child(crate::ui::app::title_bar_drag( controls.id("sidebar-titlebar-drag"), "sidebar-titlebar-drag", From 9f34cd3501e17f5259ceb267189d9c42fba84c2e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:05:39 +0800 Subject: [PATCH 08/33] fix(editor): stop scrolled-out text painting over the line numbers Bump the gpui-component fork to 070d1a2, which clips the editor's scrolling content to the right of the gutter. Text, selections, indent guides and the cursor all paint from a bounds origin that horizontal scrolling has already shifted left, so scrolled-out content kept painting under the line-number column; the only thing hiding it was the gutter quad painted afterwards, which works only while `editor.gutter.background` is opaque. `apply_theme` clears that key to transparent so the panel can sit on a gradient or image window background without a seam, which is exactly the case the upstream code does not cover. Note that dependency in the theme, so the next person to touch it knows the transparent gutter is not free. --- Cargo.lock | 40 ++++++++++++++++++++-------------------- src/ui/theme.rs | 6 ++++++ 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 238e3015..5d280d30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,7 +298,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -309,7 +309,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2098,7 +2098,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2136,7 +2136,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.8.9", + "libloading 0.7.4", ] [[package]] @@ -2388,7 +2388,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3303,7 +3303,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#809203d5f2519c29b7fd47b4cd6cd06d0005d1aa" +source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#070d1a28cec5130bf7c4c7895683595d488155b8" dependencies = [ "aho-corasick", "anyhow", @@ -3386,7 +3386,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#809203d5f2519c29b7fd47b4cd6cd06d0005d1aa" +source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#070d1a28cec5130bf7c4c7895683595d488155b8" dependencies = [ "anyhow", "gpui", @@ -3400,7 +3400,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#809203d5f2519c29b7fd47b4cd6cd06d0005d1aa" +source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#070d1a28cec5130bf7c4c7895683595d488155b8" dependencies = [ "proc-macro2", "quote", @@ -5196,7 +5196,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5252,7 +5252,7 @@ dependencies = [ "once_cell", "png", "thiserror 2.0.20", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5475,7 +5475,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6894,7 +6894,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7605,7 +7605,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8383,7 +8383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8509,7 +8509,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8911,7 +8911,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9377,7 +9377,7 @@ dependencies = [ "once_cell", "png", "thiserror 2.0.20", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9887,7 +9887,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10731,7 +10731,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -11482,7 +11482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/src/ui/theme.rs b/src/ui/theme.rs index ad6c9466..03b2c0ea 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -830,6 +830,12 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { // a wallpaper image, and any flat colour would show as a seam against it. // The current line and the invisibles become translucent ink for the same // reason — they read correctly on light and dark presets alike. + // + // A transparent gutter is only safe because our gpui-component fork clips + // the editor's scrolling content to the right of the line-number column. + // Upstream leans on the opaque gutter fill to hide horizontally scrolled + // text, so on a stock build clearing this key lets the text run straight + // across the line numbers. let ink: Hsla = rgb(m.foreground).into(); let mut highlight = (*t.highlight_theme).clone(); highlight.style.editor_background = Some(gpui::transparent_black()); From 2d517fa0f30a76071d995990240795f8aa097797 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:48:03 +0800 Subject: [PATCH 09/33] ci: pin the GITHUB_TOKEN to read-only in the CI workflow (#665) --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21ef47c0..5feeab63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,14 @@ on: pull_request: workflow_dispatch: +# Every job here checks out, builds, tests and greps — none of them write +# anything back to the repo, so the token needs nothing but read. The repository +# default is already `read`, but that is a settings toggle anyone with admin can +# flip; declaring it in the file is what actually pins it, and it is what +# CodeQL's `actions/missing-workflow-permissions` asks for. +permissions: + contents: read + # A superseded PR run is dead weight the moment the next push lands, and a run # left going is not free: the account's concurrent-job budget is shared, and # macOS slots are the scarce ones. A zombie Windows job (see the `Test` step's From 89e4ae833dde142e8206e673b6794e64e721d74b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:08:15 +0800 Subject: [PATCH 10/33] fix(terminal): stop hidden panes from repainting the whole window (#670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): stop hidden panes from repainting the whole window Every pane's PTY pump ended a batch with an unconditional window.refresh(), and a pane in a background tab still resolves to its window there — so any hidden pane producing output pinned the visible tab at full frame rate. With 30 tabs, 29 of them chatty in the background, the window repainted at a steady 60 calls/s and the GUI process sat at ~45% CPU with nothing visible changing. Dropping the refresh is not enough: the chrome reads every pane entity while the window draws, so gpui tracks them all and a hidden pane's notify() dirties the window anyway. The pump's Wakeup notify is now gated on a per-pane displayed flag — an Arc outside the entity map, declared each frame by the root render (active tab true, everything else false). Flags default to displayed, so a path that never declares can only cost extra repaints, never a frozen grid. Low-frequency events (title, exit) keep notifying unconditionally so tab chips stay fresh. Same load after the change: ~20 renders/s driven only by the visible pane, ~520 background wakeups/s suppressed, and an idle window with 30 quiet tabs sits at a few renders/s. * test(terminal): pin the output gate's semantics; scope the registry per app The displayed registry moves from a process-wide static into a gpui Global. Entity ids are only unique within one App, and parallel gpui tests each mint their own App with colliding id sequences — through a static, one test's frame declarations could flip another test's pane flags. The shipped binary runs exactly one App, so behavior there is unchanged. Three tests now hold the gate to its contract: the active tab's panes count as displayed and a tab switch hands the frame loop over; a pane nobody declared (and an id nobody registered) errs toward displayed, because the failure direction that matters is a visible pane that stops repainting; and a released pane's flag does not outlive it. Also restores touch_active_tab's doc comment, which the previous commit had accidentally fused onto declare_displayed_panes. --- src/terminal/view.rs | 92 +++++++++++++++++++++++++++----- src/ui/app.rs | 124 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 12 deletions(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 196f4887..59524cdb 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -4,9 +4,9 @@ use alacritty_terminal::index::{Column, Direction, Line, Point, Side}; use alacritty_terminal::selection::{Selection, SelectionType}; use alacritty_terminal::term::TermMode; use gpui::{ - App, ClipboardEntry, ClipboardItem, Context, ExternalPaths, FocusHandle, Focusable, Font, - KeyDownEvent, Modifiers, MouseButton, MouseDownEvent, Pixels, ScrollDelta, ScrollWheelEvent, - WeakEntity, Window, actions, div, prelude::*, px, + App, ClipboardEntry, ClipboardItem, Context, EntityId, ExternalPaths, FocusHandle, Focusable, + Font, KeyDownEvent, Modifiers, MouseButton, MouseDownEvent, Pixels, ScrollDelta, + ScrollWheelEvent, WeakEntity, Window, actions, div, prelude::*, px, }; use gpui_component::kbd::Kbd; use gpui_component::menu::{ContextMenuExt, PopupMenuItem}; @@ -36,6 +36,52 @@ use crate::ui::i18n::{L10nKey, t, t_fmt}; const GRID_PAD_X: f32 = 8.; const GRID_PAD_Y: f32 = 4.; +/// Which panes are on screen right now, readable without touching the entity +/// map. The chrome (tab strip, sidebar, switcher) reads every pane entity +/// while the window draws, so gpui tracks them all and `notify()` from a +/// hidden pane still dirties the window — this flag is the out-of-band answer +/// the output pump consults instead. `Tty7App::render` declares it each frame +/// for its own tabs; a pane nobody has declared yet counts as displayed, so a +/// missed path can only cost extra repaints, never a frozen grid. +/// +/// A gpui `Global` rather than a `static`: entity ids are only unique within +/// one `App`, and parallel `#[gpui::test]` apps mint colliding ids — a +/// process-wide map would let one test's declarations flip another's flags. +/// In the shipped binary there is exactly one `App`, so the two are the same. +#[derive(Default)] +struct DisplayedRegistry( + std::sync::Mutex< + std::collections::HashMap>, + >, +); + +impl gpui::Global for DisplayedRegistry {} + +pub fn declare_displayed(cx: &App, panes: impl IntoIterator) { + // No registry means no pane has ever been built in this app. + let Some(registry) = cx.try_global::() else { + return; + }; + let map = registry.0.lock().unwrap(); + for (id, on) in panes { + if let Some(flag) = map.get(&id) { + flag.store(on, std::sync::atomic::Ordering::Relaxed); + } + } +} + +/// What the registry holds for `id`: `None` when the pane never registered +/// (or already released), otherwise the flag the output gate would consult. +#[cfg(all(test, unix))] +pub(crate) fn displayed_for_test(cx: &App, id: EntityId) -> Option { + cx.try_global::()? + .0 + .lock() + .unwrap() + .get(&id) + .map(|flag| flag.load(std::sync::atomic::Ordering::Relaxed)) +} + actions!( terminal, [ @@ -160,6 +206,9 @@ pub struct TerminalView { /// "preparation failed" — see [`staging_cache`]. remote_clipboard_dir: Option, pub focus_handle: FocusHandle, + /// See [`displayed_registry`]. Shared with the registry so the app can + /// flip it during a draw without an entity access. + displayed: std::sync::Arc, pub font: Font, pub font_bold: Option, pub font_italic: Option, @@ -1110,6 +1159,12 @@ impl TerminalView { while let Ok(ev) = events.try_recv() { batch.push(ev); } + // Output reaches the screen through `handle_event`'s + // `notify()`, which gpui scopes to windows currently + // rendering this view. A pane in a background tab must stay + // out of the frame loop entirely: refreshing the window + // directly from here pinned the visible tab at full frame + // rate whenever any hidden pane was producing output. let res = this.update(cx, |view, cx| { let mut woke = false; for ev in batch.drain(..) { @@ -1118,14 +1173,9 @@ impl TerminalView { } view.handle_event(ev, cx); } - woke }); - let woke = match res { - Ok(woke) => woke, - Err(_) => break, - }; - if woke { - let _ = this.update_in(cx, |_, window, _| window.refresh()); + if res.is_err() { + break; } } }) @@ -1190,7 +1240,18 @@ impl TerminalView { }) .detach(); - cx.on_release_in(window, |view, window, cx| { + let displayed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let entity_id = cx.entity().entity_id(); + cx.default_global::() + .0 + .lock() + .unwrap() + .insert(entity_id, displayed.clone()); + + cx.on_release_in(window, move |view, window, cx| { + if let Some(registry) = cx.try_global::() { + registry.0.lock().unwrap().remove(&entity_id); + } view.terminal.detach_link(); for image in view.terminal.images().take_for_release() { cx.drop_image(image, Some(window)); @@ -1221,6 +1282,7 @@ impl TerminalView { ssh_spec: None, remote_clipboard_dir: None, focus_handle, + displayed, font, font_bold, font_italic, @@ -1652,7 +1714,13 @@ impl TerminalView { AlacEvent::Wakeup => { // The grid moved under whatever the search bar last measured. self.note_output_under_search(cx); - cx.notify(); + // Only a pane that is on screen repaints on output. The + // chrome's per-frame entity reads keep every pane in the + // window's tracked set, so an ungated notify from a + // background tab would dirty the window at output rate. + if self.displayed.load(std::sync::atomic::Ordering::Relaxed) { + cx.notify(); + } } AlacEvent::Title(title) => self.set_title_when_settled(title, cx), AlacEvent::ResetTitle => self.set_title_when_settled(self.default_title.clone(), cx), diff --git a/src/ui/app.rs b/src/ui/app.rs index f618b2d3..b9f6203e 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -3924,6 +3924,25 @@ impl Tty7App { self.activate(index, window, cx); } + /// Declare to the pane registry which terminals are on screen this + /// frame: the active tab's, nobody else's. Stated per frame rather than + /// maintained at every tab operation, the way `scm_sync_watchers` is. + /// Entity ids only — reading the entities here would put them into the + /// window's tracked set, which is exactly what the registry routes + /// around. + fn declare_displayed_panes(&self, cx: &App) { + let active = self.active; + crate::terminal::view::declare_displayed( + cx, + self.tabs.iter().enumerate().flat_map(|(i, tab)| { + tab.pane + .leaves() + .into_iter() + .filter_map(move |slot| Some((slot.terminal()?.entity_id(), i == active))) + }), + ); + } + /// Stamps whichever tab is active right now. Called once per frame rather /// than from the ten places that assign `self.active` — it is idempotent, /// so the stamp only advances on the first frame after a switch. @@ -6825,6 +6844,7 @@ impl Render for Tty7App { window.set_rem_size(px(cx.global::().ui_font_size)); self.claim_pending_tab(window, cx); self.touch_active_tab(); + self.declare_displayed_panes(cx); self.scm_sync_watchers(window, cx); if cx.has_active_drag() { crate::ui::reorder::clear_pending(&self.reorder); @@ -9525,6 +9545,110 @@ mod rename_gpui_tests { } } +// The output gate: a pane repaints on PTY output only while it is on screen. +// `Tty7App::render` declares the active tab's panes displayed each frame and +// everything else hidden; a pane nobody has declared — or whose id nobody +// registered — must err toward displayed, because the failure direction that +// matters is a visible pane that stops repainting. +#[cfg(all(test, unix))] +mod displayed_gpui_tests { + use gpui::TestAppContext; + + use crate::terminal::view::{declare_displayed, displayed_for_test, quiet_test_pane}; + use crate::ui::app::test_window::harness_with_tabs; + + fn pane_id(app: &super::Tty7App, tab: usize) -> gpui::EntityId { + app.tabs[tab] + .pane + .first_leaf() + .expect("tab has a pane") + .entity_id() + } + + #[gpui::test] + fn the_output_gate_follows_the_active_tab(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + app.update_in(&mut vcx, |app, window, cx| { + let (front, back) = (pane_id(app, 0), pane_id(app, 1)); + + app.declare_displayed_panes(cx); + assert_eq!( + displayed_for_test(cx, front), + Some(true), + "the active tab's pane repaints on output" + ); + assert_eq!( + displayed_for_test(cx, back), + Some(false), + "a background tab's pane stays out of the frame loop" + ); + + app.activate(1, window, cx); + app.declare_displayed_panes(cx); + assert_eq!(displayed_for_test(cx, front), Some(false)); + assert_eq!( + displayed_for_test(cx, back), + Some(true), + "switching tabs hands the frame loop to the new tab" + ); + }); + } + + #[gpui::test] + fn a_pane_nobody_declared_counts_as_displayed(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + let _orphan = app.update_in(&mut vcx, |app, window, cx| { + // A pane that exists but sits in no tab — the shape of every + // "path that never declares": it registers as displayed and a + // declaration pass over the app's own tabs leaves it alone. + let (view, stream) = quiet_test_pane(99, window, cx); + let orphan = view.entity_id(); + assert_eq!( + displayed_for_test(cx, orphan), + Some(true), + "a fresh pane defaults to displayed" + ); + + app.declare_displayed_panes(cx); + assert_eq!( + displayed_for_test(cx, orphan), + Some(true), + "declaring only speaks about panes the app holds" + ); + + // An id nobody registered is declared into the void, not + // inserted: the registry only ever holds live panes' flags. + let ghost = gpui::EntityId::from(u64::MAX); + declare_displayed(cx, [(ghost, false)]); + assert_eq!(displayed_for_test(cx, ghost), None); + + (view, stream) + }); + } + + #[gpui::test] + fn a_released_pane_leaves_the_registry(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + let back = app.update_in(&mut vcx, |app, _window, _cx| { + let back = pane_id(app, 1); + app.tabs.remove(1); + back + }); + vcx.background_executor.run_until_parked(); + + app.update_in(&mut vcx, |_, _, cx| { + assert_eq!( + displayed_for_test(cx, back), + None, + "a closed pane's flag does not outlive it" + ); + }); + } +} + // Zoom is a tab's view state: it rides with the tab across a switch, while a // layout change (drag, split, close) still clears it. #[cfg(all(test, unix))] From 3dc63e2d873a2485b510d5acc6560ef1be184eb4 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:11:16 +0800 Subject: [PATCH 11/33] fix(daemon): find and reap a seat-holding daemon that lost both its names (#671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A daemon can survive quit-and-stop with its endpoint unlinked and its pidfile gone while still holding the singleton seat (#667). Every later launch then spawns a daemon that stands down against the lock and times out red, and nothing on the machine can recover: stop answers "not running", ensure_running reaps only through the pidfile, and flock cannot say who the holder is. Two roads led there, and both are closed: - The reap identified a daemon by proc_pidpath alone, which fails outright for a live process whose binary was deleted — every nightly update replacing the installation. The identity check now falls back to the kernel's comm name (proc_name on macOS, /proc/pid/comm on Linux, both recorded at exec and immune to deletion), strips Linux's " (deleted)" marker, and — decisively — no longer deletes the pidfile of a live process it cannot identify: the record was the only handle left on the survivor. - When the pidfile is gone entirely, the pid the claimant now writes into daemon.lock at claim time is the handle of last resort. The lock file is never deleted and holding the flock is the definition of being the server, so while the seat is held its content names the holder; stop() and the reap fall back to it, and a confirmed reap clears the record (only under a momentarily-free seat) so a stale number cannot outlive its process. Unix-only: the Windows seat is share_mode(0), unreadable while held. Every road back now clears a stranded seat, not just the GUI's: ensure_running's stale cleanup is factored into spawn::reap_stranded, tty7 server start runs it too, and tty7 server stop no longer takes "nobody answered" for "nothing to stop" when the seat is still held. A short grace keeps the reap away from a daemon that is merely mid-handoff or mid-startup — where health is an answered handshake, never a bare connect: a wedged daemon's listener still completes connections out of the kernel's backlog. The startup-timeout errors name the seat-holding pid, with the kill advice identity-gated so a stale record never tells anyone to kill an innocent process. Two liveness corrections round it out: a zombie now reads as dead — it answers kill(pid, 0) like the living but holds no lock and no image, and no signal can end it, so counting it alive spent both reap timeouts on a corpse (the GUI never waits on the daemons it spawns, so crashed daemons are zombies as a rule) — and stop() only pays the process-exit wait for a shutdown it actually delivered, instead of watching an unreached survivor not move for five seconds. The guard tests were each verified to fail against the behavior they pin (fallbacks, the handshake criterion, the grace, and the wait gate removed by mutation) before being trusted green; the zombie probe semantics (proc_pidinfo failing for a zombie that still answers signal 0) were measured, not assumed. --- crates/tty7-cli/src/server.rs | 35 ++- crates/tty7-core/src/daemon/singleton.rs | 185 ++++++++++++ crates/tty7-core/src/daemon/spawn.rs | 370 ++++++++++++++++++++--- crates/tty7-server/tests/daemon_reap.rs | 307 +++++++++++++++++++ crates/tty7-server/tests/daemon_stop.rs | 41 ++- 5 files changed, 876 insertions(+), 62 deletions(-) create mode 100644 crates/tty7-server/tests/daemon_reap.rs diff --git a/crates/tty7-cli/src/server.rs b/crates/tty7-cli/src/server.rs index 1f582dc5..0cfd3078 100644 --- a/crates/tty7-cli/src/server.rs +++ b/crates/tty7-cli/src/server.rs @@ -40,6 +40,10 @@ pub fn start() -> Result { json!({ "started": false, "running": true }), ); } + // Nobody answered, so whatever is recorded or still holding the server + // seat is a stranded process (#667) — left alone it would make the spawn + // below stand down and time out with nothing to show for it. + spawn::reap_stranded(); let exe = server_exe()?; let mut cmd = Command::new(&exe); cmd.arg("--daemon"); @@ -72,9 +76,13 @@ pub fn start() -> Result { } Err(_) => "its state could not be checked, so it was left alone", }; + // A spawn that exited cleanly did so because it stood down + // against a seat holder — without naming it, the message points + // at nothing anyone can act on (#667). bail!( - "{} (pid {pid}) did not open its endpoints within {START_TIMEOUT:?} — {fate}", - exe.display() + "{} (pid {pid}) did not open its endpoints within {START_TIMEOUT:?} — {fate}{}", + exe.display(), + spawn::seat_holder_note() ); } std::thread::sleep(POLL_INTERVAL); @@ -87,9 +95,28 @@ pub fn start() -> Result { pub fn stop() -> Result { if !running() { + // Answering nothing is not the same as being gone: a stranded server + // (#667) still holds the seat, and stop is the verb people reach for + // to clear it. Only a free seat means there is truly nothing to stop. + let Some(stranded) = tty7_core::daemon::singleton::holder_pid() else { + return report( + "the server is not running", + json!({ "stopped": false, "running": false }), + ); + }; + spawn::stop(); + // Compared against the pid, not mere occupancy: a fresh daemon may + // legitimately claim the freed seat in this very window, and it is + // not the thing this command failed to stop. + if tty7_core::daemon::singleton::holder_pid() == Some(stranded) { + bail!( + "the stranded server could not be reaped{}", + spawn::seat_holder_note() + ); + } return report( - "the server is not running", - json!({ "stopped": false, "running": false }), + format!("stopped a stranded server (pid {stranded})"), + json!({ "stopped": true, "stranded": true, "pid": stranded }), ); } spawn::stop(); diff --git a/crates/tty7-core/src/daemon/singleton.rs b/crates/tty7-core/src/daemon/singleton.rs index a8018498..2edd7e8e 100644 --- a/crates/tty7-core/src/daemon/singleton.rs +++ b/crates/tty7-core/src/daemon/singleton.rs @@ -104,6 +104,10 @@ pub unsafe fn adopt(fd: std::os::fd::RawFd) -> Singleton { } let file = unsafe { ::from_raw_fd(fd) }; note_held(&file); + // The exec kept the pid, so this rewrites the same number — done anyway, + // so the recorded pid stays an invariant of holding the seat rather than + // a property of how the seat was acquired. + record_holder_pid(&file); Singleton { _file: file } } @@ -118,6 +122,7 @@ pub fn claim() -> Claim { match open_exclusive(&path) { Ok(Some(file)) => { note_held(&file); + record_holder_pid(&file); Claim::Held(Singleton { _file: file }) } Ok(None) => Claim::Taken, @@ -125,6 +130,115 @@ pub fn claim() -> Claim { } } +/// Writes this process's pid into the lock file it holds. +/// +/// The lock file is the one name for the server that nothing ever deletes, so +/// the pid in it is the handle of last resort: a daemon that unlinked its +/// endpoint and lost its pidfile (#667) is otherwise unfindable — `flock` can +/// say the seat is taken but not by whom — and every later launch stands down +/// against a process nobody can name or reap. +fn record_holder_pid(file: &File) { + use std::io::{Seek as _, Write as _}; + + let mut f = file; + let write = file + .set_len(0) + .and_then(|()| f.seek(std::io::SeekFrom::Start(0))) + .and_then(|_| f.write_all(std::process::id().to_string().as_bytes())) + .and_then(|()| f.sync_data()); + if let Err(e) = write { + log::warn!("could not record this server's pid in its lock file: {e}"); + } +} + +/// The pid of the process currently holding this config dir's server seat, or +/// `None` when the seat is free (or was never held by a build that records +/// pids). For the reap paths: when the pidfile is gone, this is the only way +/// left to name the survivor. +/// +/// Probing takes the lock for a moment when it turns out to be free, so a +/// server claiming in exactly that window is told `Taken` and stands down. +/// On the reap paths a spawn follows and serves instead, so the outcome is +/// the same either way; on the error-message paths nothing follows, and a +/// claimant colliding with the probe would be lost — accepted, because that +/// claimant is a stray arriving microseconds after its launcher already gave +/// up a multi-second wait on it. +#[cfg(unix)] +pub fn holder_pid() -> Option { + use std::os::unix::io::AsRawFd as _; + + let path = lock_path()?; + // No `create`: a lock file that does not exist has never had a holder. + let file = File::options().read(true).open(&path).ok()?; + loop { + // LOCK_SH is enough to conflict with a holder's LOCK_EX while keeping + // the probe as weak as possible. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } == 0 { + unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) }; + return None; + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::EWOULDBLOCK) => break, + Some(libc::EINTR) => continue, + _ => return None, + } + } + // Read only once the seat is known to be held: the content then names the + // holder, because every holder writes its pid the moment it claims. A + // pre-recording build holding the seat left older content or none; the + // caller's identity check on the pid is what keeps a stale number from + // reaping an innocent process. + let contents = std::fs::read_to_string(&path).ok()?; + contents.trim().parse::().ok().filter(|&pid| pid > 1) +} + +/// Truncates the recorded pid — only when nobody holds the seat. +/// +/// For the reap, after it confirms the recorded process is gone: the content +/// would otherwise keep naming the dead holder forever, and a later +/// pre-recording build holding the seat over it would make that number — by +/// then possibly reused for an unrelated process — read as the holder. A +/// claim that lands before this does wins the flock, and the truncation is +/// skipped rather than erasing the new holder's record. +/// +/// Like [`holder_pid`]'s probe, this holds the lock for a moment, so a claim +/// colliding with it is told `Taken` — accepted for the same reason: every +/// caller is a reap that has just confirmed the seat's holder dead, and a +/// spawn follows on each of those paths. +#[cfg(unix)] +pub fn clear_record_if_free() { + use std::os::unix::io::AsRawFd as _; + + let Some(path) = lock_path() else { return }; + let Ok(file) = File::options().write(true).open(&path) else { + return; + }; + loop { + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + let _ = file.set_len(0); + unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) }; + return; + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::EINTR) => continue, + _ => return, + } + } +} + +#[cfg(not(unix))] +pub fn clear_record_if_free() {} + +#[cfg(not(unix))] +pub fn holder_pid() -> Option { + // The Windows seat is `share_mode(0)`: while it is held the file cannot + // even be opened to read a pid out of. The Windows reap therefore still + // has only the pidfile to act on — a seat-holding survivor whose pidfile + // is gone stays unfindable there, so the #667 recovery is unix-only for + // now. + None +} + /// `Ok(Some(file))` when the lock is ours, `Ok(None)` when someone else holds /// it, `Err` when the question could not be put to the kernel at all. #[cfg(unix)] @@ -285,6 +399,77 @@ mod tests { ); } + /// The pid in the lock file is the reap's handle of last resort (#667): + /// held means it names the holder, free means it names nobody. + #[cfg(unix)] + #[test] + fn the_held_seat_names_its_holder_and_a_free_seat_names_nobody() { + let (dir, _guard) = pin_dir("holder-pid"); + let seat = match claim_within(PATIENCE) { + Claim::Held(s) => s, + other => panic!("the claim must be granted, got {other:?}"), + }; + assert_eq!( + std::fs::read_to_string(dir.join("daemon.lock")) + .unwrap() + .trim(), + std::process::id().to_string(), + "claiming records the holder's pid in the lock file" + ); + assert_eq!( + holder_pid(), + Some(std::process::id()), + "while the seat is held, the probe names the holder" + ); + drop(seat); + // A forked neighbour can keep the released seat referenced for a + // moment (see `claim_within`); what matters is that the answer + // becomes "nobody", not the microsecond it does. + let deadline = std::time::Instant::now() + PATIENCE; + while holder_pid().is_some() { + assert!( + std::time::Instant::now() < deadline, + "a released seat must stop naming a holder" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + + /// Clearing the record is fenced by the seat itself: a live holder's pid + /// must survive it, a dead holder's must not — stale content is what + /// could one day send a reap after a reused pid. + #[cfg(unix)] + #[test] + fn clearing_the_record_spares_a_live_holder_and_erases_a_dead_one() { + let (dir, _guard) = pin_dir("clear-record"); + let path = dir.join("daemon.lock"); + let seat = match claim_within(PATIENCE) { + Claim::Held(s) => s, + other => panic!("the claim must be granted, got {other:?}"), + }; + clear_record_if_free(); + assert_eq!( + std::fs::read_to_string(&path).unwrap().trim(), + std::process::id().to_string(), + "a held seat's record must survive the clear" + ); + drop(seat); + // A forked neighbour can keep the seat referenced briefly; keep + // asking until the clear lands. + let deadline = std::time::Instant::now() + PATIENCE; + loop { + clear_record_if_free(); + if std::fs::read_to_string(&path).unwrap().is_empty() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "a free seat's stale record must be cleared" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + #[test] fn a_lock_left_behind_by_a_dead_holder_is_claimable() { let (dir, _guard) = pin_dir("stale"); diff --git a/crates/tty7-core/src/daemon/spawn.rs b/crates/tty7-core/src/daemon/spawn.rs index f656349c..3ff94139 100644 --- a/crates/tty7-core/src/daemon/spawn.rs +++ b/crates/tty7-core/src/daemon/spawn.rs @@ -273,17 +273,7 @@ pub fn ensure_running() -> anyhow::Result<()> { } if stale { - reap_recorded_daemon(None); - - if transport::endpoint_exists() { - transport::remove_stale_endpoint(); - } - // The daemon's control listener probes control.port for a live - // predecessor before binding; a stale file would cost it the same - // refused-connect delay the probe above just skipped. The recorded - // daemon is known dead here, so the file cannot be live. - #[cfg(windows)] - crate::host::server::remove_control_endpoint(); + reap_stranded(); } // While an installer is replacing the installation, spawning a daemon @@ -321,15 +311,110 @@ pub fn ensure_running() -> anyhow::Result<()> { } if Instant::now() >= deadline { anyhow::bail!( - "daemon did not start listening at {} within {:?}", + "daemon did not start listening at {} within {:?}{}", transport::endpoint_display(), - STARTUP_TIMEOUT + STARTUP_TIMEOUT, + seat_holder_note() ); } std::thread::sleep(POLL_INTERVAL); } } +/// Clears whatever is left of a server a connect probe already failed to +/// reach: reaps the recorded (or seat-holding, #667) process and removes the +/// endpoint files a fresh spawn would otherwise stand down against or pay a +/// refusal delay on. +/// +/// A healthy server is the caller's to detect first — everything here acts on +/// the premise that nobody answered. +pub fn reap_stranded() { + // A seat holder mid-handoff or mid-startup — claimed, not yet listening — + // looks exactly like a stranded one from out here, and it may be carrying + // every live session across an exec. Give it a moment to open its + // endpoint before concluding it never will; a genuinely stranded holder + // costs this wait once and then gets reaped. + // + // Health is an *answered handshake*, never a bare connect: a wedged + // daemon's listener still completes connections out of the kernel's + // backlog, and callers on the Unresponsive path have already proven that + // connecting says nothing. A holder that connects but will not answer is + // the reap's subject, not its exception — waiting out the rest of the + // grace on it would only delay what its silence already decided. + + if crate::daemon::singleton::holder_pid().is_some() { + let deadline = Instant::now() + STRANDED_GRACE; + while Instant::now() < deadline { + if let Ok(mut stream) = transport::connect() { + match query_daemon_version(&mut stream) { + VersionProbe::Speaks(_) | VersionProbe::Legacy => return, + VersionProbe::Unresponsive => break, + } + } + std::thread::sleep(POLL_INTERVAL); + } + } + + reap_recorded_daemon(None); + + if transport::endpoint_exists() { + transport::remove_stale_endpoint(); + } + // The daemon's control listener probes control.port for a live + // predecessor before binding; a stale file would cost it the same + // refused-connect delay the reap above just made unnecessary. + #[cfg(windows)] + crate::host::server::remove_control_endpoint(); +} + +/// How long a seat holder that is not answering yet gets to be a daemon +/// mid-handoff or mid-startup rather than a stranded one. +const STRANDED_GRACE: Duration = Duration::from_secs(1); + +/// Names the process still holding the server seat, for the startup-timeout +/// errors: a spawned daemon that stood down against a survivor used to time +/// out with a message that pointed at nothing (#667). Empty when the seat is +/// free — the timeout is then genuinely about a slow or crashed start. +/// +/// The kill advice is identity-gated: the recorded pid can outlive the +/// process it named (a pre-recording build holding the seat over an older +/// number), and an ungated message would be telling the user to kill +/// whatever process the OS reuses that pid for. +pub fn seat_holder_note() -> String { + let Some(pid) = crate::daemon::singleton::holder_pid() else { + return String::new(); + }; + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + if !process_alive(pid as libc::pid_t) { + return format!( + "; the server seat is still held, but its recorded pid {pid} is no longer \ + alive — find the holder of daemon.lock before killing anything" + ); + } + return match process_identity(pid as libc::pid_t) { + ProcessIdentity::OurDaemon => format!( + "; the server seat is still held by pid {pid}, which could not be reaped — \ + `kill {pid}` and retry" + ), + ProcessIdentity::Foreign => format!( + "; the server seat is still held, but its recorded pid {pid} now names an \ + unrelated process — find the holder of daemon.lock before killing anything" + ), + // Alive, seat held, executable unreadable: most likely this *is* + // the stranded server — saying otherwise would talk the user out + // of the one action that frees the seat. + ProcessIdentity::Unknown => format!( + "; the server seat is still held by pid {pid}, whose executable can no \ + longer be read — likely a stranded server; check it with `ps -p {pid}` \ + before killing it" + ), + }; + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + format!("; the server seat is still held (recorded pid {pid})") +} + fn query_daemon_version(stream: &mut transport::Stream) -> VersionProbe { use std::io::Write as _; @@ -468,9 +553,10 @@ pub fn hand_off() -> anyhow::Result<()> { } if Instant::now() >= deadline { anyhow::bail!( - "the daemon did not start listening again at {} within {:?}", + "the daemon did not start listening again at {} within {:?}{}", transport::endpoint_display(), - STARTUP_TIMEOUT + STARTUP_TIMEOUT, + seat_holder_note() ); } std::thread::sleep(POLL_INTERVAL); @@ -485,12 +571,18 @@ pub fn stop() { // between them is exactly where an installer starts replacing files that // are still locked — and an old build's shutdown still deletes the pidfile // before the process is gone, so this is the last moment the pid is - // guaranteed readable. - let recorded = pidfile::read().filter(|&pid| pid > 4 && pid != std::process::id()); + // guaranteed readable. When the pidfile is already gone (#667), the pid + // recorded in the singleton lock file is the remaining name for a + // survivor that is still holding the seat. + let recorded = pidfile::read() + .or_else(crate::daemon::singleton::holder_pid) + .filter(|&pid| pid > 4 && pid != std::process::id()); + let mut asked = false; if let Ok(mut stream) = transport::connect() { if ClientMsg::Shutdown.encode(&mut stream).is_ok() { let _ = stream.flush(); + asked = true; let deadline = Instant::now() + SHUTDOWN_TIMEOUT; while Instant::now() < deadline && transport::connect().is_ok() { std::thread::sleep(POLL_INTERVAL); @@ -498,7 +590,13 @@ pub fn stop() { } } - if let Some(pid) = recorded + // Only a shutdown that was actually delivered earns this wait: it is the + // time a daemon that stopped listening gets to finish releasing its + // image. A daemon nobody could even connect to was never asked to die — + // waiting on it is five seconds spent watching a survivor not move + // (#667); the reap below has its own graceful SIGTERM window. + if asked + && let Some(pid) = recorded && !wait_for_recorded_exit(pid, PROCESS_EXIT_TIMEOUT) { log::warn!("daemon pid {pid} released its endpoint but has not exited yet"); @@ -543,33 +641,115 @@ fn wait_for_recorded_exit(_pid: u32, _timeout: Duration) -> bool { } /// `recorded` is a pid the caller captured before asking the daemon to die; -/// the pidfile is only the fallback, because a shutdown that stalled after its -/// cleanup may have already deleted it. +/// the pidfile is the first fallback, because a shutdown that stalled after +/// its cleanup may have already deleted it — and the pid in the singleton +/// lock file is the last one, for the #667 state where the pidfile is gone +/// entirely but a survivor still holds the seat. #[cfg(any(target_os = "macos", target_os = "linux"))] fn reap_recorded_daemon(recorded: Option) { - let Some(pid) = recorded.or_else(pidfile::read) else { + let Some(pid) = recorded + .or_else(pidfile::read) + .or_else(crate::daemon::singleton::holder_pid) + else { return; }; if pid <= 1 || pid == std::process::id() { - pidfile::remove(); + clear_daemon_records(); return; } - if process_matches_daemon_exe(pid as libc::pid_t) { - log::warn!("reaping unreachable daemon (pid {pid}); its sessions will be hung up"); - if !reap_process(pid as libc::pid_t) { - // The pid is the only handle left on the survivor; keep the file - // so the next attempt still has someone to reap. + if !process_alive(pid as libc::pid_t) { + clear_daemon_records(); + return; + } + match process_identity(pid as libc::pid_t) { + ProcessIdentity::OurDaemon => { + log::warn!("reaping unreachable daemon (pid {pid}); its sessions will be hung up"); + if !reap_process(pid as libc::pid_t) { + // The pid is the only handle left on the survivor; keep the + // file so the next attempt still has someone to reap. + return; + } + } + // The recorded pid now belongs to some unrelated program: the record + // is stale, not the process. + ProcessIdentity::Foreign => {} + ProcessIdentity::Unknown => { + // Alive but unnameable. Deleting the record here is what used to + // strand the machine (#667): the pid is the only handle on + // whatever this is, so it must outlive this attempt. + log::warn!( + "recorded daemon pid {pid} is alive but its executable cannot be identified; \ + keeping its record and leaving it alone" + ); return; } } + clear_daemon_records(); +} + +/// Clears both records of a daemon the reap has confirmed dealt with — the +/// pidfile, and the pid in the lock file (which is only touched if the seat +/// is actually free; see `clear_record_if_free`). +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn clear_daemon_records() { pidfile::remove(); + crate::daemon::singleton::clear_record_if_free(); } #[cfg(any(target_os = "macos", target_os = "linux"))] -fn process_matches_daemon_exe(pid: libc::pid_t) -> bool { - process_path(pid) +enum ProcessIdentity { + /// Named like a daemon of ours: safe to reap. + OurDaemon, + /// Named like something else: the recorded pid has been reused. + Foreign, + /// Alive, but neither its executable path nor its comm name is readable. + Unknown, +} + +/// What the process behind `pid` is, judged by name — by executable path +/// first, and by the kernel's comm name when the path is unreadable. +/// +/// The path is unreadable in exactly the case the reap exists for: on macOS +/// `proc_pidpath` fails outright for a live process whose binary has been +/// deleted, which is every daemon still running through an update that +/// replaced the installation. The comm name is recorded at `exec` time and +/// survives the deletion on both platforms. +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn process_identity(pid: libc::pid_t) -> ProcessIdentity { + let named = process_path(pid) .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) - .is_some_and(|name| is_reapable_daemon_name(&name)) + .or_else(|| process_comm(pid)); + match named { + Some(name) if is_reapable_daemon_name(&name) => ProcessIdentity::OurDaemon, + Some(_) => ProcessIdentity::Foreign, + None => ProcessIdentity::Unknown, + } +} + +#[cfg(target_os = "macos")] +fn process_comm(pid: libc::pid_t) -> Option { + if pid <= 0 { + return None; + } + // 2 * MAXCOMLEN, the buffer libproc's own callers use; proc_name refuses + // anything smaller. + let mut buf = [0u8; 64]; + let len = + unsafe { libc::proc_name(pid, buf.as_mut_ptr() as *mut libc::c_void, buf.len() as u32) }; + if len <= 0 { + return None; + } + Some(String::from_utf8_lossy(&buf[..len as usize]).into_owned()) +} + +#[cfg(target_os = "linux")] +fn process_comm(pid: libc::pid_t) -> Option { + if pid <= 0 { + return None; + } + let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?; + let comm = comm.trim(); + (!comm.is_empty()).then(|| comm.to_string()) } /// Whether the process is gone by the end. @@ -591,9 +771,53 @@ fn signal_and_await_exit(pid: libc::pid_t, sig: libc::c_int, timeout: Duration) wait_for_recorded_exit(pid as u32, timeout) } +/// Whether `pid` is a process that still exists — where a zombie does not +/// count. A zombie answers `kill(pid, 0)` like the living, but it holds no +/// lock, no endpoint and no image, and no signal can end it: counting it +/// alive made the reap SIGTERM-then-SIGKILL a corpse for the full eight +/// seconds of both timeouts before giving up on it. The GUI never waits on +/// the daemons it spawns, so a crashed daemon *is* a zombie of a long-lived +/// GUI, not a rare state. #[cfg(any(target_os = "macos", target_os = "linux"))] fn process_alive(pid: libc::pid_t) -> bool { - unsafe { libc::kill(pid, 0) == 0 } + let exists = unsafe { libc::kill(pid, 0) == 0 }; + exists && !is_zombie(pid) +} + +#[cfg(target_os = "macos")] +fn is_zombie(pid: libc::pid_t) -> bool { + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of::() as libc::c_int; + let got = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + &mut info as *mut _ as *mut libc::c_void, + size, + ) + }; + if got == size { + return info.pbi_status == libc::SZOMB; + } + // Measured, not assumed: for a zombie this call fails outright while + // `kill(pid, 0)` still succeeds — unlike a live process with a deleted + // executable, whose state (though not its path) stays readable. A pid we + // may signal but cannot introspect is a corpse. + true +} + +#[cfg(target_os = "linux")] +fn is_zombie(pid: libc::pid_t) -> bool { + let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { + // Readable state is the same boundary as on macOS: signalable but + // not introspectable is a corpse, not a daemon. + return true; + }; + // The state field follows the comm's closing paren — the comm itself may + // contain spaces and parens. + stat.rsplit_once(')') + .is_some_and(|(_, rest)| rest.trim_start().starts_with('Z')) } #[cfg(windows)] @@ -820,7 +1044,22 @@ fn process_path(pid: libc::pid_t) -> Option { if pid <= 0 { return None; } - std::fs::read_link(format!("/proc/{pid}/exe")).ok() + let path = std::fs::read_link(format!("/proc/{pid}/exe")).ok()?; + Some(PathBuf::from(strip_deleted_marker( + path.to_string_lossy().into_owned(), + ))) +} + +/// A deleted executable's `/proc//exe` reads as "/path/name (deleted)". +/// The marker is the kernel's, not part of the name — left in place it made a +/// replaced daemon read as foreign, which dropped its record without reaping +/// it (#667). +#[cfg(any(target_os = "linux", test))] +fn strip_deleted_marker(path: String) -> String { + match path.strip_suffix(" (deleted)") { + Some(stripped) => stripped.to_string(), + None => path, + } } #[cfg(unix)] @@ -1124,6 +1363,68 @@ mod exe_name_tests { assert!(is_reapable_daemon_name("TTY7")); } + /// The kernel's " (deleted)" marker on a replaced executable is not part + /// of its name; treating it as one made an updated-under daemon read as + /// foreign, which dropped its record without reaping it (#667). + #[test] + fn the_deleted_marker_is_not_part_of_a_process_name() { + assert_eq!( + strip_deleted_marker("/opt/tty7/tty7-server (deleted)".into()), + "/opt/tty7/tty7-server" + ); + assert_eq!( + strip_deleted_marker("/opt/tty7/tty7-server".into()), + "/opt/tty7/tty7-server" + ); + // Only the trailing marker is the kernel's. + assert_eq!( + strip_deleted_marker("/tmp/x (deleted)/tty7-server".into()), + "/tmp/x (deleted)/tty7-server" + ); + } + + /// A zombie answers `kill(pid, 0)` like the living but holds nothing and + /// cannot be signalled dead; counting it alive made the reap spend both + /// kill timeouts on a corpse. The GUI never waits on the daemons it + /// spawns, so this is the ordinary afterlife of a crashed daemon. + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn a_zombie_is_not_an_alive_process() { + let mut child = std::process::Command::new("true") + .spawn() + .expect("spawn true"); + let pid = child.id() as libc::pid_t; + // It has exited; nobody has waited: a zombie, once the exit lands. + let deadline = Instant::now() + Duration::from_secs(5); + while !is_zombie(pid) { + assert!( + Instant::now() < deadline, + "the unwaited child must read as a zombie" + ); + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + unsafe { libc::kill(pid, 0) == 0 }, + "a zombie still answers signal 0 — that is the trap" + ); + assert!( + !process_alive(pid), + "a corpse is not a process the reap can act on" + ); + let _ = child.wait(); + } + + /// The comm fallback is what identifies a daemon whose executable path is + /// unreadable — on macOS `proc_pidpath` fails outright once the binary is + /// deleted. This process is alive and its own comm must resolve. + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn a_live_process_resolves_its_own_comm_name() { + let comm = process_comm(std::process::id() as libc::pid_t) + .expect("this process is alive; its comm name must be readable"); + assert!(!comm.is_empty()); + } + #[test] fn strip_exe_suffix_only_strips_a_trailing_exe() { assert_eq!(strip_exe_suffix("tty7-app.exe"), "tty7-app"); @@ -1216,8 +1517,9 @@ mod tests { std::thread::sleep(Duration::from_millis(10)); } assert!( - !process_matches_daemon_exe(pid), - "sleep must not match any daemon name; matching here would mean the reap could kill it" + matches!(process_identity(pid), ProcessIdentity::Foreign), + "sleep must read as a foreign process — OurDaemon would mean the reap \ + could kill it, Unknown would mean its record is never cleared" ); let _ = child.kill(); diff --git a/crates/tty7-server/tests/daemon_reap.rs b/crates/tty7-server/tests/daemon_reap.rs new file mode 100644 index 00000000..29656ec3 --- /dev/null +++ b/crates/tty7-server/tests/daemon_reap.rs @@ -0,0 +1,307 @@ +//! Guards for #667: a daemon can survive with its endpoint unlinked and its +//! pidfile gone, holding the singleton seat against every later launch. The +//! reap must still find it — through the pid recorded in the lock file — and +//! must still recognise it when its executable has been deleted under it, +//! which is what every update that replaces the installation does. +//! +//! Unix-only: both tests drive the daemon through unix sockets and signals. +//! One process-wide config dir (`set_config_dir` is first-wins), so the tests +//! serialize on a mutex and each cleans up the daemon it started. + +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use tty7_core::client::PaneClient; + +const READY_WITHIN: Duration = Duration::from_secs(30); +const DEAD_WITHIN: Duration = Duration::from_secs(5); + +/// The one config dir every test here shares, pinned into `tty7_core` on +/// first use. `set_config_dir` is first-wins for the whole process, so a +/// per-test directory would silently leave later tests reaping in the first +/// test's directory. +fn pinned_dir() -> &'static Path { + static DIR: OnceLock = OnceLock::new(); + DIR.get_or_init(|| { + sweep_dead_runs(); + let dir = std::env::temp_dir().join(format!("tty7-reap-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create the shared config dir"); + tty7_core::core::config::set_config_dir(dir.clone()); + dir + }) +} + +/// Removes `tty7-reap-` directories whose creating test run is gone — +/// the pid in the name keeps concurrent runs apart, and is also what says a +/// leftover is safe to delete. +fn sweep_dead_runs() { + let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(pid) = name + .to_str() + .and_then(|n| n.strip_prefix("tty7-reap-")) + .and_then(|pid| pid.parse::().ok()) + .filter(|&pid| pid > 0) + else { + continue; + }; + let gone = unsafe { libc::kill(pid, 0) } != 0 + && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH); + if gone { + let _ = std::fs::remove_dir_all(entry.path()); + } + } +} + +fn serialized() -> std::sync::MutexGuard<'static, ()> { + static GATE: Mutex<()> = Mutex::new(()); + GATE.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn clear_stale_files(dir: &Path) { + for name in ["daemon.sock", "daemon.pid", "control.sock"] { + let _ = std::fs::remove_file(dir.join(name)); + } +} + +fn spawn_daemon_from(exe: &Path, dir: &Path) -> Child { + Command::new(exe) + .arg("--daemon") + .arg("--config-dir") + .arg(dir) + .env("TTY7_DATA_DIR", dir) + .env("TTY7_CONTROL_SOCK", dir.join("control.sock")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("start tty7-server --daemon") +} + +fn await_ready(dir: &Path) { + let endpoint = dir.join("daemon.sock"); + let deadline = Instant::now() + READY_WITHIN; + while PaneClient::at(&endpoint).version().is_err() || !dir.join("daemon.pid").exists() { + assert!( + Instant::now() < deadline, + "tty7-server did not open its endpoint within {READY_WITHIN:?}" + ); + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// Collects the child the moment it dies: outside tests the daemon is +/// nobody's child, and a zombie would read as alive to the reap's liveness +/// poll — and to this test's. +fn collect_on_exit( + child: Child, +) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + let mut child = child; + child.wait() + }) +} + +fn assert_dies(pid: u32, what: &str) { + let deadline = Instant::now() + DEAD_WITHIN; + while unsafe { libc::kill(pid as libc::pid_t, 0) } == 0 { + if Instant::now() >= deadline { + unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) }; + panic!("{what}: the daemon (pid {pid}) is still holding the seat"); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// The #667 state itself: endpoint unlinked, pidfile gone, the seat still +/// held by a live daemon. `stop` must find the survivor through the pid the +/// lock file records and reap it — and a fresh daemon must then be able to +/// take the seat. +#[test] +fn a_seat_holder_with_no_pidfile_is_still_found_and_reaped() { + let _gate = serialized(); + let dir = pinned_dir(); + clear_stale_files(dir); + + let child = spawn_daemon_from(Path::new(env!("CARGO_BIN_EXE_tty7-server")), dir); + let pid = child.id(); + await_ready(dir); + assert_eq!( + std::fs::read_to_string(dir.join("daemon.lock")) + .unwrap() + .trim(), + pid.to_string(), + "the lock file names the daemon holding the seat" + ); + let waiter = collect_on_exit(child); + + // What #667 reports after quit-and-stop: both names for the process are + // gone; only the seat (and the pid recorded in it) remains. + std::fs::remove_file(dir.join("daemon.sock")).unwrap(); + std::fs::remove_file(dir.join("daemon.pid")).unwrap(); + + tty7_core::daemon::spawn::stop(); + + assert_dies(pid, "stop() with no pidfile"); + waiter + .join() + .unwrap() + .expect("collect the reaped daemon's exit"); + + // The user-visible acceptance: the next launch gets the seat instead of + // standing down and timing out red. + let second = spawn_daemon_from(Path::new(env!("CARGO_BIN_EXE_tty7-server")), dir); + let second_pid = second.id(); + await_ready(dir); + let waiter = collect_on_exit(second); + tty7_core::daemon::spawn::stop(); + assert_dies(second_pid, "cleanup stop"); + waiter.join().unwrap().expect("collect the second daemon"); +} + +/// `reap_stranded` is the road the GUI's `ensure_running` and `tty7 server +/// start` take into the #667 state; it must clear the survivor the same way +/// `stop` does — after granting the grace a daemon mid-handoff would need. +#[test] +fn reap_stranded_clears_a_seat_holder_with_no_pidfile() { + let _gate = serialized(); + let dir = pinned_dir(); + clear_stale_files(dir); + + let child = spawn_daemon_from(Path::new(env!("CARGO_BIN_EXE_tty7-server")), dir); + let pid = child.id(); + await_ready(dir); + let waiter = collect_on_exit(child); + + std::fs::remove_file(dir.join("daemon.sock")).unwrap(); + std::fs::remove_file(dir.join("daemon.pid")).unwrap(); + + tty7_core::daemon::spawn::reap_stranded(); + + assert_dies(pid, "reap_stranded() with no pidfile"); + waiter + .join() + .unwrap() + .expect("collect the reaped daemon's exit"); + assert_eq!( + std::fs::read_to_string(dir.join("daemon.lock")).unwrap(), + "", + "a confirmed reap clears the dead holder's record from the lock file" + ); +} + +/// The grace's other half: a holder that answers the handshake is healthy — +/// mid-startup, mid-handoff, or simply fine — and `reap_stranded` must leave +/// it completely alone. This is the guard against the reap ending a daemon +/// that is carrying every live session across an exec. +#[test] +fn a_holder_that_answers_the_handshake_is_left_alone() { + let _gate = serialized(); + let dir = pinned_dir(); + clear_stale_files(dir); + + let child = spawn_daemon_from(Path::new(env!("CARGO_BIN_EXE_tty7-server")), dir); + let pid = child.id(); + await_ready(dir); + let waiter = collect_on_exit(child); + + tty7_core::daemon::spawn::reap_stranded(); + + assert!( + unsafe { libc::kill(pid as libc::pid_t, 0) } == 0, + "a healthy daemon must survive reap_stranded untouched" + ); + assert!( + PaneClient::at(&dir.join("daemon.sock")).version().is_ok(), + "and still be serving on its endpoint" + ); + + tty7_core::daemon::spawn::stop(); + assert_dies(pid, "cleanup stop"); + waiter.join().unwrap().expect("collect the daemon"); +} + +/// The startup grace must not excuse a holder that merely *connects*: a +/// wedged daemon's listener still completes connections out of the kernel's +/// backlog while answering nothing. Health is an answered handshake — a +/// live, connectable, silent holder is exactly what the reap is for. +#[test] +fn a_connectable_holder_that_answers_nothing_is_still_reaped() { + let _gate = serialized(); + let dir = pinned_dir(); + clear_stale_files(dir); + + let child = spawn_daemon_from(Path::new(env!("CARGO_BIN_EXE_tty7-server")), dir); + let pid = child.id(); + await_ready(dir); + let waiter = collect_on_exit(child); + + // Frozen mid-service: the kernel keeps completing connections on its + // listener, the process answers nothing — the closest reproducible stand- + // in for a daemon wedged in its main loop. + assert_eq!( + unsafe { libc::kill(pid as libc::pid_t, libc::SIGSTOP) }, + 0, + "freeze the daemon" + ); + assert!( + std::os::unix::net::UnixStream::connect(dir.join("daemon.sock")).is_ok(), + "a frozen daemon's endpoint still connects — that is the trap" + ); + + tty7_core::daemon::spawn::reap_stranded(); + + assert_dies(pid, "reap_stranded() against a connectable, silent holder"); + waiter + .join() + .unwrap() + .expect("collect the reaped daemon's exit"); +} + +/// A daemon whose executable was deleted under it — every update that +/// replaces the installation — must still read as ours. `proc_pidpath` fails +/// outright for such a process on macOS, and treating that as "not our +/// daemon" dropped the pidfile without reaping anyone: the other road into +/// the #667 lockout. +#[test] +fn a_daemon_running_from_a_deleted_executable_is_still_ours_to_reap() { + let _gate = serialized(); + let dir = pinned_dir(); + clear_stale_files(dir); + + let bin_dir = dir.join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let copied = bin_dir.join("tty7-server"); + std::fs::copy(env!("CARGO_BIN_EXE_tty7-server"), &copied).unwrap(); + + let child = spawn_daemon_from(&copied, dir); + let pid = child.id(); + await_ready(dir); + let waiter = collect_on_exit(child); + + std::fs::remove_file(&copied).unwrap(); + // Unlinked so stop() cannot simply ask for a shutdown: the reap's + // identity check is the code under test, and it only runs against a + // process that would not die politely. The pidfile stays — the pid + // source here is the ordinary one; what is broken is the executable. + std::fs::remove_file(dir.join("daemon.sock")).unwrap(); + + tty7_core::daemon::spawn::stop(); + + assert_dies(pid, "stop() with the executable deleted"); + waiter + .join() + .unwrap() + .expect("collect the reaped daemon's exit"); + assert!( + !dir.join("daemon.pid").exists(), + "a confirmed reap clears the pidfile" + ); +} diff --git a/crates/tty7-server/tests/daemon_stop.rs b/crates/tty7-server/tests/daemon_stop.rs index 670c8bfd..a5207d7a 100644 --- a/crates/tty7-server/tests/daemon_stop.rs +++ b/crates/tty7-server/tests/daemon_stop.rs @@ -103,12 +103,17 @@ fn a_clean_shutdown_keeps_the_pidfile_until_the_process_is_gone() { ); } -/// `spawn::stop` must reap a daemon that stopped listening but never exited, -/// even when the pidfile vanishes under it mid-stop — the ordering an old -/// build's shutdown produces when it wipes its files and then stalls (#653). -/// The pid captured at the top of `stop` is what the reap has to act on. +/// `spawn::stop` must reap a daemon that stopped listening but never exited +/// (#653) — and without the shutdown wait: only a shutdown that was actually +/// delivered earns `PROCESS_EXIT_TIMEOUT`, the time an *asked* daemon gets +/// to finish exiting. To a survivor stop() could not even connect to, that +/// wait was five seconds of watching nothing move (#667); the reap's own +/// SIGTERM window is all the grace such a process gets. +/// +/// (The companion ordering — the pidfile already gone when the reap needs a +/// pid — is pinned by the `daemon_reap` suite through the seat record.) #[test] -fn stop_reaps_a_lingering_daemon_even_after_the_pidfile_vanishes() { +fn stop_reaps_an_unreachable_daemon_without_the_shutdown_wait() { let dir = tempfile::TempDir::new().unwrap(); tty7_core::core::config::set_config_dir(dir.path().to_path_buf()); let child = spawn_daemon(dir.path()); @@ -124,31 +129,19 @@ fn stop_reaps_a_lingering_daemon_even_after_the_pidfile_vanishes() { }); // The #653 state, as stop() meets it: the endpoint is gone before stop() - // can ask for a shutdown, and the pidfile disappears while stop() is - // still waiting on the process. The delete must land inside stop()'s - // wait on the still-alive process (PROCESS_EXIT_TIMEOUT in spawn.rs), - // which the elapsed assertion below pins. - const SWEEP_DELAY: Duration = Duration::from_secs(1); + // can ask for a shutdown, and the process lives on. std::fs::remove_file(dir.path().join("daemon.sock")).unwrap(); - let pidfile = dir.path().join("daemon.pid"); - let sweeper = std::thread::spawn(move || { - std::thread::sleep(SWEEP_DELAY); - std::fs::remove_file(pidfile).is_ok() - }); let stop_started = Instant::now(); tty7_core::daemon::spawn::stop(); + // Well under spawn.rs's PROCESS_EXIT_TIMEOUT (5s): a stop() that pays + // that wait for a shutdown it never delivered has regressed. assert!( - stop_started.elapsed() >= SWEEP_DELAY, - "stop() returned before the sweeper's delete — the mid-stop pidfile \ - removal this test exists to exercise never happened; lower SWEEP_DELAY \ - below spawn.rs's PROCESS_EXIT_TIMEOUT" - ); - assert!( - sweeper.join().unwrap(), - "the sweeper found no pidfile to delete — stop() removed it early, so \ - the vanishing-pidfile ordering was not exercised" + stop_started.elapsed() < Duration::from_secs(4), + "stop() spent {:?} on a daemon it never reached — the exit wait must \ + be earned by a delivered shutdown", + stop_started.elapsed() ); let deadline = Instant::now() + Duration::from_secs(2); while unsafe { libc::kill(pid as libc::pid_t, 0) } == 0 { From f4c31222a4c6f0f057e37005826c3671301fb7af Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:59:54 +0800 Subject: [PATCH 12/33] feat(cli): restart the server in place by default, keeping sessions (#669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): restart the server in place by default, keeping sessions `tty7 server restart` used to be stop + start, killing every pane, while the GUI's Restart Server hands the daemon off to a new image via execve and keeps everything running. Same verb, opposite side effects. The CLI now probes the daemon for the handoff feature and asks it to exec the tty7-server binary in place: same pid, same ptys, sessions survive. Success is judged by the version endpoint answering with a new per-process instance id, not by build strings, since the CLI and server binaries can be on different versions. A refused or stalled handoff leaves the daemon untouched and reports an error suggesting `--hard` instead of silently killing sessions. The stop + start path remains for `--hard` and for daemons that cannot exec themselves (Windows, pre-handoff builds). * fix(cli): leave a slow handoff's seat holder alive, and let a hard restart say sessions ended After a taken handoff, the poll timing out does not mean the daemon died: the singleton lock survives the exec, so a held seat is the new image still coming up with every session aboard. Falling back to start() there would grant it one second of grace and then reap it — bail with the seat still held instead, and only start over a genuinely free seat. The stop-and-start fallback (--hard, Windows, pre-handoff builds) now reports that sessions ended instead of relaying start()'s plain report, since the default restart's promise is sessions kept. --- crates/tty7-cli/src/cli.rs | 13 +++- crates/tty7-cli/src/commands.rs | 8 ++- crates/tty7-cli/src/server.rs | 121 ++++++++++++++++++++++++++++++-- 3 files changed, 131 insertions(+), 11 deletions(-) diff --git a/crates/tty7-cli/src/cli.rs b/crates/tty7-cli/src/cli.rs index 56d0b234..bc216a6d 100644 --- a/crates/tty7-cli/src/cli.rs +++ b/crates/tty7-cli/src/cli.rs @@ -506,8 +506,11 @@ pub enum ServerCmd { #[command(about = "Stop the server; sessions end")] Stop, - #[command(about = "Stop, then start")] - Restart, + #[command(about = "Restart in place; sessions keep running (--hard: stop, then start)")] + Restart { + #[arg(long, help = "Stop, then start — every session ends")] + hard: bool, + }, #[command(about = "Version, uptime, panes, links")] Status, @@ -801,7 +804,7 @@ mod tests { for (verb, want) in [ ("start", ServerCmd::Start), ("stop", ServerCmd::Stop), - ("restart", ServerCmd::Restart), + ("restart", ServerCmd::Restart { hard: false }), ("status", ServerCmd::Status), ("logs", ServerCmd::Logs), ] { @@ -815,6 +818,10 @@ mod tests { "server {verb} parsed as the wrong verb" ); } + assert!(matches!( + parse(&["tty7", "server", "restart", "--hard"]).command, + Some(Command::Server(ServerCmd::Restart { hard: true })) + )); } #[test] diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index bb38e6dd..c31f2c1d 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -105,8 +105,10 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result { local_server(machine.as_deref(), "stop", crate::server::stop) } - Some(Command::Server(ServerCmd::Restart)) => { - local_server(machine.as_deref(), "restart", crate::server::restart) + Some(Command::Server(ServerCmd::Restart { hard })) => { + local_server(machine.as_deref(), "restart", || { + crate::server::restart(hard) + }) } Some(Command::Server(ServerCmd::Logs)) => { local_server(machine.as_deref(), "logs", crate::server::logs) @@ -118,7 +120,7 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result, verb: &str, - act: fn() -> Result, + act: impl FnOnce() -> Result, ) -> Result { if let Some(machine) = machine { bail!( diff --git a/crates/tty7-cli/src/server.rs b/crates/tty7-cli/src/server.rs index 0cfd3078..77cfa924 100644 --- a/crates/tty7-cli/src/server.rs +++ b/crates/tty7-cli/src/server.rs @@ -6,6 +6,7 @@ use anyhow::{Result, bail}; use serde_json::json; use tty7_core::client::PaneClient; use tty7_core::core::config; +use tty7_core::daemon::protocol::FEATURE_HANDOFF; use tty7_core::daemon::spawn; use crate::commands::{Outcome, Report}; @@ -126,14 +127,124 @@ pub fn stop() -> Result { report("stopped", json!({ "stopped": true })) } -pub fn restart() -> Result { +pub fn restart(hard: bool) -> Result { + let client = PaneClient::local(); + let Ok(old) = client.version() else { + return start(); + }; + if !hard && old.has_feature(FEATURE_HANDOFF) { + return restart_in_place(&client, &old); + } + // Either `--hard`, or a daemon that cannot replace itself in place — + // Windows, or a build from before the handoff existed. Stopping is the + // only restart that daemon has — and the report has to own that, because + // the default restart's promise is sessions kept. + spawn::stop(); if running() { - spawn::stop(); - if running() { - bail!("the server did not shut down on request"); + bail!("the server did not shut down on request"); + } + let how = if hard { + "stopped and started" + } else { + "stopped and started (this server cannot restart in place)" + }; + match start()? { + Outcome::Report(r) => report( + format!("{how}; sessions ended; {}", r.human), + json!({ + "restarted": true, + "in_place": false, + "sessions_kept": false, + "start": r.json, + }), + ), + other => Ok(other), + } +} + +/// The daemon execs the tty7-server binary found by [`server_exe`]: same pid, +/// same ptys, every session kept. The socket closing is the handoff being +/// taken; the proof it *worked* is the version endpoint answering with a new +/// `instance`, because that value is minted once per process image. +fn restart_in_place( + client: &PaneClient, + old: &tty7_core::daemon::protocol::DaemonVersion, +) -> Result { + let exe = server_exe()?; + client.hand_off(&exe).map_err(|e| { + anyhow::anyhow!( + "the server refused to restart in place and was left running: {e} — \ + `tty7 server restart --hard` stops and starts it instead; sessions end" + ) + })?; + let deadline = Instant::now() + START_TIMEOUT; + loop { + if let Ok(new) = client.version() + && new.instance != old.instance + { + let human = if new.build == old.build { + format!( + "restarted in place (build {}); sessions kept running", + new.build + ) + } else { + format!( + "restarted in place (build {} -> {}); sessions kept running", + old.build, new.build + ) + }; + return report( + human, + json!({ + "restarted": true, + "in_place": true, + "build": new.build, + "sessions_kept": true, + }), + ); } + if Instant::now() >= deadline { + break; + } + std::thread::sleep(POLL_INTERVAL); + } + if client.version().is_ok() { + // Still the old instance answering: the handoff was accepted but the + // exec never landed. The daemon is intact, and so are its sessions. + bail!( + "the server took the handoff but is still running its old image — \ + `tty7 server restart --hard` stops and starts it instead; sessions end" + ); + } + // The endpoint is silent, but the seat still tells "becoming the new + // image" apart from "died": the singleton lock survives the exec, so a + // held seat is the handed-off daemon still coming up, carrying every + // session. `start` would grant it one second of grace and then reap it — + // ending exactly the sessions this command promised to keep. + if tty7_core::daemon::singleton::holder_pid().is_some() { + bail!( + "the server took the handoff but has not started answering within \ + {START_TIMEOUT:?} — it still holds the server seat, so its sessions may yet \ + survive; give it a moment and check `tty7 server status`" + ); + } + // The seat is free: it went down mid-handoff, and its sessions with it; + // what is left to restore is a serving endpoint. + match start()? { + Outcome::Report(r) => report( + format!( + "the server went away during the handoff, ending its sessions; {}", + r.human + ), + json!({ + "restarted": true, + "in_place": false, + "sessions_kept": false, + "start": r.json, + }), + ), + other => Ok(other), } - start() } pub fn logs() -> Result { From 9c2869a25f5fb777a51d116972904a2819a78533 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:08:31 +0800 Subject: [PATCH 13/33] Trim the app's long-winded copy, add four dark themes (#663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(i18n): drop the About page shell primer and trim the long copy The About page carried a "How shells work" section explaining that shells live in a background server. Nothing linked to it and the Updates and Server sections below already say what happens to those shells, so it was a paragraph of prose the page did not need. Remove it, its search index entry, and its three L10nKeys. Then cut the padding out of 48 strings across settings rows, dialogs and notices. Two patterns accounted for most of it: the restart-server dialogs stated "your shells keep running" up to four times each in different words, and the config.json failure notices packed three subordinate clauses into every sentence. Nothing is dropped but repetition and clauses the reader can infer — every consequence a dialog asks the user to weigh is still spelled out. en, zh and ja stay in sync. * feat(themes): add Catppuccin Mocha, Gruvbox Dark, Nord and Tokyo Night Four more dark built-ins, taking the set from nine to thirteen. The docs table and description are updated to match. * fix(themes): give Catppuccin Mocha its rosewater caret, refresh a stale builtin count --- docs/customization/themes.mdx | 4 +- src/ui/i18n/en.rs | 136 ++++++++++++---------------------- src/ui/i18n/ja.rs | 112 +++++++++++----------------- src/ui/i18n/mod.rs | 3 - src/ui/i18n/zh.rs | 118 ++++++++++++----------------- src/ui/presets.rs | 122 +++++++++++++++++++++++++++++- src/ui/settings.rs | 49 +++--------- 7 files changed, 269 insertions(+), 275 deletions(-) diff --git a/docs/customization/themes.mdx b/docs/customization/themes.mdx index 06774279..5eca7930 100644 --- a/docs/customization/themes.mdx +++ b/docs/customization/themes.mdx @@ -1,6 +1,6 @@ --- title: "Themes" -description: "Nine built-ins, your own YAML themes, iTerm2 imports, and a colour editor." +description: "Thirteen built-ins, your own YAML themes, iTerm2 imports, and a colour editor." --- **Settings → Appearance → Theme** — or **Change Theme…** in the command @@ -10,7 +10,7 @@ palette — opens the theme picker. | Light | Dark | |---|---| -| Light *(default)* · One Light · Catppuccin Latte · Rosé Pine Dawn | Dark · Dracula · Harbor · One Dark Pro · Rosé Pine | +| Light *(default)* · One Light · Catppuccin Latte · Rosé Pine Dawn | Dark · Dracula · Harbor · One Dark Pro · Rosé Pine · Catppuccin Mocha · Gruvbox Dark · Nord · Tokyo Night | The tty7 theme picker diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 158b27d0..4f99d30a 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -59,10 +59,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::Close => "Close", L10nKey::QuitStopServerTitle => "Quit and Stop Server?", L10nKey::QuitStopServerBody => { - "This quits tty7 and stops the background server — anything still running \ - in your shells is terminated. Your tabs and layout are kept and reopen with \ - fresh shells next launch. (Closing the window only retires the app to the \ - tray; the shells keep running.)" + "This quits tty7 and stops the background server; anything running in your shells is terminated. Your tabs and layout reopen with fresh shells next launch. (Closing the window only retires tty7 to the tray — the shells keep running.)" } L10nKey::QuitAndStop => "Quit and Stop", L10nKey::CloseSshConnectionTitle => "Close this SSH connection?", @@ -176,7 +173,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ThemeSaveFailed => "Could not save the theme", L10nKey::OpenInFileManagerFailed => "Could not open {path}", L10nKey::SettingsCustomThemesIntro => { - "Duplicate a theme to edit its colors here, or drop your own in the themes folder: a tty7 YAML theme or an iTerm2 .itermcolors scheme." + "Duplicate a theme to edit its colors, or drop a tty7 YAML theme or iTerm2 .itermcolors file in the themes folder." } L10nKey::SettingsDuplicateToEdit => "Duplicate to edit", L10nKey::SettingsHosts => "Hosts", @@ -228,9 +225,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { "The password saved for it goes too, unless another connection still uses the same address." } L10nKey::SettingsDeleteProfileCascade => { - "{count} saved remote workspace entries point at {endpoint} and are removed from \ - this computer along with it — the sessions on the remote machine keep running, \ - and connecting with a new profile brings them back to the workspace list." + "{count} saved remote workspace entries point at {endpoint} and go with it. The sessions on the remote machine keep running — connect with a new profile and they reappear in the workspace list." } L10nKey::SettingsCouldntForgetPassword => { "Could not forget the saved password for {endpoint}: {error}" @@ -241,7 +236,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::SettingsVerifyHostKeys => "Verify host keys", L10nKey::SettingsVerifyHostKeysDesc => { - "Check each server's key against known_hosts and confirm unknown or changed keys before connecting. Off connects without checking, so a spoofed server would go unnoticed." + "Check each server's key against known_hosts before connecting. Off skips the check, so a spoofed server would go unnoticed." } L10nKey::WarnBeforeClosing => "Warn before closing", L10nKey::SettingsWarnBeforeClosingDesc => { @@ -392,7 +387,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { "That directory does not exist — the value was not saved." } L10nKey::SettingsShellFooter => { - "Applies to shells with nothing to inherit — like the first tab of a window. New tabs and splits keep inheriting the active pane's directory, and shells already open keep running." + "Applies to shells with nothing to inherit, like a window's first tab. New tabs and splits still inherit the active pane's directory; open shells keep running." } L10nKey::SettingsScrolling => "Scrolling", L10nKey::SettingsScrollback => "Scrollback", @@ -433,11 +428,11 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsOpenFilesSystem => "Default app", L10nKey::SettingsOpenFilesCommand => "Command", L10nKey::SettingsOpenFilesModeDesc => { - "What a {modifier}-clicked file link opens. The built-in editor is the only one that can jump to a line or open a file on a remote host." + "What a {modifier}-clicked file link opens. Only the built-in editor can jump to a line or open a file on a remote host." } L10nKey::LinkFileNotUnder => "{path} — nothing by that name under {dir}", L10nKey::LinkFileNoDirectory => { - "{path} — this pane has not said which directory it is in, so a relative path has nothing to be measured from" + "{path} — this pane has not said which directory it is in, so a relative path has no base" } L10nKey::LinkFileMissing => "{path} — nothing at that path", L10nKey::LinkDirOutsideTree => { @@ -445,7 +440,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::OpenFilesWith => "Open files with", L10nKey::SettingsOpenFilesWithDesc => { - "Command run when {modifier}-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." + "Command run when {modifier}-clicking a file link. Use {path}, {line}, {column} — a flag whose value is missing is dropped. Empty uses the default app." } L10nKey::SettingsBellModeOff => "Off", L10nKey::SettingsBellModeVisual => "Visual", @@ -457,7 +452,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::SettingsPromptEditor => "Prompt editor", L10nKey::SettingsPromptEditorDesc => { - "tty7 owns the line you type at the shell prompt: selection, undo, and the menus below. When off, every key, IME commit and paste at the prompt goes straight to the shell, so its own line editor — zsh's ZLE, readline, fish — does the editing and the keys you bound there behave as written. Shell integration stays on either way." + "tty7 edits the line you type at the shell prompt: selection, undo, and the menus below. Off hands the prompt back to the shell's own editor — ZLE, readline, fish." } L10nKey::SettingsNeedsPromptEditor => { "Needs the prompt editor: with it off, this key already belongs to the shell." @@ -468,7 +463,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::SettingsHistorySearch => "History search", L10nKey::SettingsHistorySearchDesc => { - "⌃R at the prompt opens tty7's fuzzy history menu. When off, ⌃R goes to the shell instead — its own reverse-i-search, or whatever you've bound there (fzf, percol)." + "⌃R at the prompt opens tty7's fuzzy history menu. Off sends ⌃R to the shell — its own reverse-i-search, or whatever you bound there (fzf, percol)." } L10nKey::SettingsSelectionClipboard => "Selection & clipboard", L10nKey::SettingsSmartSelection => "Smart selection", @@ -490,7 +485,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::SettingsAgentsIntro => "Agents", L10nKey::SettingsAgentsIntroDesc => { - "Hook integrations give panes running these agents live session status (working / waiting / done) in the tab bar. Only active inside tty7." + "Hooks give panes running these agents live status (working / waiting / done) in the tab bar. Only inside tty7." } L10nKey::SettingsReadingAgentConfig => "Reading this machine's agent config…", L10nKey::SettingsStatusNotInstalled => "Not installed", @@ -537,7 +532,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::SettingsShowTrayIcon => "Show tray icon", L10nKey::SettingsShowTrayIconDesc => { - "Keep a status item in the system tray / menu bar: it signals when a coding agent needs your input, and its menu jumps to agent panes." + "A status item in the tray / menu bar: it signals when an agent needs input, and its menu jumps to agent panes." } L10nKey::SettingsTabs => "Tabs", L10nKey::SettingsNewTabPosition => "New tab position", @@ -548,11 +543,11 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::SettingsSidebarGrouping => "Sidebar grouping", L10nKey::SettingsSidebarGroupingDesc => { - "Group sidebar tabs under a header per git repository. Non-repo tabs collect in a Scratch section, or under their working directory with \"By repo or folder\". Only applies to the left sidebar." + "Group sidebar tabs by git repository. Tabs outside a repo collect under Scratch, or under their working directory with \"By repo or folder\". Left sidebar only." } L10nKey::SettingsDiffPreviewFromCounts => "Open diff preview from sidebar counts", L10nKey::SettingsDiffPreviewFromCountsDesc => { - "Click a row's +N −N to open the working-tree diff in an overlay. Off keeps the branch and the counts on the row and just stops them being clickable." + "Click a row's +N −N to open the working-tree diff in an overlay. Off leaves the counts visible, just not clickable." } L10nKey::SettingsNotifications => "Notifications", L10nKey::SettingsNotifyOnCommandFinish => "Notify on command finish", @@ -585,7 +580,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsPressKeys => "Press keys…", L10nKey::SettingsPauseToSaveEsc => "pause to save · Esc", L10nKey::SettingsKeybindingsIntroDesc => { - "Click a shortcut, then press the new keys — it saves after a brief pause. Chain keys for a sequence like Ctrl-B then X. Esc cancels; Backspace removes the last key, or resets the shortcut to default when pressed first." + "Click a shortcut, then press the new keys — it saves after a brief pause. Chain keys for a sequence like Ctrl-B then X. Esc cancels; Backspace removes the last key, or resets to default if pressed first." } L10nKey::SettingsPrefixNote => { "With a prefix active, a bare prefix key reaches the shell after a ~1s pause, and prefix + an unbound key is sent through to the terminal." @@ -628,7 +623,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsUpdateDiscard => "Discard", L10nKey::SettingsAutoDownload => "Download updates in the background", L10nKey::SettingsAutoDownloadDesc => { - "Fetch and verify a new release as soon as it is found, so installing it is just a restart. Nothing is installed without asking. Turn this off on a metered connection — the packages are around 30 MB." + "Download and verify a new release as soon as it is found, so installing is just a restart. Nothing installs without asking. Packages are around 30 MB." } L10nKey::SettingsUpdateChannel => "Update channel", L10nKey::SettingsUpdateChannelDesc => { @@ -638,14 +633,14 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsUpdateChannelNightly => "Nightly", L10nKey::SettingsDaemonStale => "The background server is still running {build}.", L10nKey::SettingsDaemonStaleDesc => { - "tty7 was updated in place, so the app is new but your panes are still served by the previous build. Restarting the server picks up the new one and ends every process running in your panes — shells, agents, and SSH sessions alike. There is no hurry: pick a moment when your panes are idle." + "tty7 was updated in place: the app is new, your panes are still served by the old build. Restarting the server picks up the new one and ends everything running in your panes. No hurry — do it when they're idle." } L10nKey::UpdateDialogTitle => "Update available", L10nKey::UpdateDialogDetail => { - "tty7 {version} is available — you're on {current}. Installing restarts the app; the background server keeps running, so whatever is open in your panes survives." + "tty7 {version} is available — you're on {current}. Installing restarts the app; the background server keeps running, so your panes survive." } L10nKey::UpdateDialogDetailWindows => { - "tty7 {version} is available — you're on {current}. Installing restarts the app and the background service: processes running in your panes are ended, and your tabs and layout come back with fresh shells." + "tty7 {version} is available — you're on {current}. Installing restarts the app and the background service: processes in your panes are ended, and your tabs and layout come back with fresh shells." } L10nKey::UpdateDialogDetailManual => { "tty7 {version} is available — you're on {current}. {hint}" @@ -654,13 +649,13 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::UpdateDialogLater => "Later", L10nKey::UpdateDialogNextLaunch => "Install on Next Launch", L10nKey::UpdateDialogNeedsElevation => { - "This copy of tty7 is installed for all users, so Windows will ask for administrator approval once before the install begins. tty7 itself never runs elevated — it comes back as you." + "tty7 is installed for all users, so Windows asks for administrator approval once before installing. tty7 itself never runs elevated." } L10nKey::SettingsUpdateCheckFailed => "Could not check for updates: {error}", L10nKey::SettingsUpdatePrepareFailed => "Update failed: {error}", L10nKey::SettingsUpdateLaunchFailed => "Could not start the installer: {error}", L10nKey::SettingsUpdateUnsupportedMacos => { - "This copy is not running from a writable tty7.app bundle, so replacing it would be unsafe. Move tty7 to Applications or another writable folder, or open the release page to install the update." + "This copy is not in a writable tty7.app bundle, so it cannot replace itself. Move tty7 to Applications, or open the release page to update." } L10nKey::SettingsUpdateUnsupportedLinux => { "The release has no Linux package for this architecture. Build from source, or use your package manager." @@ -669,10 +664,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { "Linux installations are updated by hand. Download {name} from the release page, or use your package manager." } L10nKey::SettingsUpdateUnsupportedWindows => { - "Automatic Windows updates are available for recognized Inno Setup and portable ZIP installations. This copy is missing a valid installation marker, updater, or writable portable directory, so open the release page to update it manually." + "This copy is not a recognized Inno Setup or portable ZIP installation, so it cannot update itself. Open the release page to update it by hand." } L10nKey::SettingsUpdateWindowsAllUsers => { - "tty7 is installed for all users, which needs administrator rights to replace. tty7 will not raise an elevation prompt on its own behalf, so open the release page and run the installer yourself to update it." + "tty7 is installed for all users, so replacing it needs administrator rights that tty7 will not ask for itself. Open the release page and run the installer to update." } L10nKey::SettingsUpdateUnsupportedPlatform => { "Automatic installation is not available on this platform. Open the release page." @@ -690,18 +685,12 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsCheckUpdatesOnLaunch => "Check for updates on launch", L10nKey::SettingsCommandLine => "Command line", L10nKey::SettingsCommandLineDesc => { - "Put the bundled tty7 command on your PATH at launch, so scripts and coding agents can drive tty7 from any terminal. Inside a tty7 pane it works either way. Turn this off if you keep your own tty7 — one you built or installed yourself — and do not want it shadowed. Takes effect at next launch." + "Put the bundled tty7 command on your PATH, so scripts and agents can drive tty7 from any terminal — inside a pane it works either way. Turn off to keep your own build unshadowed. Applies at next launch." } L10nKey::SettingsInstallCliOnPath => "Install the tty7 command on PATH", L10nKey::SettingsServer => "Server", L10nKey::SettingsServerDesc => { - "Restarts the background server that keeps your shells running. This ends every shell on this computer; your tabs and layout reopen with fresh ones." - } - L10nKey::SettingsHowShellsWorkBody => { - "Your shells run in a background server, not inside this window. Quitting tty7 \ - leaves them running: reopen it and your tabs, layout, and working directories \ - come back with the same shells still in them. Closing a tab ends its shell; \ - \"Restart server\" and \"Quit and Stop Server\" end all of them." + "Restarts the background server that keeps your shells running. Every shell on this computer ends; your tabs and layout reopen with fresh ones." } L10nKey::SettingsRestartServer => "Restart server…", L10nKey::SettingsAppHttpProxy => "Proxy for updates", @@ -774,10 +763,6 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchHostsKeywords => { "ssh host connection saved profile import ssh_config manage add edit quick connect" } - L10nKey::SettingsSearchHowShellsWorkKeywords => { - "shell session daemon server detach persist background close quit stop delete workspace layout survive reboot tmux" - } - L10nKey::SettingsSearchHowShellsWorkTitle => "How shells work", L10nKey::SettingsSearchItalicFontKeywords => "typeface oblique", L10nKey::SettingsSearchKeybindingsKeywords => { "shortcut hotkey keyboard binding chord tmux preset rebind prefix" @@ -1140,13 +1125,13 @@ pub fn translate_en(key: L10nKey) -> &'static str { "… and {count} more changed files — run git diff in the terminal to see them." } L10nKey::DiffOversizedNotice => { - "This working tree is too large to render efficiently ({summary}). Every file is collapsed — expand individual files, or run git diff in the terminal." + "This working tree is too large to render ({summary}). Every file is collapsed — expand them one at a time, or run git diff in the terminal." } L10nKey::DiffTruncatedPerFile => { "Diff truncated at {limit} lines — run git diff in the terminal for the rest." } L10nKey::DiffTruncatedBudget => { - "Body not loaded — this working tree is past tty7's diff budget. Run git diff in the terminal for this file." + "Body not loaded — past tty7's diff budget. Run git diff in the terminal for this file." } L10nKey::DiffUntrackedHeader => "Untracked files ({count})", L10nKey::DiffMoreUntracked => { @@ -1196,9 +1181,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { "The connection profile for {machine} no longer exists — it cannot reconnect" } L10nKey::RemoteRouteParkedHint => { - "Its connection profile no longer exists, so it will not reconnect on its own. \ - The remote session is still there — connect to the machine with a new profile \ - and it reappears in the workspace list." + "Its connection profile is gone, so it will not reconnect on its own. The remote session is still there — connect with a new profile and it reappears in the workspace list." } L10nKey::RemoteNoticePreempted => "Opened elsewhere — typing has no effect", L10nKey::RemoteNoticeDisconnected => "Not connected — typing has no effect", @@ -1208,17 +1191,13 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::RemoteActionRetry => "Retry", L10nKey::RemoteActionRemoveEntry => "Remove entry", L10nKey::RemoteNoConnectionDetails => { - "This window is a workspace on {machine}, but tty7 has no connection \ - details for it any more — check that its SSH profile or ~/.ssh/config \ - entry still exists." + "This window is a workspace on {machine}, but tty7 has no connection details for it — check its SSH profile or ~/.ssh/config entry still exists." } L10nKey::RemoteThisComputer => "this computer", L10nKey::RemoteProfileGone => "deleted profile", L10nKey::RemoteRestartTitle => "Restart tty7's server on \"{machine}\"?", L10nKey::RemoteRestartBody => { - "This stops every shell on {machine} — anything still running in them \ - will be terminated, including shells this window is not showing. \ - Workspaces and layouts are kept and come back with fresh shells." + "This ends every shell on {machine}, including ones this window is not showing. Workspaces and layouts are kept and come back with fresh shells." } L10nKey::RemoteReplaceBody => { "tty7 will install a matching server on {machine} and start it.\n\ @@ -1256,12 +1235,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::RemoteInstallBytes => "bytes", L10nKey::RemoteMismatchTitle => "Update tty7's server on \"{machine}\"?", L10nKey::RemoteMismatchDetail => { - "{machine} is serving tty7 sessions from {running}, which speaks a protocol \ - this client ({wanted}) cannot. tty7 has installed a matching server there, \ - but the one already running is the one your sessions are on.\n\ - \n\ - {replace_server}\u{2003}replaces it with {wanted} and ends every session it is hosting.\n\ - {cancel}\u{2003}leaves {machine} exactly as it is. This window will not connect." + "{machine} runs server {running}, which this client ({wanted}) cannot speak. A matching server is installed there, but your sessions are on the one already running.\n\n{replace_server}\u{2003}replaces it with {wanted} and ends every session it is hosting.\n{cancel}\u{2003}leaves {machine} exactly as it is. This window will not connect." } L10nKey::RemoteMismatchReplaceServer => "Update Server", // Same button, opposite direction: the machine is ahead of this build, @@ -1282,8 +1256,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::RemoteDaemonStartFailed => "tty7's local server could not be started: {error}", L10nKey::RemoteDaemonUnreachable => "could not reach tty7's local server: {error}", L10nKey::RemoteDaemonTooOld => { - "this machine's tty7 daemon is an older build and cannot restart the server on \ - {machine}. Quit tty7 (which stops the daemon) and open it again, then retry." + "this machine's daemon is an older build and cannot restart the server on {machine}. Quit tty7 (that stops the daemon), open it again, and retry." } L10nKey::RemoteProfileMissing => "that saved SSH profile no longer exists", L10nKey::RemoteAliasMissing => "\"{alias}\" is no longer in ~/.ssh/config", @@ -1362,7 +1335,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::IoBusy => "Something else has it open.", L10nKey::IoTimedOut => "The machine did not answer in time.", L10nKey::TreeWindowOpenedEmpty => { - "This window's server never handed over its tabs, so it opened empty. Nothing was lost — they come back when it answers. If it doesn't, run \"Restart Server\" from the command palette." + "The server never handed over this window's tabs, so it opened empty. Nothing was lost — they come back when it answers. If it doesn't, run \"Restart Server\" from the command palette." } L10nKey::CmdGroupTabsPanes => "Tabs & Panes", L10nKey::CmdGroupWorkspaces => "Workspaces", @@ -1476,35 +1449,35 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::AppRestartServerTitle => "Restart Server?", L10nKey::AppRestartServerFailed => "Could not restart the background server: {error}", L10nKey::AppRestartServerMismatchDetail => { - "The server holding your shells is build v{build}, protocol {protocol}; this app speaks {ours}. They can't talk, so your tabs are out of reach.\n\nQuit: nothing changes — the server and your shells keep running.\nRestart: tabs come back with fresh shells, and anything running now is killed." + "The server holding your shells speaks protocol {protocol} (build v{build}); this app speaks {ours}, so your tabs are out of reach.\n\nQuit: nothing changes — the server and your shells keep running.\nRestart: tabs come back with fresh shells; anything running now is killed." } L10nKey::AppRestartServerDialectDetail => { - "The server holding your shells is build v{build}: control dialect v{dialect}, where this app speaks v{ours}. It can't hand over your tabs, so every window opens empty.\n\nQuit: nothing changes — the server and your shells keep running.\nRestart: tabs come back with fresh shells, and anything running now is killed." + "The server holding your shells speaks control dialect v{dialect} (build v{build}); this app speaks v{ours}, so every window opens empty.\n\nQuit: nothing changes — the server and your shells keep running.\nRestart: tabs come back with fresh shells; anything running now is killed." } L10nKey::AppRestartServerDialectNewerDetail => { - "The server holding your shells is build v{build}: control dialect v{dialect}, where this app speaks v{ours}. It can't hand over your tabs, so every window opens empty.\n\nQuit and install the newer build: the real fix, and your shells survive it.\nRestart: tabs come back with fresh shells, and anything running now is killed." + "The server holding your shells speaks control dialect v{dialect} (build v{build}); this app speaks v{ours}, so every window opens empty.\n\nQuit and install the newer build: the real fix — your shells survive it.\nRestart: tabs come back with fresh shells; anything running now is killed." } L10nKey::AppRestartServerOldDetail => { - "The server holding your shells predates the version handshake, so this app can't tell what it speaks.\n\nQuit: nothing changes — the server and your shells keep running.\nRestart: tabs come back with fresh shells, and anything running now is killed." + "The server holding your shells predates the version handshake, so this app can't tell what it speaks.\n\nQuit: nothing changes — the server and your shells keep running.\nRestart: tabs come back with fresh shells; anything running now is killed." } L10nKey::AppRestart => "Restart", L10nKey::AppRestartServerNoServer => { - "tty7 has no server of its own to restart on {label} — it is a program this computer runs over --stdio. Stop its workspace instead." + "{label} has no server of its own — it is a program this computer runs over --stdio. Stop its workspace instead." } L10nKey::AppRestartServerBody => { - "This stops every running shell on this computer — anything still running in them will be terminated. Your tabs and layout are kept and reopened with fresh shells." + "This ends every shell on this computer. Your tabs and layout are kept and reopen with fresh shells." } L10nKey::ConfigQuarantinedStartup => { - "config.json could not be parsed, so tty7 is running on default settings and will not write over the file. Its contents were kept beside it as config.json.corrupt — fix the file and it reloads itself. Until then, changes made in Settings are not saved." + "config.json could not be parsed. tty7 is on default settings and kept the file's contents beside it as config.json.corrupt. Fix it and tty7 reloads; until then, Settings changes are not saved." } L10nKey::ConfigQuarantinedReload => { - "The edited config.json could not be parsed, so tty7 kept the settings it is already running on and set the file's contents aside as config.json.corrupt. Fix the file and it reloads itself; saving a setting before then replaces it with the settings in use." + "The edited config.json could not be parsed. tty7 kept the settings it is running on and set the file's contents aside as config.json.corrupt. Fix it and tty7 reloads; saving a setting first overwrites it." } L10nKey::ConfigUnreadableStartup => { - "config.json could not be read, so tty7 is running on default settings and will not write over the file — it is left exactly as it is. Fix its permissions or contents and it reloads itself. Until then, changes made in Settings are not saved." + "config.json could not be read. tty7 is on default settings and left the file exactly as it is. Fix its permissions or contents and tty7 reloads; until then, Settings changes are not saved." } L10nKey::ConfigUnreadableReload => { - "config.json could not be read, so tty7 kept the settings it is already running on and left the file exactly as it is. Fix its permissions or contents and it reloads itself; saving a setting before then replaces it with the settings in use." + "config.json could not be read. tty7 kept the settings it is running on and left the file exactly as it is. Fix its permissions or contents and tty7 reloads; saving a setting first overwrites it." } L10nKey::AppWorktreeRemoveDetailDirty => { "The closed tab's worktree at {path} has uncommitted changes." @@ -1626,36 +1599,23 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::Replace => "Replace", L10nKey::SftpErrorInvalidOctalMode => "invalid octal mode", L10nKey::SettingsDaemonStaleDescInPlace => { - "tty7 was updated in place, so the app is new but your panes are still served by the \ - previous build. The server can replace itself with the new one without stopping: \ - your shells and whatever is running in them carry straight over. Panes on tty7's \ - built-in SSH client are the exception — those connections close and need reopening." + "tty7 was updated in place: the app is new, your panes still run on the old build. The server can swap itself for the new one without stopping, so your shells carry straight over. Panes on tty7's built-in SSH client are the exception — those close and need reopening." } L10nKey::AppRestartServerBodyInPlace => { - "The background server replaces itself with this build without stopping. Your shells \ - keep running — commands, agents and `ssh` sessions in a pane are not interrupted — \ - and the window reconnects to them a moment later. Panes on tty7's built-in SSH \ - client are the exception: those connections close and need reopening." + "The server swaps itself for this build in place: your shells keep running, and the window reconnects a moment later. Panes on tty7's built-in SSH client are the exception — those close and need reopening." } L10nKey::PaneRestoredScreenBanner => { "restored screen — this shell is new, nothing above it is still running" } L10nKey::SettingsPerPaneHistory => "Give each pane its own shell history", L10nKey::SettingsPerPaneHistoryDescription => { - "Up walks through what you ran in this pane, instead of an interleaving of every \ - pane. A new pane starts from your existing history rather than blank, and what it \ - adds is written back when it closes, so nothing is lost. Applies to bash and zsh \ - panes that tty7 can set up; a shell started with your own arguments is left alone." + "Up walks through what you ran in this pane, not every pane interleaved. A new pane starts from your existing history and writes back what it adds when it closes. Applies to bash and zsh panes tty7 can set up; a shell started with your own arguments is left alone." } L10nKey::IntegrationNoticeBlocked => { - "tty7 shell integration is blocked in this pane — \u{201c}{wrapper}\u{201d} is \ - intercepting shell reports, so inline completion and the Ctrl+R menu are \ - unavailable. The shell's own history search still works." + "\u{201c}{wrapper}\u{201d} is intercepting shell reports in this pane, so inline completion and the Ctrl+R menu are unavailable. The shell's own history search still works." } L10nKey::IntegrationNoticeNotEngaged => { - "tty7 shell integration hasn't engaged in this pane, so inline completion and the \ - Ctrl+R menu are unavailable. A shell you started with your own arguments, a PTY \ - wrapper (figterm-style), or an unsupported shell setup can cause this." + "tty7 shell integration hasn't engaged in this pane, so inline completion and the Ctrl+R menu are unavailable. Usual causes: a shell you started with your own arguments, a PTY wrapper, or an unsupported shell." } L10nKey::PaneTitleDisconnected => "{title} — disconnected", L10nKey::PaneTitleProcessExited => "{title} — process exited", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index bd963bad..4dbe2818 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -61,7 +61,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::Close => "閉じる", L10nKey::QuitStopServerTitle => "tty7 を終了してサーバーを停止しますか?", L10nKey::QuitStopServerBody => { - "tty7 を終了してバックグラウンドサーバーを停止します。シェルで実行中のものはすべて終了します。タブとレイアウトは保持され、次回起動時に新しいシェルで開きます。ウィンドウを閉じただけではアプリはトレイに退避し、シェルは動き続けます。" + "tty7 を終了してバックグラウンドサーバーを停止します。シェルで実行中のものはすべて終了します。タブとレイアウトは次回起動時に新しいシェルで開きます。(ウィンドウを閉じるだけならトレイに退避し、シェルは動き続けます)" } L10nKey::QuitAndStop => "終了して停止", L10nKey::CloseSshConnectionTitle => "この SSH 接続を閉じますか?", @@ -176,7 +176,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ThemeSaveFailed => "テーマを保存できませんでした", L10nKey::OpenInFileManagerFailed => "{path} を開けませんでした", L10nKey::SettingsCustomThemesIntro => { - "テーマを複製して色を編集するか、テーマフォルダに自作テーマ(tty7 の YAML テーマまたは iTerm2 の .itermcolors スキーム)を置けます" + "テーマを複製して色を編集するか、tty7 の YAML テーマや iTerm2 の .itermcolors をテーマフォルダに置いてください" } L10nKey::SettingsDuplicateToEdit => "複製して編集", L10nKey::SettingsHosts => "ホスト", @@ -230,9 +230,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "保存されたパスワードも一緒に削除されます。同じアドレスを使う接続が他にある場合は残ります。" } L10nKey::SettingsDeleteProfileCascade => { - "{endpoint} を参照しているリモートワークスペースのエントリが {count} 件あり、\ - プロファイルと一緒にこのコンピュータから削除されます。リモートマシン上のセッションは\ - 維持され、新しいプロファイルで接続すればワークスペース一覧に再表示されます。" + "{endpoint} を参照するリモートワークスペースのエントリが {count} 件あり、一緒に削除されます。リモートマシン上のセッションは動いたままで、新しいプロファイルで接続すれば一覧に戻ります。" } L10nKey::SettingsCouldntForgetPassword => { "{endpoint} のパスワードを消去できませんでした: {error}" @@ -241,7 +239,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSecurityIntro => "ホストは詳細設定でこれらを上書きできます", L10nKey::SettingsVerifyHostKeys => "ホストキーを検証", L10nKey::SettingsVerifyHostKeysDesc => { - "接続前に各サーバーのキーを known_hosts と照合し、未知のキーや変更されたキーを確認します。オフにすると接続時に確認しないため、なりすましサーバーに気づきません" + "接続前に各サーバーのキーを known_hosts と照合します。オフでは確認しないため、なりすましサーバーに気づきません" } L10nKey::WarnBeforeClosing => "閉じる前に警告", L10nKey::SettingsWarnBeforeClosingDesc => { @@ -396,7 +394,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "このディレクトリは存在しないため、この値は保存されませんでした" } L10nKey::SettingsShellFooter => { - "継承元のないシェルに適用されます。ウィンドウの最初のタブなどです。新しいタブと分割はアクティブなペインのディレクトリを引き継ぎ、開いているシェルは動き続けます" + "継承元のないシェル(ウィンドウの最初のタブなど)に適用されます。新しいタブと分割はアクティブなペインのディレクトリを引き継ぎ、開いているシェルは動き続けます" } L10nKey::SettingsScrolling => "スクロール", L10nKey::SettingsScrollback => "スクロールバック", @@ -441,7 +439,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsOpenFilesSystem => "デフォルトアプリ", L10nKey::SettingsOpenFilesCommand => "コマンド", L10nKey::SettingsOpenFilesModeDesc => { - "ファイルリンクを {modifier}+クリックしたときに開くもの。行番号へのジャンプとリモートホスト上のファイルを開けるのは内蔵エディタだけです" + "ファイルリンクを {modifier}+クリックしたときに開くもの。行番号へのジャンプとリモートファイルを開けるのは内蔵エディタだけです" } L10nKey::LinkFileNotUnder => "{path} — {dir} にそのファイルはありません", L10nKey::LinkFileNoDirectory => { @@ -453,7 +451,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::OpenFilesWith => "ファイルを開くアプリケーション", L10nKey::SettingsOpenFilesWithDesc => { - "ファイルリンクを {modifier}+クリックで開くときに使うコマンドです。デフォルトアプリの代わりに実行します。{path}、{line}、{column} を使えます。値のないフラグは除外されます(例: herdr edit {path} --line={line})。空欄ならデフォルトアプリを使います" + "ファイルリンクを {modifier}+クリックしたときに実行するコマンド。{path}、{line}、{column} を使えます — 値のないフラグは除外されます。空欄ならデフォルトアプリ" } L10nKey::SettingsBellModeOff => "オフ", L10nKey::SettingsBellModeVisual => "視覚的(画面点滅)", @@ -465,7 +463,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::SettingsPromptEditor => "プロンプトエディター", L10nKey::SettingsPromptEditorDesc => { - "シェルプロンプトで入力する行を tty7 が持ちます — 選択、取り消し、そして下のメニュー。オフにすると、プロンプトでのキー、IME の確定、貼り付けはすべてシェルへ直接渡り、シェル自身の行エディター(zsh の ZLE、readline、fish)が編集を担当するため、そこでバインドしたキーがそのまま動きます。どちらの場合もシェル統合は有効なままです" + "シェルプロンプトで入力する行を tty7 が編集します — 選択、取り消し、下のメニュー。オフにするとシェル自身の行エディター(ZLE、readline、fish)に戻ります" } L10nKey::SettingsNeedsPromptEditor => { "プロンプトエディターが必要です。オフの間、このキーはすでにシェルのものです" @@ -476,7 +474,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::SettingsHistorySearch => "履歴検索", L10nKey::SettingsHistorySearchDesc => { - "プロンプトで ⌃R を押すと tty7 のファジー履歴メニューが開きます。オフの場合、⌃R はシェルに渡されます — シェルの逆方向検索や、シェルでバインドしたもの(fzf、percol など)" + "プロンプトで ⌃R を押すと tty7 のファジー履歴メニューが開きます。オフなら ⌃R はシェルへ — 逆方向検索や、そこでバインドしたもの(fzf、percol)" } L10nKey::SettingsSelectionClipboard => "選択とクリップボード", L10nKey::SettingsSmartSelection => "スマート選択", @@ -496,7 +494,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::SettingsAgentsIntro => "エージェント", L10nKey::SettingsAgentsIntroDesc => { - "フック統合により、これらのエージェントを実行するペインのセッション状態(作業中 / 待機中 / 完了)がタブバーに表示されます。tty7 内でのみ有効です" + "フックにより、これらのエージェントを実行するペインの状態(作業中 / 待機中 / 完了)がタブバーに表示されます。tty7 内でのみ有効" } L10nKey::SettingsReadingAgentConfig => "このマシンのエージェント設定を読み込んでいます…", L10nKey::SettingsStatusNotInstalled => "未インストール", @@ -543,7 +541,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::SettingsShowTrayIcon => "システムトレイアイコンを表示", L10nKey::SettingsShowTrayIconDesc => { - "システムトレイ / メニューバーに状態を表示します。コーディングエージェントが入力を必要とするときに通知し、そのメニューからエージェントペインへ移動できます" + "システムトレイ / メニューバーの状態表示:エージェントが入力を必要とするときに通知し、メニューからそのペインへ移動できます" } L10nKey::SettingsTabs => "タブ", L10nKey::SettingsNewTabPosition => "新規タブの表示位置", @@ -554,11 +552,11 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::SettingsSidebarGrouping => "サイドバーのグループ化", L10nKey::SettingsSidebarGroupingDesc => { - "git リポジトリごとにサイドバータブをまとめます。リポジトリ外のタブはスクラッチセクションに置くか、「リポジトリ/フォルダ別」なら作業ディレクトリごとにまとめます。左サイドバーにのみ適用" + "サイドバータブを git リポジトリごとにまとめます。リポジトリ外のタブはスクラッチに、「リポジトリ/フォルダ別」なら作業ディレクトリごとに。左サイドバーのみ" } L10nKey::SettingsDiffPreviewFromCounts => "サイドバーのカウントから Diff プレビューを開く", L10nKey::SettingsDiffPreviewFromCountsDesc => { - "行の +N −N をクリックすると、オーバーレイでワーキングツリーの Diff を開きます。オフならブランチとカウントは表示されますが、クリックできません" + "行の +N −N をクリックすると、オーバーレイでワーキングツリーの Diff を開きます。オフならカウントは表示されたまま、クリックだけできません" } L10nKey::SettingsNotifications => "通知", L10nKey::SettingsNotifyOnCommandFinish => "コマンド終了時に通知", @@ -589,7 +587,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsPressKeys => "キーを入力…", L10nKey::SettingsPauseToSaveEsc => "一時停止して保存 · Esc", L10nKey::SettingsKeybindingsIntroDesc => { - "ショートカットをクリックして新しいキーを押すと、少し間を置いて保存されます。Ctrl-B の後に X を押すようなシーケンスでは、キーを続けて入力します。Esc でキャンセル。Backspace は最後のキーを削除し、最初に押すとデフォルトに戻します" + "ショートカットをクリックして新しいキーを押すと、少し間を置いて保存されます。Ctrl-B の後に X のようなシーケンスはキーを続けて入力。Esc でキャンセル、Backspace は最後のキーを削除し、最初に押すとデフォルトに戻します" } L10nKey::SettingsPrefixNote => { "プレフィックスが有効な場合、プレフィックスキーを単独で押すと約 1 秒後にシェルに渡され、プレフィックス + 未割り当てのキーはターミナルへそのまま送信されます" @@ -634,7 +632,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsUpdateDiscard => "破棄", L10nKey::SettingsAutoDownload => "アップデートをバックグラウンドでダウンロード", L10nKey::SettingsAutoDownloadDesc => { - "新しいリリースを見つけ次第ダウンロードと検証を済ませておき、インストールは再起動するだけにします。確認なしにインストールすることはありません。従量制の回線ではオフにしてください(パッケージは約 30 MB です)" + "新しいリリースを見つけ次第ダウンロードと検証を済ませ、インストールは再起動するだけにします。確認なしにインストールすることはありません。パッケージは約 30 MB" } L10nKey::SettingsUpdateChannel => "更新チャンネル", L10nKey::SettingsUpdateChannelDesc => { @@ -644,14 +642,14 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsUpdateChannelNightly => "ナイトリー", L10nKey::SettingsDaemonStale => "バックグラウンドサーバーは {build} のままです。", L10nKey::SettingsDaemonStaleDesc => { - "tty7 はその場で更新されたため、アプリは新しくなりましたが、各ペインは以前のビルドのサーバーが処理しています。サーバーを再起動すると新しいビルドに切り替わりますが、ペインで動いているプロセス(シェル、エージェント、SSH セッション)はすべて終了します。急ぐ必要はありません。ペインが空いているときに実行してください" + "tty7 はその場で更新されました。アプリは新しく、ペインはまだ以前のビルドのサーバーが処理しています。再起動すると新しいビルドに切り替わり、ペインで動いているプロセスはすべて終了します。急ぐ必要はなく、ペインが空いているときにどうぞ" } L10nKey::UpdateDialogTitle => "アップデートがあります", L10nKey::UpdateDialogDetail => { - "tty7 {version} が利用できます(現在 {current})。インストールするとアプリが再起動します。バックグラウンドサーバーは動いたままなので、ペインで開いているものはそのまま残ります。" + "tty7 {version} が利用できます(現在 {current})。インストールするとアプリが再起動します。バックグラウンドサーバーは動いたままなので、ペインの中身は残ります" } L10nKey::UpdateDialogDetailWindows => { - "tty7 {version} が利用できます(現在 {current})。インストールするとアプリとバックグラウンドサービスが再起動します。ペインで実行中のプロセスは終了し、タブとレイアウトは新しいシェルで復元されます。" + "tty7 {version} が利用できます(現在 {current})。インストールするとアプリとバックグラウンドサービスが再起動します。ペインのプロセスは終了し、タブとレイアウトは新しいシェルで復元されます" } L10nKey::UpdateDialogDetailManual => { "tty7 {version} が利用できます(現在 {current})。{hint}" @@ -660,13 +658,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::UpdateDialogLater => "後で", L10nKey::UpdateDialogNextLaunch => "次回起動時にインストール", L10nKey::UpdateDialogNeedsElevation => { - "この tty7 は全ユーザー向けにインストールされているため、インストール開始前に Windows の管理者承認が一度だけ求められます。tty7 自体が管理者権限で実行されることはなく、あなたの権限のまま再起動します。" + "tty7 は全ユーザー向けにインストールされているため、インストール前に Windows の管理者承認が一度求められます。tty7 自体が管理者権限で実行されることはありません" } L10nKey::SettingsUpdateCheckFailed => "アップデートを確認できませんでした: {error}", L10nKey::SettingsUpdatePrepareFailed => "アップデートに失敗しました: {error}", L10nKey::SettingsUpdateLaunchFailed => "インストーラーを起動できませんでした: {error}", L10nKey::SettingsUpdateUnsupportedMacos => { - "この tty7 は書き込み可能な tty7.app バンドルから実行されていないため、そのまま置き換えるのは安全ではありません。tty7 を「アプリケーション」など書き込み可能なフォルダへ移動するか、リリースページを開いてアップデートをインストールしてください" + "この tty7 は書き込み可能な tty7.app バンドルにないため、自分自身を置き換えられません。「アプリケーション」へ移動するか、リリースページから更新してください" } L10nKey::SettingsUpdateUnsupportedLinux => { "このアーキテクチャ向けの Linux パッケージはリリースにありません。ソースからビルドするか、パッケージマネージャーをご利用ください" @@ -675,10 +673,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "Linux は手動で更新します。リリースページから {name} をダウンロードするか、パッケージマネージャーをご利用ください" } L10nKey::SettingsUpdateUnsupportedWindows => { - "Windows の自動更新は、認識可能な Inno Setup 版とポータブル ZIP 版に対応しています。この tty7 には有効なインストール情報・アップデーター・書き込み可能なポータブルディレクトリのいずれかが見つからないため、リリースページを開いて手動で更新してください" + "この tty7 は認識可能な Inno Setup 版でもポータブル ZIP 版でもないため、自動更新できません。リリースページを開いて手動で更新してください" } L10nKey::SettingsUpdateWindowsAllUsers => { - "tty7 はすべてのユーザー向けにインストールされているため、置き換えには管理者権限が必要です。tty7 が自ら昇格を要求することはありません。リリースページを開き、インストーラーを手動で実行して更新してください" + "tty7 はすべてのユーザー向けにインストールされており、置き換えには管理者権限が必要ですが、tty7 は自ら昇格を要求しません。リリースページからインストーラーを実行して更新してください" } L10nKey::SettingsUpdateUnsupportedPlatform => { "このプラットフォームでは自動インストールを利用できません。リリースページを開いてください" @@ -696,16 +694,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsCheckUpdatesOnLaunch => "起動時にアップデートを確認", L10nKey::SettingsCommandLine => "コマンドライン", L10nKey::SettingsCommandLineDesc => { - "起動時に同梱の `tty7` コマンドを PATH に入れ、スクリプトやコーディングエージェントが任意のターミナルから tty7 を操作できるようにします。tty7 のペイン内ではどちらでも機能します。自分でビルド・インストールした `tty7` を上書きされたくない場合はオフにしてください。次回起動時に有効になります" + "同梱の tty7 コマンドを PATH に入れ、スクリプトやエージェントが任意のターミナルから tty7 を操作できるようにします(ペイン内ではどちらでも動きます)。自分でビルドした tty7 を優先したい場合はオフに。次回起動時に有効" } L10nKey::SettingsInstallCliOnPath => "`tty7` コマンドを PATH にインストール", L10nKey::SettingsServer => "デーモンサーバー", L10nKey::SettingsServerDesc => { "シェルを動かし続けているバックグラウンドサーバーを再起動します。このコンピュータ上のすべてのシェルが終了し、タブとレイアウトは新しいシェルで開き直します" } - L10nKey::SettingsHowShellsWorkBody => { - "シェルはこのウィンドウの中ではなく、バックグラウンドのサーバーで動いています。tty7 を終了してもシェルは動き続け、開き直せばタブ・レイアウト・作業ディレクトリが同じシェルのまま戻ります。タブを閉じるとそのシェルは終了し、「サーバーを再起動」と「終了してサーバーを停止」はすべてのシェルを終了します。" - } L10nKey::SettingsRestartServer => "サーバーを再起動…", L10nKey::SettingsAppHttpProxy => "アップデート用プロキシ", L10nKey::SettingsAppHttpProxyDesc => { @@ -805,10 +800,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchHostsKeywords => { "ssh ホスト 接続 保存 プロファイル インポート ssh_config 管理 追加 編集 クイック接続 hosts ssh profile import connect manage" } - L10nKey::SettingsSearchHowShellsWorkKeywords => { - "シェル セッション デーモン サーバー デタッチ 永続化 バックグラウンド 閉じる 終了 停止 削除 ワークスペース レイアウト 再起動 tmux how shells work shell daemon persist survive reboot" - } - L10nKey::SettingsSearchHowShellsWorkTitle => "シェルの仕組み", L10nKey::SettingsSearchItalicFontKeywords => "タイプフェイス 斜体 italic oblique typeface", L10nKey::SettingsSearchKeybindingsKeywords => { "ショートカット ホットキー キーボード バインディング コード tmux プリセット 再バインド プレフィックス keybindings shortcut hotkey binding chord prefix" @@ -1194,13 +1185,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "… さらに変更されたファイル {count} 個 — ターミナルで `git diff` を実行して確認してください" } L10nKey::DiffOversizedNotice => { - "このワーキングツリーは大きすぎて効率的に描画できません({summary})。すべてのファイルは折りたたまれています — 個々のファイルを展開するか、ターミナルで `git diff` を実行してください" + "このワーキングツリーは大きすぎて描画できません({summary})。すべて折りたたんであります — 個別に展開するか、ターミナルで `git diff` を実行してください" } L10nKey::DiffTruncatedPerFile => { "Diff は {limit} 行で切り詰められました — 残りはターミナルで `git diff` を実行してください" } L10nKey::DiffTruncatedBudget => { - "差分の内容は読み込まれていません — このワーキングツリーは tty7 の Diff 予算を超えています。ターミナルでこのファイルの `git diff` を実行してください" + "内容は読み込まれていません — tty7 の Diff 予算を超えています。ターミナルで `git diff` を実行してください" } L10nKey::DiffUntrackedHeader => "未追跡ファイル ({count})", L10nKey::DiffMoreUntracked => { @@ -1248,8 +1239,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::RemoteStripFailed => "{machine} に未接続です — {error}", L10nKey::RemoteStripRouteLost => "{machine} の接続設定は存在しません — 再接続できません", L10nKey::RemoteRouteParkedHint => { - "接続設定が存在しないため、自動再接続しません。リモートのセッションは残っています — \ - 新しいプロファイルでこのマシンに接続すると、ワークスペース一覧に再表示されます。" + "接続設定が存在しないため、自動再接続しません。リモートのセッションは残っています — 新しいプロファイルで接続すると、ワークスペース一覧に戻ります。" } L10nKey::RemoteNoticePreempted => "別の場所で開かれました — 入力しても反映されません", L10nKey::RemoteNoticeDisconnected => "未接続です — 入力しても反映されません", @@ -1259,13 +1249,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::RemoteActionRetry => "再試行", L10nKey::RemoteActionRemoveEntry => "エントリを削除", L10nKey::RemoteNoConnectionDetails => { - "このウィンドウは {machine} 上のワークスペースですが、tty7 には接続情報がありません。SSH プロファイルか ~/.ssh/config に項目があるか確認してください" + "このウィンドウは {machine} 上のワークスペースですが、tty7 に接続情報がありません。SSH プロファイルか ~/.ssh/config の項目が残っているか確認してください" } L10nKey::RemoteThisComputer => "このコンピュータ", L10nKey::RemoteProfileGone => "削除されたプロファイル", L10nKey::RemoteRestartTitle => "「{machine}」上の tty7 サーバーを再起動しますか?", L10nKey::RemoteRestartBody => { - "これにより {machine} 上のすべてのシェルが停止します。表示されていないものも含め、実行中のものはすべて終了します。ワークスペースとレイアウトは保持され、新しいシェルで開きます" + "{machine} 上のシェルは、表示されていないものも含めてすべて終了します。ワークスペースとレイアウトは保持され、新しいシェルで開きます" } L10nKey::RemoteReplaceBody => { "tty7 は {machine} に対応するサーバーをインストールして起動します。\n\n{machine} で実行中のすべてのセッションが終了します。このウィンドウが接続していないセッションも含みます" @@ -1292,7 +1282,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::RemoteInstallBytes => "バイト", L10nKey::RemoteMismatchTitle => "「{machine}」上の tty7 サーバーを更新しますか?", L10nKey::RemoteMismatchDetail => { - "{machine} は {running} から tty7 セッションを提供していますが、このクライアント({wanted})はそのプロトコルを理解できません。tty7 は対応するサーバーをそこにインストール済みですが、セッションは実行中のサーバー上にあります。\n\n{replace_server}\u{2003}を選ぶと {wanted} に置き換えられ、そのサーバー上のセッションはすべて終了します。\n{cancel}\u{2003}を選ぶと {machine} はそのままです。このウィンドウは接続しません" + "{machine} はサーバー {running} で動いていますが、このクライアント({wanted})はそのプロトコルを話せません。対応するサーバーはインストール済みですが、セッションは実行中のサーバー上にあります。\n\n{replace_server}\u{2003}{wanted} に置き換え、そのサーバー上のセッションをすべて終了します。\n{cancel}\u{2003}{machine} はそのままです。このウィンドウは接続しません" } L10nKey::RemoteMismatchReplaceServer => "サーバーを更新", L10nKey::RemoteMismatchDowngradeServer => "サーバーを置き換え", @@ -1311,7 +1301,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "tty7 のローカルサーバーに到達できませんでした: {error}" } L10nKey::RemoteDaemonTooOld => { - "このマシンの tty7 デーモンは古いビルドのため、{machine} 上のサーバーを再起動できません。tty7 を終了(デーモンが停止します)して開き直し、再試行してください" + "このマシンのデーモンは古いビルドのため、{machine} 上のサーバーを再起動できません。tty7 を終了(デーモンも停止します)して開き直し、再試行してください" } L10nKey::RemoteProfileMissing => "その保存済み SSH プロファイルはもう存在しません", L10nKey::RemoteAliasMissing => "`{alias}` は ~/.ssh/config にありません", @@ -1396,7 +1386,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::IoBusy => "他のプログラムが使用中です。", L10nKey::IoTimedOut => "時間内に応答がありませんでした。", L10nKey::TreeWindowOpenedEmpty => { - "このウィンドウのサーバーがタブを渡さなかったため、空のまま開きました。失われたものはなく、応答すれば戻ります。戻らない場合はコマンドパレットの「サーバーを再起動」を実行してください" + "サーバーがこのウィンドウのタブを渡さなかったため、空のまま開きました。失われたものはなく、応答すれば戻ります。戻らない場合はコマンドパレットの「サーバーを再起動」を実行してください" } L10nKey::CmdGroupTabsPanes => "タブとペイン", L10nKey::CmdGroupWorkspaces => "ワークスペース", @@ -1510,35 +1500,35 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "バックグラウンドサーバーを再起動できませんでした: {error}" } L10nKey::AppRestartServerMismatchDetail => { - "サーバーは v{build}、プロトコル {protocol}。このアプリは {ours} です。噛み合わないため、タブを取り出せません。\n\n終了:何も変わりません。サーバーもシェルも動き続けます。\n再起動:タブは新しいシェルで戻り、いま実行中のものは終了します" + "サーバーはプロトコル {protocol}(ビルド v{build})、このアプリは {ours} のため、タブを取り出せません。\n\n終了:何も変わりません。サーバーもシェルも動き続けます。\n再起動:タブは新しいシェルで戻り、いま実行中のものは終了します" } L10nKey::AppRestartServerDialectDetail => { - "サーバーは v{build}:制御方言 v{dialect}、このアプリは v{ours} です。タブを渡せないため、ウィンドウはどれも空のまま開きます。\n\n終了:何も変わりません。サーバーもシェルも動き続けます。\n再起動:タブは新しいシェルで戻り、いま実行中のものは終了します" + "サーバーは制御方言 v{dialect}(ビルド v{build})、このアプリは v{ours} のため、ウィンドウはどれも空で開きます。\n\n終了:何も変わりません。サーバーもシェルも動き続けます。\n再起動:タブは新しいシェルで戻り、いま実行中のものは終了します" } L10nKey::AppRestartServerDialectNewerDetail => { - "サーバーは v{build}:制御方言 v{dialect}、このアプリは v{ours} です。タブを渡せないため、ウィンドウはどれも空のまま開きます。\n\n終了して新しいビルドを入れる:根本的な解決で、シェルはそのまま残ります。\n再起動:タブは新しいシェルで戻り、いま実行中のものは終了します" + "サーバーは制御方言 v{dialect}(ビルド v{build})、このアプリは v{ours} のため、ウィンドウはどれも空で開きます。\n\n終了して新しいビルドを入れる:根本的な解決で、シェルはそのまま残ります。\n再起動:タブは新しいシェルで戻り、いま実行中のものは終了します" } L10nKey::AppRestartServerOldDetail => { "サーバーはバージョン照合より前のもので、何を話すか分かりません。\n\n終了:何も変わりません。サーバーもシェルも動き続けます。\n再起動:タブは新しいシェルで戻り、いま実行中のものは終了します" } L10nKey::AppRestart => "再起動", L10nKey::AppRestartServerNoServer => { - "{label} には再起動できる tty7 自身のサーバーがありません。これはこのコンピュータが --stdio で実行しているプログラムです。代わりにそのワークスペースを止めてください" + "{label} には再起動できるサーバーがありません。このコンピュータが --stdio で実行しているプログラムです。代わりにワークスペースを止めてください" } L10nKey::AppRestartServerBody => { - "このコンピュータで実行中のすべてのシェルが停止します。タブとレイアウトは保持され、新しいシェルで開きます" + "このコンピュータのシェルはすべて終了します。タブとレイアウトは保持され、新しいシェルで開きます" } L10nKey::ConfigQuarantinedStartup => { - "config.json を解析できなかったため、デフォルト設定で実行しており、ファイルを上書きすることもありません。内容は config.json.corrupt として残しました——修正すれば自動で再読み込みされます。それまでは設定での変更は保存されません" + "config.json を解析できませんでした。デフォルト設定で実行しており、内容は config.json.corrupt として残しました。直せば自動で再読み込みされます。それまで設定の変更は保存されません" } L10nKey::ConfigQuarantinedReload => { - "編集された config.json を解析できなかったため、実行中の設定をそのまま保持し、ファイルの内容は config.json.corrupt として残しました。修正すれば自動で再読み込みされます。それまでに設定を保存すると、実行中の設定で上書きされます" + "編集された config.json を解析できませんでした。実行中の設定を保持し、内容は config.json.corrupt として残しました。直せば自動で再読み込みされます。それまでに設定を保存すると上書きされます" } L10nKey::ConfigUnreadableStartup => { - "config.json を読み込めなかったため、デフォルト設定で実行しており、ファイルを上書きすることもありません——ファイルはそのままです。権限か内容を直せば自動で再読み込みされます。それまでは設定での変更は保存されません" + "config.json を読み込めませんでした。デフォルト設定で実行しており、ファイルはそのままです。権限か内容を直せば自動で再読み込みされます。それまで設定の変更は保存されません" } L10nKey::ConfigUnreadableReload => { - "config.json を読み込めなかったため、実行中の設定をそのまま保持し、ファイルもそのままにしてあります。権限か内容を直せば自動で再読み込みされます。それまでに設定を保存すると、実行中の設定で上書きされます" + "config.json を読み込めませんでした。実行中の設定を保持し、ファイルもそのままです。権限か内容を直せば自動で再読み込みされます。それまでに設定を保存すると上書きされます" } L10nKey::AppWorktreeRemoveDetailDirty => { "閉じたタブの {path} にあるワークツリーには未コミットの変更があります" @@ -1674,35 +1664,23 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::Replace => "置き換える", L10nKey::SftpErrorInvalidOctalMode => "無効な 8 進数モードです", L10nKey::SettingsDaemonStaleDescInPlace => { - "tty7 はその場で更新されたため、アプリは新しくても、ペインは前のビルドが提供したままです。\ - サーバーは停止せずに新しいビルドへ自分自身を置き換えられます。\ - シェルとその中で動いているものはそのまま引き継がれます。\ - tty7 内蔵の SSH クライアントを使うペインだけは例外で、その接続は閉じられ、開き直しが必要です" + "tty7 はその場で更新されました。アプリは新しく、ペインはまだ前のビルドで動いています。サーバーは停止せずに新しいビルドへ置き換えられるので、シェルはそのまま引き継がれます。tty7 内蔵の SSH クライアントを使うペインだけは例外で、その接続は閉じられ、開き直しが必要です" } L10nKey::AppRestartServerBodyInPlace => { - "バックグラウンドサーバーは停止せずに、自分自身をこのビルドに置き換えます。\ - シェルは動いたままで、ペイン内のコマンド・エージェント・`ssh` セッションは中断されません。\ - ウィンドウはすぐに再接続します。\ - tty7 内蔵の SSH クライアントを使うペインだけは例外で、その接続は閉じられ、開き直しが必要です" + "サーバーは停止せずに自分自身をこのビルドへ置き換えます。シェルは動いたままで、ウィンドウはすぐに再接続します。tty7 内蔵の SSH クライアントを使うペインだけは例外で、その接続は閉じられ、開き直しが必要です" } L10nKey::PaneRestoredScreenBanner => { "復元された画面 — 以下は新しいシェルで、これより上のものは動いていません" } L10nKey::SettingsPerPaneHistory => "ペインごとに独自のシェル履歴を持たせる", L10nKey::SettingsPerPaneHistoryDescription => { - "上キーでたどるのは、すべてのペインが混ざったものではなく、このペインで実行したコマンドです。\ - 新しいペインは空ではなく既存の履歴から始まり、追加された分はペインを閉じるときに書き戻されるので失われません。\ - tty7 が設定できる bash と zsh のペインが対象で、独自の引数で起動したシェルはそのままです" + "上キーでたどるのは、全ペインが混ざったものではなくこのペインで実行したコマンドです。新しいペインは既存の履歴から始まり、追加分は閉じるときに書き戻されます。対象は tty7 が設定できる bash と zsh のペインで、独自の引数で起動したシェルはそのままです" } L10nKey::IntegrationNoticeBlocked => { - "このペインでは tty7 シェル統合がブロックされています。“{wrapper}”がシェルレポートを\ - 横取りしているため、インライン補完と Ctrl+R メニューは利用できません。\ - シェル独自の履歴検索は引き続き使えます。" + "“{wrapper}”がこのペインのシェルレポートを横取りしているため、インライン補完と Ctrl+R メニューは使えません。シェル独自の履歴検索は引き続き使えます。" } L10nKey::IntegrationNoticeNotEngaged => { - "このペインでは tty7 シェル統合が有効になっていないため、インライン補完と Ctrl+R \ - メニューは利用できません。独自の引数で起動したシェル、PTY ラッパー(figterm 系)、\ - 未対応のシェル設定が原因の可能性があります。" + "このペインでは tty7 シェル統合が有効になっておらず、インライン補完と Ctrl+R メニューは使えません。よくある原因は、独自の引数で起動したシェル、PTY ラッパー、未対応のシェルです。" } L10nKey::PaneTitleDisconnected => "{title} — 切断されました", L10nKey::PaneTitleProcessExited => "{title} — プロセスが終了しました", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 7ac2174e..4e17e6b0 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -553,7 +553,6 @@ l10n_keys! { SettingsInstallCliOnPath, SettingsServer, SettingsServerDesc, - SettingsHowShellsWorkBody, SettingsRestartServer, SettingsAppHttpProxy, SettingsAppHttpProxyDesc, @@ -597,8 +596,6 @@ l10n_keys! { SettingsSearchHideMouseWhileTypingKeywords, SettingsSearchHistorySearchKeywords, SettingsSearchHostsKeywords, - SettingsSearchHowShellsWorkKeywords, - SettingsSearchHowShellsWorkTitle, SettingsSearchItalicFontKeywords, SettingsSearchKeybindingsKeywords, SettingsSearchKeybindingsTitle, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index b6312ea9..aeb8e41d 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -55,7 +55,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::Close => "关闭", L10nKey::QuitStopServerTitle => "退出并停止 server?", L10nKey::QuitStopServerBody => { - "这会退出 tty7 并停止后台 server,所有仍在运行的 shell 都会被终止。你的标签页和布局会被保留,下次启动时以全新的 shell 重新打开。(关闭窗口只会把应用收起到托盘,shell 保持运行。)" + "这会退出 tty7 并停止后台 server,shell 里正在跑的东西都会被终止。标签页和布局会保留,下次启动时以全新的 shell 打开。(只关窗口的话应用收进托盘,shell 继续跑。)" } L10nKey::QuitAndStop => "退出并停止", L10nKey::CloseSshConnectionTitle => "关闭这个 SSH 连接?", @@ -156,7 +156,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ThemeSaveFailed => "无法保存主题", L10nKey::OpenInFileManagerFailed => "无法打开 {path}", L10nKey::SettingsCustomThemesIntro => { - "复制一个主题后可在此编辑其颜色,或者把自定义主题放入主题文件夹:tty7 YAML 主题或 iTerm2 的 .itermcolors 方案。" + "复制一个主题即可在此编辑颜色,或把 tty7 YAML 主题、iTerm2 .itermcolors 文件放进主题文件夹。" } L10nKey::SettingsDuplicateToEdit => "复制以编辑", L10nKey::SettingsHosts => "主机", @@ -204,15 +204,14 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "为它保存的密码也会一并删除,除非还有别的连接用着同一个地址。" } L10nKey::SettingsDeleteProfileCascade => { - "有 {count} 个已保存的远程工作区条目指向 {endpoint},将随配置一并从本机清除;\ - 远端机器上的会话不受影响,新建配置连上同一台机器后即可在工作区列表找回。" + "有 {count} 个已保存的远程工作区条目指向 {endpoint},会一并从本机清除。远端机器上的会话照常跑——新建配置连上去就能在工作区列表里找回。" } L10nKey::SettingsCouldntForgetPassword => "无法清除 {endpoint} 的已保存密码:{error}", L10nKey::SettingsSecurity => "安全", L10nKey::SettingsSecurityIntro => "主机可以在自己的高级选项中覆盖这些设置。", L10nKey::SettingsVerifyHostKeys => "校验主机密钥", L10nKey::SettingsVerifyHostKeysDesc => { - "在连接前对照 known_hosts 检查每台服务器的密钥,并确认未知或已更改的密钥。关闭后连接不做检查,被仿冒的服务器也不会被察觉。" + "连接前对照 known_hosts 检查服务器密钥。关闭后不做检查,被仿冒的服务器也不会被察觉。" } L10nKey::WarnBeforeClosing => "关闭前警告", L10nKey::SettingsWarnBeforeClosingDesc => { @@ -341,7 +340,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsWdCustom => "自定义", L10nKey::SettingsWdPathInvalid => "这个目录不存在,该值未保存。", L10nKey::SettingsShellFooter => { - "仅适用于没有可继承目录的 shell,例如窗口的第一个标签页。新标签页和分屏仍会继承活动窗格的目录,已经打开的 shell 会继续运行。" + "仅适用于没有目录可继承的 shell,例如窗口的第一个标签页。新标签页和分屏仍继承活动窗格的目录,已打开的 shell 继续运行。" } L10nKey::SettingsScrolling => "滚动", L10nKey::SettingsScrollback => "回滚行数", @@ -377,7 +376,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsOpenFilesSystem => "默认应用", L10nKey::SettingsOpenFilesCommand => "自定义命令", L10nKey::SettingsOpenFilesModeDesc => { - "{modifier}+点击 文件链接时用什么打开。只有内置编辑器能跳到指定行,也只有它能打开远程主机上的文件。" + "{modifier}+点击 文件链接时用什么打开。只有内置编辑器能跳到指定行、能打开远程主机上的文件。" } L10nKey::LinkFileNotUnder => "{path} —— {dir} 下没有这个文件", L10nKey::LinkFileNoDirectory => { @@ -387,7 +386,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::LinkDirOutsideTree => "{path} —— 它在另一台机器上,也不在文件面板打开的任何目录里", L10nKey::OpenFilesWith => "打开文件方式", L10nKey::SettingsOpenFilesWithDesc => { - "{modifier}+点击 文件链接时运行的命令,而不是默认应用。可使用 {path}、{line}、{column};参数值缺失的标志会被丢弃(例如 herdr edit {path} --line={line})。留空使用默认应用。" + "{modifier}+点击 文件链接时运行的命令。可用 {path}、{line}、{column}——取值缺失的标志会被丢弃。留空则用默认应用。" } L10nKey::SettingsBellModeOff => "关", L10nKey::SettingsBellModeVisual => "闪烁", @@ -399,7 +398,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { } L10nKey::SettingsPromptEditor => "提示符编辑器", L10nKey::SettingsPromptEditorDesc => { - "由 tty7 接管你在 shell 提示符上敲的这一行:选择、撤销,以及下面这些菜单。关闭后,提示符处的每个按键、输入法上屏和粘贴都直接交给 shell,由它自己的行编辑器——zsh 的 ZLE、readline、fish——负责编辑,你在那里绑定的键位照常生效。两种模式下 shell 集成都保持开启。" + "由 tty7 编辑你在 shell 提示符上敲的这一行:选择、撤销,以及下面这些菜单。关闭后交还给 shell 自己的行编辑器——ZLE、readline、fish。" } L10nKey::SettingsNeedsPromptEditor => { "需要提示符编辑器:它关闭时,这个按键本就归 shell 所有。" @@ -410,7 +409,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { } L10nKey::SettingsHistorySearch => "历史搜索", L10nKey::SettingsHistorySearchDesc => { - "在提示符按 ⌃R 打开 tty7 的模糊历史菜单。关闭后 ⌃R 交由 shell 处理——它自带的反向搜索,或你在那里绑定的其它功能(fzf、percol)。" + "在提示符按 ⌃R 打开 tty7 的模糊历史菜单。关闭后 ⌃R 交给 shell——它自带的反向搜索,或你绑定的其它功能(fzf、percol)。" } L10nKey::SettingsSelectionClipboard => "选择与剪贴板", L10nKey::SettingsSmartSelection => "智能选择", @@ -428,7 +427,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { } L10nKey::SettingsAgentsIntro => "Agents", L10nKey::SettingsAgentsIntroDesc => { - "hook 集成让标签栏中的窗格实时显示这些 agent 的会话状态(进行中 / 等待中 / 已完成)。仅在 tty7 内生效。" + "hook 让跑这些 agent 的窗格在标签栏实时显示状态(进行中 / 等待中 / 已完成)。仅在 tty7 内生效。" } L10nKey::SettingsReadingAgentConfig => "正在读取这台机器的 agent 配置…", L10nKey::SettingsStatusNotInstalled => "未安装", @@ -471,7 +470,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { } L10nKey::SettingsShowTrayIcon => "显示托盘图标", L10nKey::SettingsShowTrayIconDesc => { - "在系统托盘/菜单栏保留状态项:当编码 agent 需要输入时发出提示,其菜单可跳转到该 agent 的窗格。" + "在系统托盘/菜单栏保留状态项:agent 需要输入时发出提示,菜单可跳到该 agent 的窗格。" } L10nKey::SettingsTabs => "标签页", L10nKey::SettingsNewTabPosition => "新标签页位置", @@ -480,11 +479,11 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsTabBarPositionDesc => "将标签页显示为顶部横向条或左侧垂直侧栏。", L10nKey::SettingsSidebarGrouping => "侧栏分组", L10nKey::SettingsSidebarGroupingDesc => { - "按 git 仓库在标题下对侧栏标签页分组;非仓库标签页放在“草稿”分组,选“按仓库或文件夹”时则按其工作目录分组。仅适用于左侧栏。" + "按 git 仓库给侧栏标签页分组。仓库外的标签页归到“草稿”,选“按仓库或文件夹”时则按工作目录分。仅左侧栏。" } L10nKey::SettingsDiffPreviewFromCounts => "从侧栏计数打开 diff 预览", L10nKey::SettingsDiffPreviewFromCountsDesc => { - "点击行上的 +N −N 可在浮层中打开 worktree diff。关闭时行上仍显示分支和计数,但不再可点击。" + "点击行上的 +N −N 在浮层中打开 worktree diff。关闭后计数仍显示,只是不可点击。" } L10nKey::SettingsNotifications => "通知", L10nKey::SettingsNotifyOnCommandFinish => "命令完成时通知", @@ -513,7 +512,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsPressKeys => "按下按键…", L10nKey::SettingsPauseToSaveEsc => "暂停以保存 · Esc", L10nKey::SettingsKeybindingsIntroDesc => { - "点击某个快捷键,然后按下新按键,短暂停顿后便会保存。可连续按键组成序列,例如 Ctrl-B 后按 X。Esc 取消;Backspace 移除最后一个按键,若最先按下则重置为默认。" + "点击某个快捷键,再按下新按键,短暂停顿后保存。连续按键可组成序列,例如 Ctrl-B 后按 X。Esc 取消;Backspace 移除最后一个按键,最先按下则重置为默认。" } L10nKey::SettingsPrefixNote => { "启用前缀后,单独按前缀键约 1 秒后会传给 shell,前缀 + 未绑定的按键会直接发送到终端。" @@ -550,7 +549,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsUpdateDiscard => "丢弃", L10nKey::SettingsAutoDownload => "后台下载更新", L10nKey::SettingsAutoDownloadDesc => { - "发现新版本就先下载并校验好,安装时只需重启一下。不会未经确认就安装。用移动流量时可以关掉——安装包约 30 MB。" + "发现新版本就先下载并校验好,安装时只需重启一下。不会未经确认就安装。安装包约 30 MB。" } L10nKey::SettingsUpdateChannel => "更新通道", L10nKey::SettingsUpdateChannelDesc => { @@ -560,27 +559,27 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsUpdateChannelNightly => "每夜构建", L10nKey::SettingsDaemonStale => "后台 server 仍运行在 {build}。", L10nKey::SettingsDaemonStaleDesc => { - "tty7 是原地升级的,界面已经是新版本,但各个 pane 仍由旧版本的后台 server 托管。重启 server 才能用上新版本,代价是 pane 里正在跑的进程全部结束——shell、agent、SSH 会话都算。不急,挑个 pane 空闲的时候再重启。" + "tty7 是原地升级的:界面已是新版,pane 还由旧版 server 托管。重启 server 换成新版,代价是 pane 里正在跑的进程全部结束。不急,挑 pane 空闲时再重启。" } L10nKey::UpdateDialogTitle => "有可用更新", L10nKey::UpdateDialogDetail => { - "tty7 {version} 已发布,你现在是 {current}。安装会重启应用;后台 server 不动,pane 里开着的东西都还在。" + "tty7 {version} 已发布,你现在是 {current}。安装会重启应用;后台 server 不动,pane 里的东西都还在。" } L10nKey::UpdateDialogDetailWindows => { - "tty7 {version} 已发布,你现在是 {current}。安装会重启应用和后台 server:pane 里正在运行的进程会被结束,标签页和布局会以全新的 shell 恢复。" + "tty7 {version} 已发布,你现在是 {current}。安装会重启应用和后台 server:pane 里的进程会被结束,标签页和布局以全新的 shell 恢复。" } L10nKey::UpdateDialogDetailManual => "tty7 {version} 已发布,你现在是 {current}。{hint}", L10nKey::UpdateDialogCannotSelfUpdate => "这份安装无法自行更新。", L10nKey::UpdateDialogLater => "以后再说", L10nKey::UpdateDialogNextLaunch => "下次启动时安装", L10nKey::UpdateDialogNeedsElevation => { - "这份 tty7 是为所有用户安装的,开始安装前 Windows 会请求一次管理员批准。tty7 本身不会以管理员身份运行——重启后仍以你的身份回来。" + "tty7 是为所有用户安装的,安装前 Windows 会请求一次管理员批准。tty7 本身不会以管理员身份运行。" } L10nKey::SettingsUpdateCheckFailed => "无法检查更新:{error}", L10nKey::SettingsUpdatePrepareFailed => "更新失败:{error}", L10nKey::SettingsUpdateLaunchFailed => "无法启动安装程序:{error}", L10nKey::SettingsUpdateUnsupportedMacos => { - "当前副本并非从可写的 tty7.app 包运行,直接替换并不安全。请将 tty7 移到“应用程序”或其他可写文件夹,或者打开发布页面安装更新。" + "当前副本不在可写的 tty7.app 包里,无法自我替换。请把 tty7 移到“应用程序”,或打开发布页面更新。" } L10nKey::SettingsUpdateUnsupportedLinux => { "发布版本中没有适用于该架构的 Linux 包。请自行从源码构建,或使用包管理器。" @@ -589,10 +588,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "Linux 需要手动更新。请到发布页面下载 {name},或使用包管理器。" } L10nKey::SettingsUpdateUnsupportedWindows => { - "Windows 自动更新适用于可识别的 Inno Setup 安装版和便携 ZIP 版。当前副本缺少有效的安装标记、更新程序或可写的便携目录,请打开发布页面手动更新。" + "当前副本不是可识别的 Inno Setup 安装版或便携 ZIP 版,无法自我更新。请打开发布页面手动更新。" } L10nKey::SettingsUpdateWindowsAllUsers => { - "tty7 是为所有用户安装的,替换它需要管理员权限。tty7 不会自行弹出提权请求,请打开发布页面并自行运行安装程序进行更新。" + "tty7 是为所有用户安装的,替换需要管理员权限,而 tty7 不会自行提权。请打开发布页面,自行运行安装程序更新。" } L10nKey::SettingsUpdateUnsupportedPlatform => "此平台不支持自动安装,请打开发布页面。", L10nKey::SettingsUpdateMissingPackage => { @@ -606,15 +605,12 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsCheckUpdatesOnLaunch => "启动时检查更新", L10nKey::SettingsCommandLine => "命令行", L10nKey::SettingsCommandLineDesc => { - "启动时将自带的 tty7 命令加入 PATH,让脚本和编码 agent 可在任意终端驱动 tty7。在 tty7 窗格内两种情况都可用。如果你自己构建或安装了 tty7 且不希望被遮蔽,请关闭此选项。下次启动时生效。" + "把自带的 tty7 命令加入 PATH,让脚本和 agent 能从任意终端驱动 tty7——在 tty7 窗格内两种情况都可用。自己构建的 tty7 不想被遮蔽就关掉。下次启动生效。" } L10nKey::SettingsInstallCliOnPath => "将 `tty7` 命令安装到 PATH", L10nKey::SettingsServer => "Server", L10nKey::SettingsServerDesc => { - "重启在后台维持 shell 运行的 server。这会结束这台计算机上所有正在运行的 shell;你的标签页和布局会以全新的 shell 重新打开。" - } - L10nKey::SettingsHowShellsWorkBody => { - "你的 shell 跑在后台 server 里,不在这个窗口里。退出 tty7 后它们照常运行——再打开时标签页、布局和工作目录都会回来,里面还是原来那些 shell。关闭标签页会结束它那个 shell;“重启 server”和“退出并停止 server”会结束全部。" + "重启在后台维持 shell 运行的 server。这台计算机上所有 shell 都会结束;标签页和布局会以全新的 shell 重新打开。" } L10nKey::SettingsRestartServer => "重启 server…", L10nKey::SettingsAppHttpProxy => "更新代理", @@ -711,10 +707,6 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchHostsKeywords => { "主机 SSH 连接 保存 主机配置 配置文件 导入 ssh_config 管理 添加 编辑 快速连接 hosts ssh profile import connect" } - L10nKey::SettingsSearchHowShellsWorkKeywords => { - "Shell工作原理 shell 会话 守护进程 持久化 后台 工作区 布局 survive reboot daemon how shells work" - } - L10nKey::SettingsSearchHowShellsWorkTitle => "Shell 工作原理", L10nKey::SettingsSearchItalicFontKeywords => "斜体 字体样式 italic oblique typeface", L10nKey::SettingsSearchKeybindingsKeywords => { "按键绑定 快捷键 热键 键盘 绑定 前缀 tmux keybindings shortcut hotkey binding prefix" @@ -1073,13 +1065,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::DiffUntrackedCount => " · {count} 个未跟踪文件", L10nKey::DiffMoreFiles => "…还有 {count} 个变更文件——在终端中运行 git diff 查看。", L10nKey::DiffOversizedNotice => { - "此 worktree 太大,无法高效渲染({summary})。每个文件都已折叠——可展开单个文件,或在终端中运行 git diff。" + "此 worktree 太大,渲染不动({summary})。每个文件都已折叠——可逐个展开,或在终端运行 git diff。" } L10nKey::DiffTruncatedPerFile => { "diff 在 {limit} 行处截断——在终端中运行 git diff 查看其余部分。" } L10nKey::DiffTruncatedBudget => { - "内容未加载——此 worktree 已超出 tty7 的 diff 预算。在终端中运行 git diff 查看此文件。" + "内容未加载——已超出 tty7 的 diff 预算。在终端运行 git diff 查看此文件。" } L10nKey::DiffUntrackedHeader => "未跟踪文件 ({count})", L10nKey::DiffMoreUntracked => "…还有 {count} 个——在终端中运行 git status 查看。", @@ -1123,8 +1115,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::RemoteStripFailed => "未连接到 {machine}——{error}", L10nKey::RemoteStripRouteLost => "{machine} 的连接配置已不存在,无法重连", L10nKey::RemoteRouteParkedHint => { - "其连接配置已不存在,不会再自动重连。远端会话仍在——\ - 新建配置连上该机器后,可在工作区列表中找回。" + "其连接配置已不存在,不会再自动重连。远端会话仍在——新建配置连上去就能在工作区列表里找回。" } L10nKey::RemoteNoticePreempted => "已在别处打开——输入无效", L10nKey::RemoteNoticeDisconnected => "未连接——输入无效", @@ -1134,15 +1125,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::RemoteActionRetry => "重试", L10nKey::RemoteActionRemoveEntry => "移除条目", L10nKey::RemoteNoConnectionDetails => { - "此窗口是 {machine} 上的工作区,但 tty7 已没有它的连接详情——\ - 请检查其 SSH 主机配置或 ~/.ssh/config 条目是否仍然存在。" + "此窗口是 {machine} 上的工作区,但 tty7 没有它的连接信息了——检查其 SSH 配置或 ~/.ssh/config 条目是否还在。" } L10nKey::RemoteThisComputer => "本机", L10nKey::RemoteProfileGone => "已删除的配置", L10nKey::RemoteRestartTitle => "重启“{machine}”上的 tty7 server?", L10nKey::RemoteRestartBody => { - "这将停止 {machine} 上的所有 shell——其中仍在运行的任何内容都会被终止,\ - 包括此窗口未显示的 shell。工作区和布局会被保留,并以全新的 shell 恢复。" + "这会结束 {machine} 上的所有 shell,包括此窗口没显示的。工作区和布局会保留,并以全新的 shell 恢复。" } L10nKey::RemoteReplaceBody => { "tty7 会在 {machine} 上安装匹配的 server 并启动它。\n\ @@ -1178,12 +1167,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::RemoteInstallBytes => "字节", L10nKey::RemoteMismatchTitle => "更新“{machine}”上的 tty7 server?", L10nKey::RemoteMismatchDetail => { - "{machine} 正在使用 {running} 提供 tty7 会话,该版本使用的协议无法被\ - 此客户端({wanted})识别。tty7 已在那里安装了匹配的 server,\ - 但正在运行的是你当前会话所在的版本。\n\ - \n\ - {replace_server}\u{2003}会将其替换为 {wanted} 并结束其托管的所有会话。\n\ - {cancel}\u{2003}会保持 {machine} 现状不变。此窗口将不会连接。" + "{machine} 上跑的是 server {running},此客户端({wanted})不认它的协议。匹配的 server 已经装好了,但你的会话在正在跑的那个上面。\n\n{replace_server}\u{2003}换成 {wanted},并结束它托管的所有会话。\n{cancel}\u{2003}保持 {machine} 现状。此窗口不会连接。" } L10nKey::RemoteMismatchReplaceServer => "更新 server", L10nKey::RemoteMismatchDowngradeServer => "替换 server", @@ -1200,8 +1184,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::RemoteDaemonStartFailed => "无法启动 tty7 本地 server:{error}", L10nKey::RemoteDaemonUnreachable => "无法连接到 tty7 本地 server:{error}", L10nKey::RemoteDaemonTooOld => { - "此机器上的 tty7 守护进程版本较旧,无法重启 {machine} 上的 server。\ - 请退出 tty7(这会停止守护进程)并重新打开,然后重试。" + "本机的 tty7 守护进程版本较旧,无法重启 {machine} 上的 server。请退出 tty7(这会停止守护进程)再打开,然后重试。" } L10nKey::RemoteProfileMissing => "该已保存的 SSH 主机配置已不存在", L10nKey::RemoteAliasMissing => "“{alias}”已不再位于 ~/.ssh/config 中", @@ -1268,7 +1251,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::IoBusy => "有别的程序正占着它。", L10nKey::IoTimedOut => "对方没有在规定时间内响应。", L10nKey::TreeWindowOpenedEmpty => { - "这个窗口的 server 没有交出标签页,所以窗口是空的。什么都没丢,它一响应就会回来。如果一直不回来,在命令面板里执行「重启 server」。" + "server 没有交出这个窗口的标签页,所以窗口是空的。什么都没丢,它一响应就会回来。如果一直不回来,在命令面板里执行「重启 server」。" } L10nKey::CmdGroupTabsPanes => "标签页与窗格", L10nKey::CmdGroupWorkspaces => "工作区", @@ -1380,35 +1363,35 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppRestartServerTitle => "重启 server?", L10nKey::AppRestartServerFailed => "无法重启后台 server:{error}", L10nKey::AppRestartServerMismatchDetail => { - "server 是 v{build},协议 {protocol};此应用使用 {ours}。两者无法对话,标签页取不出来。\n\n退出:什么都不变,server 和 shell 继续运行。\n重启:标签页带全新 shell 回来,现在跑着的东西会被杀掉。" + "server 用协议 {protocol}(构建 v{build}),此应用用 {ours},标签页取不出来。\n\n退出:什么都不变,server 和 shell 继续运行。\n重启:标签页带全新 shell 回来,现在跑着的东西会被杀掉。" } L10nKey::AppRestartServerDialectDetail => { - "server 是 v{build}:control 方言 v{dialect},而此应用使用 v{ours}。它交不出标签页,所以每个窗口都开成空的。\n\n退出:什么都不变,server 和 shell 继续运行。\n重启:标签页带全新 shell 回来,现在跑着的东西会被杀掉。" + "server 用 control 方言 v{dialect}(构建 v{build}),此应用用 v{ours},每个窗口都开成空的。\n\n退出:什么都不变,server 和 shell 继续运行。\n重启:标签页带全新 shell 回来,现在跑着的东西会被杀掉。" } L10nKey::AppRestartServerDialectNewerDetail => { - "server 是 v{build}:control 方言 v{dialect},而此应用使用 v{ours}。它交不出标签页,所以每个窗口都开成空的。\n\n退出并装上更新的构建:真正的解法,shell 全都还在。\n重启:标签页带全新 shell 回来,现在跑着的东西会被杀掉。" + "server 用 control 方言 v{dialect}(构建 v{build}),此应用用 v{ours},每个窗口都开成空的。\n\n退出并装上更新的构建:真正的解法,shell 全都还在。\n重启:标签页带全新 shell 回来,现在跑着的东西会被杀掉。" } L10nKey::AppRestartServerOldDetail => { "server 早于版本握手,此应用无从得知它说的是什么。\n\n退出:什么都不变,server 和 shell 继续运行。\n重启:标签页带全新 shell 回来,现在跑着的东西会被杀掉。" } L10nKey::AppRestart => "重启", L10nKey::AppRestartServerNoServer => { - "{label} 上没有 tty7 自己的 server 可重启——它是本机通过 --stdio 运行的程序。请改为停止其工作区。" + "{label} 没有自己的 server 可重启——它是本机通过 --stdio 运行的程序。请改为停止其工作区。" } L10nKey::AppRestartServerBody => { - "这会停止本机上所有正在运行的 shell——其中仍在运行的任何内容都会被终止。你的标签页和布局会被保留,并以全新的 shell 重新打开。" + "这会结束本机上所有 shell。标签页和布局会保留,并以全新的 shell 重新打开。" } L10nKey::ConfigQuarantinedStartup => { - "config.json 无法解析,tty7 正以默认设置运行,也不会覆写该文件。原内容已保留为旁边的 config.json.corrupt——修好文件后会自动重载;在此之前,设置里的更改不会被保存。" + "config.json 无法解析。tty7 正以默认设置运行,原内容已保留为旁边的 config.json.corrupt。修好后会自动重载;在此之前,设置里的更改不会保存。" } L10nKey::ConfigQuarantinedReload => { - "修改后的 config.json 无法解析,已保留当前在用的设置,文件内容也已另存为旁边的 config.json.corrupt。修好文件后会自动重载;在此之前,任何一次保存设置都会用当前在用的设置覆盖它。" + "修改后的 config.json 无法解析。tty7 保留了当前在用的设置,文件内容另存为旁边的 config.json.corrupt。修好后会自动重载;在此之前保存设置会覆盖它。" } L10nKey::ConfigUnreadableStartup => { - "config.json 读取失败,tty7 正以默认设置运行,也不会覆写该文件——文件原样保留。修好它的权限或内容后会自动重载;在此之前,设置里的更改不会被保存。" + "config.json 读取失败。tty7 正以默认设置运行,文件原样保留。修好权限或内容后会自动重载;在此之前,设置里的更改不会保存。" } L10nKey::ConfigUnreadableReload => { - "config.json 读取失败,已保留当前在用的设置,文件也原样保留。修好它的权限或内容后会自动重载;在此之前,任何一次保存设置都会用当前在用的设置覆盖它。" + "config.json 读取失败。tty7 保留了当前在用的设置,文件也原样保留。修好权限或内容后会自动重载;在此之前保存设置会覆盖它。" } L10nKey::AppWorktreeRemoveDetailDirty => { "位于 {path} 的已关闭标签页的 worktree 有未提交的变更。" @@ -1526,32 +1509,23 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::Replace => "覆盖", L10nKey::SftpErrorInvalidOctalMode => "无效的八进制模式", L10nKey::SettingsDaemonStaleDescInPlace => { - "tty7 是原地更新的,所以应用是新的,但你的面板仍由上一个版本在服务。\ - server 可以在不停止的情况下把自己换成新版本:你的 shell 和里面正在跑的东西会直接延续下来。\ - 用 tty7 内置 SSH 客户端的面板除外——那些连接会断开,需要重新打开。" + "tty7 是原地更新的:应用是新的,面板还跑在旧版上。server 可以不停机就换成新版,shell 直接延续下来。用 tty7 内置 SSH 客户端的面板除外——那些连接会断开,需要重新打开。" } L10nKey::AppRestartServerBodyInPlace => { - "后台 server 会在不停止的情况下把自己换成当前这个版本。\ - 你的 shell 会继续运行——面板里的命令、agent、`ssh` 会话都不会被打断——窗口稍后会重新连上它们。\ - 用 tty7 内置 SSH 客户端的面板除外:那些连接会断开,需要重新打开。" + "后台 server 会原地把自己换成当前这个版本:shell 继续运行,窗口稍后自动连回去。用 tty7 内置 SSH 客户端的面板除外——那些连接会断开,需要重新打开。" } L10nKey::PaneRestoredScreenBanner => { "已恢复的画面 —— 下面是新的 shell,上面的内容都已不在运行" } L10nKey::SettingsPerPaneHistory => "每个面板用自己的 shell 历史", L10nKey::SettingsPerPaneHistoryDescription => { - "上方向键翻的是这个面板里跑过的命令,而不是所有面板混在一起的结果。\ - 新面板会从你已有的历史开始,而不是一片空白;面板关闭时,它新增的部分会写回原来的历史文件,不会丢。\ - 只对 tty7 能接管的 bash 和 zsh 面板生效;用你自己参数启动的 shell 不受影响。" + "上方向键翻的是这个面板里跑过的命令,而不是所有面板混在一起。新面板从已有历史开始,关闭时把新增的写回去。只对 tty7 能接管的 bash 和 zsh 面板生效;用你自己参数启动的 shell 不受影响。" } L10nKey::IntegrationNoticeBlocked => { - "此窗格中的 tty7 shell 集成被拦截——“{wrapper}”截获了 shell 上报,\ - 内联补全和 Ctrl+R 菜单不可用。shell 自带的历史搜索仍可使用。" + "“{wrapper}”截获了此窗格的 shell 上报,内联补全和 Ctrl+R 菜单不可用。shell 自带的历史搜索仍可使用。" } L10nKey::IntegrationNoticeNotEngaged => { - "此窗格中的 tty7 shell 集成尚未生效,内联补全和 Ctrl+R 菜单不可用。\ - 用你自己参数启动的 shell、PTY 包装器(figterm 类)或不受支持的 shell 配置\ - 都可能导致此问题。" + "此窗格的 tty7 shell 集成没生效,内联补全和 Ctrl+R 菜单不可用。常见原因:用自己参数启动的 shell、PTY 包装器,或不受支持的 shell。" } L10nKey::PaneTitleDisconnected => "{title} — 已断开", L10nKey::PaneTitleProcessExited => "{title} — 进程已退出", diff --git a/src/ui/presets.rs b/src/ui/presets.rs index 240de247..43c62d29 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -174,7 +174,7 @@ impl Theme { sidebar, // Blended, not bisected, so a palette's own softness carries into // the sidebar — but floored on the fill it is actually painted on - // (`sidebar`, not `background`), because four of the nine builtins + // (`sidebar`, not `background`), because four of the builtins // land this under 4.5:1 and it is the tab title, not a caption. sidebar_fg: at_least(mix(fg, bg, 0.28), fg, sidebar, TEXT_FLOOR), accent: legible_accent(bg, self.accent), @@ -1035,7 +1035,7 @@ struct BuiltinSpec { ansi16: [(u8, u8, u8); 16], } -static BUILTINS: [BuiltinSpec; 9] = [ +static BUILTINS: [BuiltinSpec; 13] = [ BuiltinSpec { id: "light", name: "Light", @@ -1270,6 +1270,112 @@ static BUILTINS: [BuiltinSpec; 9] = [ (0xe0, 0xde, 0xf4), ], }, + BuiltinSpec { + id: "catppuccin_mocha", + name: "Catppuccin Mocha", + background: 0x1e1e2e, + foreground: 0xcdd6f4, + accent: 0x89b4fa, + // Rosewater, the cursor colour Catppuccin's own terminal spec names — + // the accent fallback would paint it blue. + caret: Some(0xf5e0dc), + ansi16: [ + (0x45, 0x47, 0x5a), + (0xf3, 0x8b, 0xa8), + (0xa6, 0xe3, 0xa1), + (0xf9, 0xe2, 0xaf), + (0x89, 0xb4, 0xfa), + (0xf5, 0xc2, 0xe7), + (0x94, 0xe2, 0xd5), + (0xba, 0xc2, 0xde), + (0x58, 0x5b, 0x70), + (0xf3, 0x8b, 0xa8), + (0xa6, 0xe3, 0xa1), + (0xf9, 0xe2, 0xaf), + (0x89, 0xb4, 0xfa), + (0xf5, 0xc2, 0xe7), + (0x94, 0xe2, 0xd5), + (0xa6, 0xad, 0xc8), + ], + }, + BuiltinSpec { + id: "gruvbox_dark", + name: "Gruvbox Dark", + background: 0x282828, + foreground: 0xebdbb2, + accent: 0xfe8019, + caret: Some(0xebdbb2), + ansi16: [ + (0x28, 0x28, 0x28), + (0xcc, 0x24, 0x1d), + (0x98, 0x97, 0x1a), + (0xd7, 0x99, 0x21), + (0x45, 0x85, 0x88), + (0xb1, 0x62, 0x86), + (0x68, 0x9d, 0x6a), + (0xa8, 0x99, 0x84), + (0x92, 0x83, 0x74), + (0xfb, 0x49, 0x34), + (0xb8, 0xbb, 0x26), + (0xfa, 0xbd, 0x2f), + (0x83, 0xa5, 0x98), + (0xd3, 0x86, 0x9b), + (0x8e, 0xc0, 0x7c), + (0xeb, 0xdb, 0xb2), + ], + }, + BuiltinSpec { + id: "nord", + name: "Nord", + background: 0x2e3440, + foreground: 0xd8dee9, + accent: 0x88c0d0, + caret: Some(0xd8dee9), + ansi16: [ + (0x3b, 0x42, 0x52), + (0xbf, 0x61, 0x6a), + (0xa3, 0xbe, 0x8c), + (0xeb, 0xcb, 0x8b), + (0x81, 0xa1, 0xc1), + (0xb4, 0x8e, 0xad), + (0x88, 0xc0, 0xd0), + (0xe5, 0xe9, 0xf0), + (0x4c, 0x56, 0x6a), + (0xbf, 0x61, 0x6a), + (0xa3, 0xbe, 0x8c), + (0xeb, 0xcb, 0x8b), + (0x81, 0xa1, 0xc1), + (0xb4, 0x8e, 0xad), + (0x8f, 0xbc, 0xbb), + (0xec, 0xef, 0xf4), + ], + }, + BuiltinSpec { + id: "tokyo_night", + name: "Tokyo Night", + background: 0x1a1b26, + foreground: 0xc0caf5, + accent: 0x7aa2f7, + caret: Some(0xc0caf5), + ansi16: [ + (0x15, 0x16, 0x1e), + (0xf7, 0x76, 0x8e), + (0x9e, 0xce, 0x6a), + (0xe0, 0xaf, 0x68), + (0x7a, 0xa2, 0xf7), + (0xbb, 0x9a, 0xf7), + (0x7d, 0xcf, 0xff), + (0xa9, 0xb1, 0xd6), + (0x41, 0x48, 0x68), + (0xf7, 0x76, 0x8e), + (0x9e, 0xce, 0x6a), + (0xe0, 0xaf, 0x68), + (0x7a, 0xa2, 0xf7), + (0xbb, 0x9a, 0xf7), + (0x7d, 0xcf, 0xff), + (0xc0, 0xca, 0xf5), + ], + }, ]; #[cfg(test)] @@ -1297,7 +1403,17 @@ mod tests { .collect(); assert_eq!( dark, - ["dark", "dracula", "harbor", "one_dark_pro", "rose_pine"] + [ + "dark", + "dracula", + "harbor", + "one_dark_pro", + "rose_pine", + "catppuccin_mocha", + "gruvbox_dark", + "nord", + "tokyo_night", + ] ); } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index a7b462e6..1c0b8f46 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -673,11 +673,6 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsAppHttpProxy, keywords: SettingsSearchAppHttpProxyKeywords, }, - SearchEntry { - section: About, - title: SettingsSearchHowShellsWorkTitle, - keywords: SettingsSearchHowShellsWorkKeywords, - }, SearchEntry { section: About, title: SettingsUpdateChannel, @@ -748,8 +743,8 @@ pub(crate) fn section_match_count(section: SettingsSection, query: &str) -> usiz /// Whether a rendered row is one of the ones the section's `(n)` badge counted. /// A row can match on its own label, or through the keyword list the search -/// index carries for it — "persist" finds "How shells work" and nothing on that -/// page contains the word. +/// index carries for it — "palette" finds "Theme" and nothing in that label +/// contains the word. fn row_matches_query(section: SettingsSection, label: &str, query: &str) -> bool { if query.is_empty() { return false; @@ -6895,18 +6890,6 @@ impl Tty7App { .text_color(muted_fg) .child(t(L10nKey::SettingsAboutDesc1)), ) - // The search index has promised a "How shells work" entry on this - // page since it was written, and it pointed at nothing — the one - // thing that makes tty7 different from any other terminal was - // never stated in the app. - .child(self.section_rule(cx)) - .child(self.section_header(t(L10nKey::SettingsSearchHowShellsWorkTitle), cx)) - .child( - div() - .text_sm() - .text_color(muted_fg) - .child(t(L10nKey::SettingsHowShellsWorkBody)), - ) .child(self.section_rule(cx)) .child(self.section_header(t(L10nKey::SettingsUpdates), cx)) .child( @@ -7552,19 +7535,19 @@ mod tests { "Blur", "blur" )); - // Keyword hit: nothing on the About page contains "persist", but the - // index says the "How shells work" block answers it. + // Keyword hit: the label says "Theme" and nothing more, but the index + // says that row answers "palette". assert!(row_matches_query( - SettingsSection::About, - t(L10nKey::SettingsSearchHowShellsWorkTitle), - "persist" + SettingsSection::Appearance, + t(L10nKey::SettingsThemeIntroTitle), + "palette" )); // A row on some other page is not a hit just because the query matches // an entry elsewhere. assert!(!row_matches_query( SettingsSection::Terminal, "Blur", - "persist" + "palette" )); // An empty query marks nothing at all, so no page ever renders greyed // out just because the field is focused. @@ -7575,21 +7558,7 @@ mod tests { fn a_query_that_matches_nothing_is_distinguishable_from_one_that_does() { assert_eq!(total_match_count("zzqqxx"), 0); assert!(total_match_count("blur") > 0); - assert!(total_match_count("persist") > 0); - } - - #[test] - fn the_how_shells_work_entry_has_something_to_point_at() { - // The index promised this page an explanation of the one thing that - // makes tty7 different; for a long time it pointed at nothing. - assert!( - !t(L10nKey::SettingsHowShellsWorkBody).is_empty(), - "the About page has no body copy for its own search entry" - ); - assert_eq!( - best_matching_section("persist").map(|s| s.profile_label()), - Some(SettingsSection::About.profile_label()) - ); + assert!(total_match_count("palette") > 0); } #[test] From 8b5aeb00778b47a6bdbcddac83d34ecf7e66a2cf Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:42:56 +0800 Subject: [PATCH 14/33] Wire hooks, resume and fork for the CLI agents that support them (#666) * feat(agents): hook, resume and fork support for nine more CLI agents Hooks go from 7 agents to 11. Gemini, Droid and Qwen merge into their own settings.json the way Claude and Codex already do; Goose gets an owned file under the Open Plugins layout it implements. Qwen is the only one of them with a first-class PermissionRequest event, so it needs none of the notification sniffing the others do -- and deliberately gets no Notification hook at all, since that event fires for non-blocking alerts too and would strand a pane on "waiting". Resume goes from 10 agents to 17, fork from 5 to 9. Amp's `threads fork` is a real subcommand that is simply missing from `amp threads --help`. Four detection and replay bugs turned up while checking each CLI: - `python3 -m antigravity`, the documented way to trigger Python's own easter egg, was detected as a coding agent. The `antigravity` binary is the IDE's launcher shim anyway, in the shape of VS Code's `code`, not the terminal agent -- that one is `agy`. - Amp lost every launch flag on resume. It names a thread with a positional argument, so the stale-flag list had nothing to drop and the generic bare-token check rejected the whole tail along with it. - Gemini could be handed a command line it refuses to start from: `--session-id` and `--session-file` are mutually exclusive with `--resume` and were never stripped. - Cursor's `--continue` was not stripped either, leaving it to collide with the injected `--resume `. Brand colours for Aider, Goose, Droid, Vibe, Qwen and Antigravity now come from first-party sources -- logo SVG fills and site CSS variables -- rather than approximations. Qwen ships its real mark instead of the generic bot glyph. Hooks stay unwired for Aider (no lifecycle mechanism exists at all), Cursor (its usable events gate permissions, and tty7's silent hook would read as a failed check and auto-allow the command), Auggie (its command field takes only script paths, needing generated wrappers, and the constraint could not be verified without a billed run), and for Hermes, Amp, Vibe and Antigravity, whose event sets are too thin to report a blocked turn. * fix(agents): strip every session-naming alias before replaying launch flags Goose spells --session-id also as --id, --name as -n, and keeps a legacy --path, all in one exclusive clap group; Qwen rejects --session-id next to --resume; Vibe shortens --continue to -c. Any of these surviving a replay broke the regenerated resume command. Qwen's --no-chat-recording also persists nothing, so it now opts the pane out of resume and fork like Auggie's --dont-save-session. The Qwen icon gains the 24x24 width/height every other agent mark carries. --- assets/icons/agents/qwen.svg | 6 + crates/tty7-core/src/core/agent_hooks.rs | 362 +++++++++++++++++++---- crates/tty7-core/src/core/cli_agent.rs | 294 +++++++++++++++++- src/ui/assets.rs | 1 + src/ui/i18n/en.rs | 10 + src/ui/i18n/ja.rs | 16 + src/ui/i18n/mod.rs | 12 + src/ui/i18n/zh.rs | 16 + src/ui/settings.rs | 20 ++ 9 files changed, 665 insertions(+), 72 deletions(-) create mode 100644 assets/icons/agents/qwen.svg diff --git a/assets/icons/agents/qwen.svg b/assets/icons/agents/qwen.svg new file mode 100644 index 00000000..efb2e4f3 --- /dev/null +++ b/assets/icons/agents/qwen.svg @@ -0,0 +1,6 @@ + + + diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 93f3837a..545fe1c2 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -43,9 +43,12 @@ fn effective_agent(agent: &str, ran_by_grok: bool) -> &str { } fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> { - if matches!(agent, "copilot" | "grok") && event == "notification" { + if matches!(agent, "copilot" | "grok" | "droid" | "gemini") && event == "notification" { let blocks = stdin_json.contains("elicitation_dialog") - || (agent == "copilot" && stdin_json.contains("permission_prompt")); + || (matches!(agent, "copilot" | "droid") && stdin_json.contains("permission_prompt")) + // Gemini's only notification kind so far, but naming it keeps a + // future non-blocking one from being read as a block. + || (agent == "gemini" && stdin_json.contains("ToolPermission")); return blocks.then_some("permission-request"); } Some(event) @@ -63,6 +66,8 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec { ("session_id", "sessionId"), ("message", "message"), ("cwd", "cwd"), + // Goose spells the working directory its own way. + ("cwd", "working_dir"), ] { if let Some(v) = payload .get(key) @@ -214,10 +219,14 @@ pub enum HookAgent { Pi, Grok, OhMyPi, + Gemini, + Droid, + Qwen, + Goose, } impl HookAgent { - pub const ALL: [HookAgent; 7] = [ + pub const ALL: [HookAgent; 11] = [ HookAgent::Claude, HookAgent::Codex, HookAgent::Copilot, @@ -225,6 +234,10 @@ impl HookAgent { HookAgent::Pi, HookAgent::Grok, HookAgent::OhMyPi, + HookAgent::Gemini, + HookAgent::Droid, + HookAgent::Qwen, + HookAgent::Goose, ]; /// The hooks behind a detected agent process, if it has any. @@ -241,17 +254,36 @@ impl HookAgent { CLIAgent::Pi => Some(HookAgent::Pi), CLIAgent::Grok => Some(HookAgent::Grok), CLIAgent::OhMyPi => Some(HookAgent::OhMyPi), - CLIAgent::Gemini - | CLIAgent::Aider + CLIAgent::Gemini => Some(HookAgent::Gemini), + CLIAgent::Droid => Some(HookAgent::Droid), + CLIAgent::Qwen => Some(HookAgent::Qwen), + CLIAgent::Goose => Some(HookAgent::Goose), + CLIAgent::Aider | CLIAgent::Amp | CLIAgent::Cursor - | CLIAgent::Goose - | CLIAgent::Droid | CLIAgent::Auggie | CLIAgent::Hermes | CLIAgent::Vibe - | CLIAgent::Antigravity - | CLIAgent::Qwen => None, + | CLIAgent::Antigravity => None, + } + } + + /// The events this agent's hooks merge into a shared JSON config, if that + /// is how it takes them. `None` means the agent owns a generated file + /// instead — see [`owned_file_content`]. + fn hook_map_events(self) -> Option<&'static [(&'static str, &'static str)]> { + match self { + HookAgent::Claude => Some(CLAUDE_HOOK_EVENTS), + HookAgent::Codex => Some(CODEX_HOOK_EVENTS), + HookAgent::Gemini => Some(GEMINI_HOOK_EVENTS), + HookAgent::Droid => Some(DROID_HOOK_EVENTS), + HookAgent::Qwen => Some(QWEN_HOOK_EVENTS), + HookAgent::Copilot + | HookAgent::OpenCode + | HookAgent::Pi + | HookAgent::Grok + | HookAgent::OhMyPi + | HookAgent::Goose => None, } } @@ -264,6 +296,10 @@ impl HookAgent { HookAgent::Pi => "pi", HookAgent::Grok => "grok", HookAgent::OhMyPi => "omp", + HookAgent::Gemini => "gemini", + HookAgent::Droid => "droid", + HookAgent::Qwen => "qwen", + HookAgent::Goose => "goose", } } @@ -276,6 +312,10 @@ impl HookAgent { HookAgent::Pi => "Pi", HookAgent::Grok => "Grok Build", HookAgent::OhMyPi => "Oh My Pi", + HookAgent::Gemini => "Gemini", + HookAgent::Droid => "Droid", + HookAgent::Qwen => "Qwen Code", + HookAgent::Goose => "Goose", } } @@ -297,6 +337,15 @@ impl HookAgent { HookAgent::OhMyPi => { target.under_home(&[".omp", "agent", "extensions", "tty7", "index.ts"]) } + HookAgent::Gemini => target.under_home(&[".gemini", "settings.json"]), + HookAgent::Droid => target.under_home(&[".factory", "settings.json"]), + HookAgent::Qwen => target.under_home(&[".qwen", "settings.json"]), + // The Open Plugins layout, which Goose implements rather than + // inventing its own: any `.agents/plugins//hooks/hooks.json` + // is picked up at startup. + HookAgent::Goose => { + target.under_home(&[".agents", "plugins", "tty7", "hooks", "hooks.json"]) + } } } @@ -449,20 +498,13 @@ pub enum HooksState { pub fn hooks_state(target: &HookTarget, agent: HookAgent) -> HooksState { let path = agent.target_path(target); - match agent { - HookAgent::Claude => hook_map_state(target, &path, agent, CLAUDE_HOOK_EVENTS), - HookAgent::Codex => hook_map_state(target, &path, agent, CODEX_HOOK_EVENTS), - HookAgent::Copilot - | HookAgent::OpenCode - | HookAgent::Pi - | HookAgent::Grok - | HookAgent::OhMyPi => { - let Some(expected) = owned_file_content(target, agent) else { - return HooksState::NotInstalled; - }; - owned_file_state(target, &path, &expected, &agent.marker()) - } + if let Some(events) = agent.hook_map_events() { + return hook_map_state(target, &path, agent, events); } + let Some(expected) = owned_file_content(target, agent) else { + return HooksState::NotInstalled; + }; + owned_file_state(target, &path, &expected, &agent.marker()) } /// What an install or uninstall actually did. @@ -489,43 +531,30 @@ pub enum HookOutcome { pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result { let path = agent.target_path(target); - match agent { - HookAgent::Claude => { - hook_map_install(target, &path, agent, CLAUDE_HOOK_EVENTS)?; - Ok(HookOutcome::Installed) + if let Some(events) = agent.hook_map_events() { + hook_map_install(target, &path, agent, events)?; + if agent != HookAgent::Codex { + return Ok(HookOutcome::Installed); } - HookAgent::Codex => { - hook_map_install(target, &path, agent, CODEX_HOOK_EVENTS)?; - if !target.is_local() { - return Ok(HookOutcome::InstalledEnableCodexThere); - } - Ok(match enable_codex_hooks_feature() { - Ok(()) => HookOutcome::Installed, - Err(e) => HookOutcome::InstalledCodexEnableFailed(e.to_string()), - }) - } - HookAgent::Copilot - | HookAgent::OpenCode - | HookAgent::Pi - | HookAgent::Grok - | HookAgent::OhMyPi => { - let content = owned_file_content(target, agent) - .ok_or_else(|| anyhow::anyhow!("{agent:?} has no owned file"))?; - owned_file_install(target, &path, &content, &agent.marker())?; - Ok(HookOutcome::Installed) + if !target.is_local() { + return Ok(HookOutcome::InstalledEnableCodexThere); } + return Ok(match enable_codex_hooks_feature() { + Ok(()) => HookOutcome::Installed, + Err(e) => HookOutcome::InstalledCodexEnableFailed(e.to_string()), + }); } + let content = owned_file_content(target, agent) + .ok_or_else(|| anyhow::anyhow!("{agent:?} has no owned file"))?; + owned_file_install(target, &path, &content, &agent.marker())?; + Ok(HookOutcome::Installed) } pub fn uninstall_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result { let path = agent.target_path(target); - match agent { - HookAgent::Claude | HookAgent::Codex => hook_map_uninstall(target, &path, agent), - HookAgent::Copilot - | HookAgent::OpenCode - | HookAgent::Pi - | HookAgent::Grok - | HookAgent::OhMyPi => owned_file_uninstall(target, &path, &agent.marker()), + match agent.hook_map_events() { + Some(_) => hook_map_uninstall(target, &path, agent), + None => owned_file_uninstall(target, &path, &agent.marker()), } } @@ -600,6 +629,40 @@ const CODEX_HOOK_EVENTS: &[(&str, &str)] = &[ ("Stop", "stop"), ]; +/// Gemini names the turn boundaries after the agent rather than the user, and +/// omitting `matcher` matches everything (`hookPlanner.ts`, `!entry.matcher`), +/// so the bare entries [`hook_map_install`] already writes are enough. +const GEMINI_HOOK_EVENTS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("BeforeAgent", "prompt-submit"), + ("Notification", "notification"), + ("AfterTool", "tool-complete"), + ("AfterAgent", "stop"), + ("SessionEnd", "session-end"), +]; + +const DROID_HOOK_EVENTS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt-submit"), + ("Notification", "notification"), + ("PostToolUse", "tool-complete"), + ("Stop", "stop"), + ("SessionEnd", "session-end"), +]; + +/// Qwen is the only agent here with a first-class permission event, so it needs +/// none of the notification sniffing in [`effective_event`] — and it gets no +/// `Notification` hook at all, which would only muddy a status the dedicated +/// event already reports precisely. +const QWEN_HOOK_EVENTS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt-submit"), + ("PermissionRequest", "permission-request"), + ("PostToolUse", "tool-complete"), + ("Stop", "stop"), + ("SessionEnd", "session-end"), +]; + const GROK_HOOK_TIMEOUT_SECS: u32 = 10; const GROK_HOOK_EVENTS: &[(&str, &str, Option<&str>)] = &[ @@ -809,7 +872,12 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { HookAgent::OpenCode => opencode_plugin_js(target), HookAgent::Pi | HookAgent::OhMyPi => pi_extension_ts(target, agent), HookAgent::Grok => grok_hooks_json(target), - HookAgent::Claude | HookAgent::Codex => None, + HookAgent::Goose => goose_hooks_json(target), + HookAgent::Claude + | HookAgent::Codex + | HookAgent::Gemini + | HookAgent::Droid + | HookAgent::Qwen => None, } } @@ -862,10 +930,26 @@ fn owned_file_uninstall( )); } target.host.remove(path, false)?; - if let Some(parent) = path.parent() - && parent.file_name().is_some_and(|n| n == "tty7") - { - let _ = target.host.remove(parent, false); + // Take the directories tty7 generated with it, innermost first, stopping at + // the one named after tty7. `remove` is not recursive, so a directory still + // holding someone else's file simply survives the attempt. Goose nests one + // level deeper than the rest (`.../tty7/hooks/hooks.json`), which is why + // this walks rather than checking a single parent. + let mut dir = path.parent(); + while let Some(d) = dir { + if d.file_name().is_some_and(|n| n == "tty7") { + let _ = target.host.remove(d, false); + break; + } + if !d + .parent() + .and_then(|p| p.file_name()) + .is_some_and(|n| n == "tty7") + { + break; + } + let _ = target.host.remove(d, false); + dir = d.parent(); } Ok(HookOutcome::Removed) } @@ -909,6 +993,33 @@ fn grok_hooks_json(target: &HookTarget) -> Option { serde_json::to_string_pretty(&serde_json::json!({ "hooks": hooks })).ok() } +/// Goose has no permission hook — `PreToolUse` fires on every call, approved or +/// not, so there is nothing here that could report a blocked turn. The four +/// events it does have still carry the pane from idle to working to done. +const GOOSE_HOOK_EVENTS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt-submit"), + ("PostToolUse", "tool-complete"), + ("Stop", "stop"), + ("SessionEnd", "session-end"), +]; + +fn goose_hooks_json(target: &HookTarget) -> Option { + let mut hooks = serde_json::Map::new(); + for (event, sentinel) in GOOSE_HOOK_EVENTS { + hooks.insert( + (*event).to_string(), + serde_json::json!([{ + "hooks": [{ + "type": "command", + "command": target.hook_command(HookAgent::Goose, sentinel), + }] + }]), + ); + } + serde_json::to_string_pretty(&serde_json::json!({ "hooks": hooks })).ok() +} + fn opencode_plugin_js(target: &HookTarget) -> Option { let prefix = serde_json::to_string(&format!( "{} ", @@ -1121,6 +1232,10 @@ mod tests { let mut events: Vec<&str> = CLAUDE_HOOK_EVENTS .iter() .chain(CODEX_HOOK_EVENTS) + .chain(GEMINI_HOOK_EVENTS) + .chain(DROID_HOOK_EVENTS) + .chain(QWEN_HOOK_EVENTS) + .chain(GOOSE_HOOK_EVENTS) .map(|(_, e)| *e) .chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e)) .collect(); @@ -1140,6 +1255,139 @@ mod tests { } } + #[test] + fn the_new_hook_agents_target_the_paths_their_clis_read() { + let host = FakeRemote::shared(); + let t = HookTarget::remote(&*host, PathBuf::from("/home/me")); + + for (agent, want) in [ + (HookAgent::Gemini, "/home/me/.gemini/settings.json"), + (HookAgent::Droid, "/home/me/.factory/settings.json"), + (HookAgent::Qwen, "/home/me/.qwen/settings.json"), + ( + HookAgent::Goose, + "/home/me/.agents/plugins/tty7/hooks/hooks.json", + ), + ] { + assert_eq!( + agent.target_path(&t), + PathBuf::from(want), + "{} writes somewhere its CLI does not read", + agent.slug() + ); + } + + let dir = std::env::temp_dir().join(format!("tty7-new-hooks-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let real = HookTarget::remote(&*host, dir.clone()); + + for agent in [ + HookAgent::Gemini, + HookAgent::Droid, + HookAgent::Qwen, + HookAgent::Goose, + ] { + assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled); + install_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug())); + assert_eq!( + hooks_state(&real, agent), + HooksState::Installed, + "{} does not read back what it wrote", + agent.slug() + ); + let written = std::fs::read_to_string(agent.target_path(&real)).unwrap(); + assert!( + written.contains(&format!("agent-hook {}", agent.slug())), + "{} wrote a config without its own emitter", + agent.slug() + ); + uninstall_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug())); + assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled); + } + + let _ = std::fs::remove_dir_all(&dir); + } + + /// Qwen is the one agent that reports a blocked turn outright, so it must + /// not also carry the `Notification` hook the others need — that event fires + /// for non-blocking alerts too and would strand the pane on "waiting". + #[test] + fn qwen_reports_permission_requests_natively() { + assert!( + QWEN_HOOK_EVENTS + .iter() + .any(|(hook, tty7)| *hook == "PermissionRequest" && *tty7 == "permission-request") + ); + assert!( + !QWEN_HOOK_EVENTS + .iter() + .any(|(hook, _)| *hook == "Notification") + ); + assert_eq!( + effective_event("qwen", "permission-request", "{}"), + Some("permission-request") + ); + } + + #[test] + fn gemini_and_droid_notifications_filter_to_permission_requests() { + assert_eq!( + effective_event( + "gemini", + "notification", + r#"{"notification_type":"ToolPermission"}"# + ), + Some("permission-request") + ); + assert_eq!( + effective_event( + "droid", + "notification", + r#"{"notification_type":"permission_prompt"}"# + ), + Some("permission-request") + ); + // A non-blocking alert must not strand the pane on "waiting". + for agent in ["gemini", "droid"] { + assert_eq!( + effective_event( + agent, + "notification", + r#"{"notification_type":"auth_success"}"# + ), + None, + "{agent} reported an idle notification as a block" + ); + } + } + + #[test] + fn uninstalling_goose_takes_its_generated_plugin_dirs_with_it() { + let root = std::env::temp_dir().join(format!("tty7-goose-test-{}", std::process::id())); + let plugins = root.join("plugins"); + let plugin = plugins.join("tty7"); + let hooks_dir = plugin.join("hooks"); + std::fs::create_dir_all(&hooks_dir).unwrap(); + let path = hooks_dir.join("hooks.json"); + + let host = local_host(); + let t = HookTarget::local(&*host).expect("home resolves in tests"); + let content = goose_hooks_json(&t).expect("goose content builds"); + assert!(content.contains("agent-hook goose")); + let marker = "agent-hook goose"; + + owned_file_install(&t, &path, &content, marker).expect("install"); + owned_file_uninstall(&t, &path, marker).expect("uninstall"); + + assert!(!path.exists()); + assert!(!hooks_dir.exists(), "the generated hooks/ dir goes too"); + assert!(!plugin.exists(), "and the tty7 plugin dir above it"); + assert!(plugins.exists(), "but never the shared plugins/ dir"); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn copilot_notifications_filter_to_permission_requests() { assert_eq!( diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 8a2d9161..577dbf28 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -62,7 +62,11 @@ impl CLIAgent { CLIAgent::Auggie => &["auggie"], CLIAgent::Hermes => &["hermes"], CLIAgent::Vibe => &["vibe", "vibe-acp"], - CLIAgent::Antigravity => &["agy", "antigravity"], + // `agy` only. The `antigravity` binary the IDE installs is a + // launcher shim in the shape of VS Code's `code`, not the terminal + // agent — and the name also collides with `python3 -m antigravity`, + // the standard way to trigger Python's own easter egg. + CLIAgent::Antigravity => &["agy"], CLIAgent::Grok => &["grok"], CLIAgent::Qwen => &["qwen", "qwen-code"], // Oh My Pi is a fork of Pi, but it ships one binary of its own and @@ -137,7 +141,16 @@ impl CLIAgent { CLIAgent::Gemini => Some(format!("gemini{flags} --resume {session_id}")), CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id}")), CLIAgent::Amp => Some(format!("amp threads continue {session_id}{flags}")), + CLIAgent::Auggie => Some(format!("auggie{flags} --resume {session_id}")), + CLIAgent::Hermes => Some(format!("hermes chat{flags} --resume {session_id}")), + CLIAgent::Qwen => Some(format!("qwen{flags} --resume {session_id}")), + CLIAgent::Goose => Some(format!( + "goose session{flags} --resume --session-id {session_id}" + )), + CLIAgent::Vibe => Some(format!("vibe{flags} --resume {session_id}")), + CLIAgent::Antigravity => Some(format!("agy{flags} --conversation {session_id}")), CLIAgent::Cursor => Some(format!("cursor-agent{flags} --resume {session_id}")), + CLIAgent::Droid => Some(format!("droid{flags} --resume {session_id}")), CLIAgent::Copilot => Some(format!("copilot{flags} --resume {session_id}")), CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id}")), CLIAgent::Pi => Some(format!("pi{flags} --session {session_id}")), @@ -149,6 +162,12 @@ impl CLIAgent { fn opts_out_of_sessions(self, argv: &[String]) -> bool { let ephemeral: &[&str] = match self { CLIAgent::Pi | CLIAgent::OhMyPi => &["--no-session"], + // "Do not save conversation history" — nothing is persisted, so + // there is no session left to resume from. + CLIAgent::Auggie => &["--dont-save-session"], + // "If false, chat history is not saved and --continue/--resume + // will not work" — the yargs negation of `--chat-recording`. + CLIAgent::Qwen => &["--no-chat-recording"], _ => &[], }; argv.iter().any(|t| ephemeral.contains(&t.as_str())) @@ -167,6 +186,16 @@ impl CLIAgent { CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id} --fork-session")), CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id} --fork")), CLIAgent::OhMyPi => Some(format!("omp{flags} --fork {session_id}")), + // Droid forks with a standalone flag rather than resume-plus-a-switch. + CLIAgent::Droid => Some(format!("droid{flags} --fork {session_id}")), + // `fork` is missing from `amp threads --help`, but the subcommand is + // real — `amp threads fork --help` prints its own usage. + CLIAgent::Amp => Some(format!("amp threads fork {session_id}{flags}")), + CLIAgent::Qwen => Some(format!("qwen{flags} --resume {session_id} --fork-session")), + // Goose forks by adding a switch to the same resume invocation. + CLIAgent::Goose => Some(format!( + "goose session{flags} --resume --fork --session-id {session_id}" + )), _ => None, } } @@ -177,7 +206,11 @@ impl CLIAgent { | CLIAgent::Codex | CLIAgent::Grok | CLIAgent::OpenCode - | CLIAgent::OhMyPi => Some("Fork Session"), + | CLIAgent::OhMyPi + | CLIAgent::Droid + | CLIAgent::Amp + | CLIAgent::Qwen + | CLIAgent::Goose => Some("Fork Session"), _ => None, } } @@ -225,6 +258,33 @@ impl CLIAgent { } } + // Agents that reach their session through subcommands leave `stale` + // nothing to drop — `amp threads continue ` names the thread with a + // positional argument, and `goose session --resume` hides the flags one + // level down. Either way the prefix has to come off here, because the + // "a bare token must follow a flag" check below would otherwise reject + // the tail wholesale and take every launch flag down with it. The + // replacement command spells the subcommand out again itself. + let (groups, verbs): (&[&str], &[&str]) = match self { + CLIAgent::Amp => ( + &["threads", "t"], + &["continue", "c", "fork", "f", "handoff", "h"], + ), + CLIAgent::Auggie => (&["session"], &["resume", "continue"]), + CLIAgent::Goose => (&["session", "s"], &[]), + CLIAgent::Hermes => (&["chat"], &[]), + _ => (&[], &[]), + }; + if tail.first().is_some_and(|t| groups.contains(t)) { + tail.remove(0); + if tail.first().is_some_and(|t| verbs.contains(t)) { + tail.remove(0); + if tail.first().is_some_and(|t| !t.starts_with('-')) { + tail.remove(0); + } + } + } + let stale: &[&str] = match self { CLIAgent::Claude => &[ "--resume", @@ -235,8 +295,39 @@ impl CLIAgent { "--from-pr", "--fork-session", ], - CLIAgent::Gemini | CLIAgent::Cursor => &["--resume", "-r"], - CLIAgent::Copilot => &["--resume", "-r", "--continue", "-c"], + // `--session-id` and `--session-file` name a session too, and Gemini + // rejects them outright alongside `--resume`. + CLIAgent::Gemini => &["--resume", "-r", "--session-id", "--session-file"], + CLIAgent::Cursor => &["--resume", "-r", "--continue"], + CLIAgent::Copilot | CLIAgent::Auggie | CLIAgent::Hermes => { + &["--resume", "-r", "--continue", "-c"] + } + // `--session-id` names a *new* session and Qwen rejects it + // alongside `--resume`, so it is as stale as the resume flags. + CLIAgent::Qwen => &[ + "--resume", + "-r", + "--continue", + "-c", + "--fork-session", + "--session-id", + ], + CLIAgent::Droid => &["--resume", "-r", "--fork", "--session-id", "-s"], + // `--session-id`/`--id`, `-n`/`--name` and the legacy `--path` are + // one mutually-exclusive clap group in Goose; any of them surviving + // next to the `--session-id` this command appends is a parse error. + CLIAgent::Goose => &[ + "--resume", + "-r", + "--fork", + "--session-id", + "--id", + "--name", + "-n", + "--path", + ], + CLIAgent::Vibe => &["--resume", "--continue", "-c"], + CLIAgent::Antigravity => &["--conversation", "--continue", "-c"], CLIAgent::OpenCode => &["--session", "-s", "--continue", "-c", "--fork"], CLIAgent::Codex => &["--last"], CLIAgent::Pi => &[ @@ -308,20 +399,20 @@ impl CLIAgent { CLIAgent::Claude => 0xD97757, CLIAgent::Codex => 0x000000, CLIAgent::Gemini => 0x4285F4, - CLIAgent::Aider => 0x14B8A6, + CLIAgent::Aider => 0x14B014, CLIAgent::Amp => 0xF34E3F, CLIAgent::OpenCode => 0x6E56CF, CLIAgent::Copilot => 0x8957E5, CLIAgent::Cursor => 0x9AA0A6, - CLIAgent::Goose => 0x9A8CFF, - CLIAgent::Droid => 0xF59E0B, + CLIAgent::Goose => 0x3ECC5F, + CLIAgent::Droid => 0xEF6F2E, CLIAgent::Pi => 0x0EA5E9, CLIAgent::Auggie => 0x16A34A, CLIAgent::Hermes => 0x8B5CF6, - CLIAgent::Vibe => 0xFF7000, - CLIAgent::Antigravity => 0x2563EB, + CLIAgent::Vibe => 0xFA520F, + CLIAgent::Antigravity => 0x3186FF, CLIAgent::Grok => 0x000000, - CLIAgent::Qwen => 0x7C3AED, + CLIAgent::Qwen => 0x6D44E8, CLIAgent::OhMyPi => 0xF97316, } } @@ -340,12 +431,12 @@ impl CLIAgent { CLIAgent::Grok => "icons/agents/grok.svg", CLIAgent::Pi => "icons/agents/pi.svg", CLIAgent::OhMyPi => "icons/agents/omp.svg", + CLIAgent::Qwen => "icons/agents/qwen.svg", CLIAgent::Aider | CLIAgent::Auggie | CLIAgent::Hermes | CLIAgent::Vibe - | CLIAgent::Antigravity - | CLIAgent::Qwen => "icons/bot.svg", + | CLIAgent::Antigravity => "icons/bot.svg", } } @@ -746,7 +837,7 @@ mod tests { .collect(); assert_eq!( fallback, - ["aider", "auggie", "hermes", "vibe", "antigravity", "qwen"] + ["aider", "auggie", "hermes", "vibe", "antigravity"] ); assert!( !fallback.contains(&"omp"), @@ -1349,14 +1440,36 @@ mod tests { CLIAgent::OpenCode.fork_command("s-1", None).as_deref(), Some("opencode --session s-1 --fork") ); + assert_eq!( + CLIAgent::Droid.fork_command("session-abc", None).as_deref(), + Some("droid --fork session-abc") + ); + assert_eq!( + CLIAgent::Qwen.fork_command("q-1", None).as_deref(), + Some("qwen --resume q-1 --fork-session") + ); + assert_eq!( + CLIAgent::Goose.fork_command("20260213_9", None).as_deref(), + Some("goose session --resume --fork --session-id 20260213_9") + ); + // Undocumented in `amp threads --help`, but `amp threads fork --help` + // prints its own usage, so the subcommand is real. + assert_eq!( + CLIAgent::Amp.fork_command("T-abc", None).as_deref(), + Some("amp threads fork T-abc") + ); + // Cursor and Antigravity fork only from inside a running TUI (`/fork`), + // which is not something a launch command line can reach. for agent in [ CLIAgent::Gemini, CLIAgent::Copilot, CLIAgent::Cursor, - CLIAgent::Amp, CLIAgent::Aider, - CLIAgent::Qwen, + CLIAgent::Auggie, + CLIAgent::Hermes, + CLIAgent::Vibe, + CLIAgent::Antigravity, ] { assert_eq!( agent.fork_command("abc", None), @@ -1468,6 +1581,157 @@ mod tests { ); } + #[test] + fn newly_wired_agents_resume_the_way_their_own_cli_spells_it() { + let argv = |parts: &[&str]| parts.iter().map(|s| s.to_string()).collect::>(); + + for (agent, id, want) in [ + (CLIAgent::Droid, "session-abc", "droid --resume session-abc"), + (CLIAgent::Qwen, "q-1", "qwen --resume q-1"), + (CLIAgent::Auggie, "a-1", "auggie --resume a-1"), + ( + CLIAgent::Goose, + "20260213_9", + "goose session --resume --session-id 20260213_9", + ), + ( + CLIAgent::Hermes, + "20260812_213234_5de948", + "hermes chat --resume 20260812_213234_5de948", + ), + (CLIAgent::Vibe, "v-1", "vibe --resume v-1"), + ( + CLIAgent::Antigravity, + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "agy --conversation a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ), + ] { + assert_eq!( + agent.resume_command(id, None).as_deref(), + Some(want), + "{} resumes with the wrong command", + agent.slug() + ); + } + + // A subcommand-addressed session leaves nothing in `stale` to strip, so + // the prefix has to be dropped structurally — otherwise the launch flags + // go down with it. + assert_eq!( + CLIAgent::Amp + .resume_command( + "T-2", + Some(&argv(&[ + "amp", + "threads", + "continue", + "T-1", + "--dangerously-allow-all", + ])) + ) + .as_deref(), + Some("amp threads continue T-2 --dangerously-allow-all") + ); + assert_eq!( + CLIAgent::Goose + .resume_command( + "20260213_9", + Some(&argv(&["goose", "session", "--resume", "--name", "old"])) + ) + .as_deref(), + Some("goose session --resume --session-id 20260213_9") + ); + assert_eq!( + CLIAgent::Auggie + .resume_command( + "a-2", + Some(&argv(&["auggie", "session", "resume", "a-1", "--verbose"])) + ) + .as_deref(), + Some("auggie --verbose --resume a-2") + ); + assert_eq!( + CLIAgent::Droid + .resume_command( + "s-2", + Some(&argv(&["droid", "--fork", "s-1", "--auto", "low"])) + ) + .as_deref(), + Some("droid --auto low --resume s-2") + ); + + // `--id` is an alias of `--session-id` and `-n` of `--name`, and the + // three share one exclusive clap group — any of them surviving next to + // the `--session-id` the command appends would fail to parse. + assert_eq!( + CLIAgent::Goose + .resume_command( + "20260213_9", + Some(&argv(&["goose", "s", "--resume", "--id", "20260101_1"])) + ) + .as_deref(), + Some("goose session --resume --session-id 20260213_9") + ); + assert_eq!( + CLIAgent::Goose + .resume_command( + "20260213_9", + Some(&argv(&["goose", "session", "-r", "-n", "old"])) + ) + .as_deref(), + Some("goose session --resume --session-id 20260213_9") + ); + // Qwen rejects `--session-id` alongside `--resume`; Vibe spells + // `--continue` as `-c` too. + assert_eq!( + CLIAgent::Qwen + .resume_command("q-2", Some(&argv(&["qwen", "--session-id", "old"]))) + .as_deref(), + Some("qwen --resume q-2") + ); + assert_eq!( + CLIAgent::Vibe + .resume_command("v-2", Some(&argv(&["vibe", "-c"]))) + .as_deref(), + Some("vibe --resume v-2") + ); + + // Nothing was persisted, so there is nothing to resume or fork. + for id in ["a-1"] { + assert_eq!( + CLIAgent::Auggie + .resume_command(id, Some(&argv(&["auggie", "--dont-save-session"]))), + None + ); + } + // "If false, chat history is not saved and --continue/--resume will + // not work" — so neither resume nor fork is offered. + let no_recording = argv(&["qwen", "--no-chat-recording"]); + assert_eq!( + CLIAgent::Qwen.resume_command("q-1", Some(&no_recording)), + None + ); + assert_eq!( + CLIAgent::Qwen.fork_command("q-1", Some(&no_recording)), + None + ); + } + + /// `python3 -m antigravity` opens an xkcd comic. It is the standard way to + /// trigger Python's easter egg, and the interpreter branch used to read that + /// module name as an agent. + #[test] + fn the_python_easter_egg_is_not_a_coding_agent() { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["python3", "-m", "antigravity"])), + None + ); + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["agy"])), + Some(CLIAgent::Antigravity) + ); + } + #[test] fn status_metadata_is_consistent() { assert_eq!(AgentStatus::Idle.dot_rgb(), None); diff --git a/src/ui/assets.rs b/src/ui/assets.rs index e42038e2..5f89789f 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -59,6 +59,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/agents/grok.svg" => include_bytes!("../../assets/icons/agents/grok.svg"), "icons/agents/pi.svg" => include_bytes!("../../assets/icons/agents/pi.svg"), "icons/agents/omp.svg" => include_bytes!("../../assets/icons/agents/omp.svg"), + "icons/agents/qwen.svg" => include_bytes!("../../assets/icons/agents/qwen.svg"), _ => return None, }; Some(bytes) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 4f99d30a..9142dabe 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -707,6 +707,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAgentPi => "Pi", L10nKey::SettingsAgentGrokBuild => "Grok Build", L10nKey::SettingsAgentOhMyPi => "Oh My Pi", + L10nKey::SettingsAgentGemini => "Gemini", + L10nKey::SettingsAgentDroid => "Droid", + L10nKey::SettingsAgentQwenCode => "Qwen Code", + L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github", L10nKey::SettingsSearchAppHttpProxyKeywords => { "proxy http https socks socks5 clash v2ray network download update" @@ -787,6 +791,12 @@ pub fn translate_en(key: L10nKey) -> &'static str { "alt keyboard modifier escape macos option meta option acts as meta" } L10nKey::SettingsSearchOhMyPiKeywords => "agent integration extension install omp oh my pi", + L10nKey::SettingsSearchGeminiKeywords => "agent integration hooks install gemini google", + L10nKey::SettingsSearchDroidKeywords => "agent integration hooks install droid factory", + L10nKey::SettingsSearchQwenCodeKeywords => { + "agent integration hooks install qwen code qwen-code" + } + L10nKey::SettingsSearchGooseKeywords => "agent integration hooks plugin install goose", L10nKey::SettingsSearchPiKeywords => "agent integration extension install pi", L10nKey::SettingsSearchPortForwardingKeywords => { "ssh tunnel local remote dynamic socks forward rule" diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 4dbe2818..c14d72cc 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -716,6 +716,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentPi => "Pi", L10nKey::SettingsAgentGrokBuild => "Grok Build", L10nKey::SettingsAgentOhMyPi => "Oh My Pi", + L10nKey::SettingsAgentGemini => "Gemini", + L10nKey::SettingsAgentDroid => "Droid", + L10nKey::SettingsAgentQwenCode => "Qwen Code", + L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsSearchAboutKeywords => { "バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check" } @@ -832,6 +836,18 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchOhMyPiKeywords => { "エージェント 統合 拡張 インストール omp oh my pi agent integration extension install" } + L10nKey::SettingsSearchGeminiKeywords => { + "エージェント 統合 フック インストール gemini google agent integration hooks install" + } + L10nKey::SettingsSearchDroidKeywords => { + "エージェント 統合 フック インストール droid factory agent integration hooks install" + } + L10nKey::SettingsSearchQwenCodeKeywords => { + "エージェント 統合 フック インストール qwen code agent integration hooks install" + } + L10nKey::SettingsSearchGooseKeywords => { + "エージェント 統合 フック プラグイン インストール goose agent integration hooks plugin install" + } L10nKey::SettingsSearchPiKeywords => { "エージェント 統合 拡張 インストール pi agent integration extension install" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 4e17e6b0..2122034b 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -564,6 +564,10 @@ l10n_keys! { SettingsAgentPi, SettingsAgentGrokBuild, SettingsAgentOhMyPi, + SettingsAgentGemini, + SettingsAgentDroid, + SettingsAgentQwenCode, + SettingsAgentGoose, SettingsSearchAppHttpProxyKeywords, SettingsSearchAboutKeywords, SettingsSearchAutoDownloadKeywords, @@ -587,11 +591,14 @@ l10n_keys! { SettingsSearchDetectUrlsKeywords, SettingsSearchDiffPreviewFromCountsKeywords, SettingsSearchDimInactivePanesKeywords, + SettingsSearchDroidKeywords, SettingsSearchFocusFollowsMouseKeywords, SettingsSearchFontFamilyKeywords, SettingsSearchFontLigaturesKeywords, SettingsSearchFontSizeKeywords, SettingsSearchForwardSshLoopbackLinksKeywords, + SettingsSearchGeminiKeywords, + SettingsSearchGooseKeywords, SettingsSearchGrokBuildKeywords, SettingsSearchHideMouseWhileTypingKeywords, SettingsSearchHistorySearchKeywords, @@ -611,6 +618,7 @@ l10n_keys! { SettingsSearchPiKeywords, SettingsSearchPortForwardingKeywords, SettingsSearchProgramKeywords, + SettingsSearchQwenCodeKeywords, SettingsSearchRememberWindowSizeKeywords, SettingsSearchReportMouseToAppsKeywords, SettingsSearchRestoreLastLayoutKeywords, @@ -1497,10 +1505,14 @@ mod tests { L10nKey::SettingsAgentClaudeCode, L10nKey::SettingsAgentCodex, L10nKey::SettingsAgentCopilotCli, + L10nKey::SettingsAgentDroid, + L10nKey::SettingsAgentGemini, + L10nKey::SettingsAgentGoose, L10nKey::SettingsAgentGrokBuild, L10nKey::SettingsAgentOhMyPi, L10nKey::SettingsAgentOpencode, L10nKey::SettingsAgentPi, + L10nKey::SettingsAgentQwenCode, // Windows names its backdrop materials, and Japanese Windows keeps // those names in Latin script — so does this list. Chinese does // translate them (云母 / 亚克力), which is what Microsoft's own diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index aeb8e41d..b32c0a1e 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -625,6 +625,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentPi => "Pi", L10nKey::SettingsAgentGrokBuild => "Grok Build", L10nKey::SettingsAgentOhMyPi => "Oh My Pi", + L10nKey::SettingsAgentGemini => "Gemini", + L10nKey::SettingsAgentDroid => "Droid", + L10nKey::SettingsAgentQwenCode => "Qwen Code", + L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsSearchAboutKeywords => { "关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update" } @@ -739,6 +743,18 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchOhMyPiKeywords => { "Oh My Pi agent 集成 扩展 安装 omp oh my pi agent integration extension install" } + L10nKey::SettingsSearchGeminiKeywords => { + "Gemini agent 集成 钩子 安装 gemini google agent integration hooks install" + } + L10nKey::SettingsSearchDroidKeywords => { + "Droid agent 集成 钩子 安装 droid factory agent integration hooks install" + } + L10nKey::SettingsSearchQwenCodeKeywords => { + "Qwen Code 通义千问 agent 集成 钩子 安装 qwen code agent integration hooks install" + } + L10nKey::SettingsSearchGooseKeywords => { + "Goose agent 集成 钩子 插件 安装 goose agent integration hooks plugin install" + } L10nKey::SettingsSearchPiKeywords => { "Pi agent 集成 扩展 安装 pi agent integration extension install" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 1c0b8f46..709f47c9 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -608,6 +608,26 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsAgentOhMyPi, keywords: SettingsSearchOhMyPiKeywords, }, + SearchEntry { + section: Agents, + title: SettingsAgentGemini, + keywords: SettingsSearchGeminiKeywords, + }, + SearchEntry { + section: Agents, + title: SettingsAgentDroid, + keywords: SettingsSearchDroidKeywords, + }, + SearchEntry { + section: Agents, + title: SettingsAgentQwenCode, + keywords: SettingsSearchQwenCodeKeywords, + }, + SearchEntry { + section: Agents, + title: SettingsAgentGoose, + keywords: SettingsSearchGooseKeywords, + }, SearchEntry { section: WindowTabs, title: SettingsStartupWindow, From ef333bf0556f33c0c75bad691f9d232a61c68bb1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:16:17 +0800 Subject: [PATCH 15/33] feat(terminal): make the wheel-zoom modifier configurable (#676) Cmd-scroll zoomed the font with no way to move it or switch it off, so a thumb left on Cmd resized the terminal mid-scroll (#668). The modifier is now a setting: the platform modifier by default, or Ctrl, Alt, or none. Stored as the choice rather than the resolved key, so one config file still means the same thing on a Mac and on a Linux box. Settings -> Terminal -> Mouse carries the picker; off macOS Ctrl and the platform modifier are the same key, so it shows one cell for them. --- crates/tty7-core/src/core/config.rs | 52 ++++++++++++++++++ docs/reference/configuration.mdx | 1 + src/terminal/view.rs | 82 ++++++++++++++++++++++++++--- src/ui/app.rs | 14 ++++- src/ui/i18n/en.rs | 5 ++ src/ui/i18n/ja.rs | 5 ++ src/ui/i18n/mod.rs | 3 ++ src/ui/i18n/zh.rs | 3 ++ src/ui/settings.rs | 77 +++++++++++++++++++++++++-- 9 files changed, 231 insertions(+), 11 deletions(-) diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index c1197f65..ef6309ad 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -259,6 +259,14 @@ pub struct Config { pub smooth_scroll: bool, #[serde(default = "default_true")] pub mouse_reporting: bool, + /// Which modifier turns the wheel into a font zoom over a terminal. + /// + /// Defaults to the platform modifier, which is what tty7 has always done — + /// but on macOS that is ⌘, a key people are holding half the time for + /// something else entirely, so the font jumps size while they scroll + /// (#668). Movable, and switchable off. + #[serde(default, deserialize_with = "de_lenient")] + pub mouse_zoom_modifier: MouseZoomModifier, pub clipboard_trim_trailing_spaces: bool, pub copy_on_select: bool, /// Optional HTTP/SOCKS proxy for tty7's *own* update checks and release @@ -450,6 +458,25 @@ pub enum UpdateChannel { Nightly, } +/// The modifier that makes the mouse wheel resize the font. +/// +/// `Platform` keeps the historical binding — ⌘ on macOS, Ctrl elsewhere — and +/// is stored rather than the resolved key so one config file can be shared +/// between machines that disagree about which key that is. +/// +/// Shift is deliberately not offered: shift+wheel is the escape hatch that +/// scrolls the scrollback out from under a mouse-reporting program. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum MouseZoomModifier { + #[default] + Platform, + Ctrl, + Alt, + /// The wheel never zooms; every scroll goes to the buffer. + None, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum BellMode { @@ -582,6 +609,7 @@ impl Default for Config { mouse_scroll_multiplier: 1.0, smooth_scroll: true, mouse_reporting: true, + mouse_zoom_modifier: MouseZoomModifier::default(), clipboard_trim_trailing_spaces: false, copy_on_select: false, http_proxy: None, @@ -1635,6 +1663,30 @@ mod tests { assert_eq!(serde_json::to_string(&BellMode::Both).unwrap(), "\"both\""); } + /// #668: a config written on a Mac travels to a Linux box, where the + /// platform modifier is a different key — so the *choice* is stored, not + /// the key it resolves to. An unknown value must not silently disable + /// zooming either. + #[test] + fn the_zoom_modifier_round_trips_and_falls_back() { + let cfg: Config = serde_json::from_str(r#"{"mouse_zoom_modifier": "none"}"#).unwrap(); + assert_eq!(cfg.mouse_zoom_modifier, MouseZoomModifier::None); + + let cfg: Config = serde_json::from_str(r#"{"mouse_zoom_modifier": "alt"}"#).unwrap(); + assert_eq!(cfg.mouse_zoom_modifier, MouseZoomModifier::Alt); + + let cfg: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); + assert_eq!(cfg.mouse_zoom_modifier, MouseZoomModifier::Platform); + + let cfg: Config = serde_json::from_str(r#"{"mouse_zoom_modifier": "meta"}"#).unwrap(); + assert_eq!(cfg.mouse_zoom_modifier, MouseZoomModifier::Platform); + + assert_eq!( + serde_json::to_string(&MouseZoomModifier::None).unwrap(), + "\"none\"" + ); + } + #[test] fn sanitize_clamps_notify_threshold_into_band() { let clamp = |n: u64| { diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index 604a2a30..3ff0d300 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -101,6 +101,7 @@ their id from the file name. [More about themes →](/customization/themes) | `mouse_scroll_multiplier` | number | `1.0` | 0.1–10. | | `smooth_scroll` | bool | `true` | Ease each wheel notch. Trackpads unaffected. | | `mouse_reporting` | bool | `true` | Let full-screen apps handle clicks and scrolling. | +| `mouse_zoom_modifier` | enum | `"platform"` | Modifier that makes the wheel resize the font: `platform` (⌘ on macOS, Ctrl elsewhere), `ctrl`, `alt`, `none`. | | `mouse_hide_while_typing` | bool | `true` | | | `focus_follows_mouse` | bool | `false` | | diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 59524cdb..f2665f00 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -29,7 +29,7 @@ use crate::core::actions::{ ForkAgentSessionRight, ForkAgentSessionUp, IncreaseFontSize, NewTab, SendBackTab, SendTab, SplitDown, SplitRight, ToggleMaximizePane, }; -use crate::core::config::{BellMode, Config, LinkFileOpen, NotifyMode}; +use crate::core::config::{BellMode, Config, LinkFileOpen, MouseZoomModifier, NotifyMode}; use crate::daemon::protocol::{RemoteContext, ShellSpec}; use crate::ui::i18n::{L10nKey, t, t_fmt}; @@ -4707,11 +4707,11 @@ impl TerminalView { } fn on_scroll(&mut self, ev: &ScrollWheelEvent, window: &mut Window, cx: &mut Context) { - // The platform modifier alone turns the wheel into a zoom, the way it - // does in a browser. Any other modifier alongside it is somebody else's - // gesture — shift in particular is the escape hatch that scrolls the - // scrollback out from under a mouse-reporting program. - if ev.modifiers.secondary() && ev.modifiers.number_of_modifiers() == 1 { + // One modifier turns the wheel into a zoom, the way it does in a + // browser. Which one is the user's to say, because the default is the + // platform modifier and on macOS that is a key half the world is + // already holding for something else (#668). + if zoom_wheel(cx.global::().mouse_zoom_modifier, &ev.modifiers) { self.zoom_scroll(ev, window, cx); return; } @@ -6732,6 +6732,27 @@ fn wrapped_click_index( } } +/// Whether this wheel event is a zoom rather than a scroll. +/// +/// Exactly one modifier, and it has to be the configured one: anything +/// alongside it is somebody else's gesture — shift in particular is the escape +/// hatch that scrolls the scrollback out from under a mouse-reporting program. +/// +/// Off macOS the platform modifier *is* Ctrl, so `Platform` and `Ctrl` describe +/// the same key there; the setting still round-trips, so a config file shared +/// with a Mac keeps meaning what it meant. +fn zoom_wheel(modifier: MouseZoomModifier, mods: &Modifiers) -> bool { + if mods.number_of_modifiers() != 1 { + return false; + } + match modifier { + MouseZoomModifier::Platform => mods.secondary(), + MouseZoomModifier::Ctrl => mods.control, + MouseZoomModifier::Alt => mods.alt, + MouseZoomModifier::None => false, + } +} + /// How many font-size steps a zoom event is worth, and what is left over for /// the next one. /// @@ -10488,6 +10509,55 @@ mod gpui_tests { .unwrap(); } + /// #668: the platform modifier is the wrong key to hardwire a zoom to — + /// on a Mac it is ⌘, which people are already holding for something + /// else — so which key zooms is a setting, all the way down to none. + #[test] + fn the_zoom_modifier_is_configurable_and_can_be_turned_off() { + let secondary = Modifiers::secondary_key(); + let alt = Modifiers::alt(); + assert!(zoom_wheel(MouseZoomModifier::Platform, &secondary)); + assert!( + !zoom_wheel(MouseZoomModifier::None, &secondary), + "off is off" + ); + assert!(!zoom_wheel(MouseZoomModifier::Alt, &secondary)); + assert!(zoom_wheel(MouseZoomModifier::Alt, &alt)); + assert!(!zoom_wheel(MouseZoomModifier::Platform, &alt)); + assert!(zoom_wheel(MouseZoomModifier::Ctrl, &Modifiers::control())); + assert_eq!( + zoom_wheel(MouseZoomModifier::Platform, &Modifiers::control()), + !cfg!(target_os = "macos"), + "off macOS the platform modifier is Ctrl itself" + ); + // A second modifier is somebody else's gesture, whichever key is bound. + let both = Modifiers { shift: true, ..alt }; + assert!(!zoom_wheel(MouseZoomModifier::Alt, &both)); + assert!(!zoom_wheel(MouseZoomModifier::Platform, &Modifiers::none())); + } + + /// The point of turning it off: the modifier goes back to being an + /// ordinary scroll, rather than eating the wheel. + #[gpui::test] + fn zooming_off_hands_the_wheel_back_to_the_scrollback(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + cx.update(|cx| { + cx.global_mut::().mouse_zoom_modifier = MouseZoomModifier::None; + }); + window + .update(cx, |view, w, cx| { + scroll_into_history(view, 10); + let mut ev = notch(view, -4.9); + ev.modifiers = Modifiers::secondary_key(); + view.on_scroll(&ev, w, cx); + assert!( + view.scroll_anim.is_some(), + "the wheel never reached the scrollback" + ); + }) + .unwrap(); + } + /// A detent is one step however many lines the platform bills it as — /// macOS calls a single notch five. #[test] diff --git a/src/ui/app.rs b/src/ui/app.rs index b9f6203e..eb2c7fc6 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -15,8 +15,8 @@ use std::sync::Arc; use crate::core::actions::*; use crate::core::config::{ - Config, CursorStyle as ConfigCursorStyle, NewTabPosition, RightPanelTab, ShellConfig, - TabBarPosition, WindowBackdrop, + Config, CursorStyle as ConfigCursorStyle, MouseZoomModifier, NewTabPosition, RightPanelTab, + ShellConfig, TabBarPosition, WindowBackdrop, }; use crate::core::session::{ Session, SessionAxis, SessionPane, SessionTab, WorkspaceId, WorkspaceStore, @@ -2955,6 +2955,16 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.smooth_scroll = on); } + /// Panes read the modifier off the global config as each wheel event + /// arrives, so there is nothing to push at them here. + pub(crate) fn set_mouse_zoom_modifier( + &mut self, + modifier: MouseZoomModifier, + cx: &mut Context, + ) { + self.update_config(cx, |cfg| cfg.mouse_zoom_modifier = modifier); + } + pub(crate) fn set_clipboard_trim(&mut self, on: bool, cx: &mut Context) { self.update_config(cx, |cfg| cfg.clipboard_trim_trailing_spaces = on); } diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 9142dabe..1db56b4f 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -406,6 +406,11 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsHideMouseWhileTypingDesc => { "Hide the pointer as you type; it returns on the next move." } + L10nKey::SettingsMouseZoom => "Zoom with the wheel", + L10nKey::SettingsMouseZoomDesc => { + "Modifier that makes the mouse wheel resize the terminal font instead of scrolling." + } + L10nKey::SettingsMouseZoomOff => "Off", L10nKey::SettingsReportMouseToApps => "Report mouse to apps", L10nKey::SettingsReportMouseToAppsDesc => { "Let full-screen apps (vim, tmux) handle clicks and scrolling; hold Shift to keep a gesture local." diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index c14d72cc..4b7edcd9 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -417,6 +417,11 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsHideMouseWhileTypingDesc => { "入力中はポインタを隠し、次のマウス移動で再表示する" } + L10nKey::SettingsMouseZoom => "ホイールで拡大縮小", + L10nKey::SettingsMouseZoomDesc => { + "この修飾キーを押しながらホイールを回すと、スクロールではなくフォントサイズが変わる" + } + L10nKey::SettingsMouseZoomOff => "オフ", L10nKey::SettingsReportMouseToApps => "マウスイベントをアプリに報告", L10nKey::SettingsReportMouseToAppsDesc => { "フルスクリーンアプリ(vim、tmux)にクリックとスクロールを処理させる。Shift を押している間はローカルで処理されます" diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 2122034b..61872198 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -371,6 +371,9 @@ l10n_keys! { SettingsFocusFollowsMouseDesc, SettingsHideMouseWhileTyping, SettingsHideMouseWhileTypingDesc, + SettingsMouseZoom, + SettingsMouseZoomDesc, + SettingsMouseZoomOff, SettingsReportMouseToApps, SettingsReportMouseToAppsDesc, SettingsBell, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index b32c0a1e..da4ed249 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -356,6 +356,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsFocusFollowsMouseDesc => "悬停窗格即聚焦,无需点击。", L10nKey::SettingsHideMouseWhileTyping => "输入时隐藏鼠标", L10nKey::SettingsHideMouseWhileTypingDesc => "输入时隐藏指针;下次移动鼠标时恢复。", + L10nKey::SettingsMouseZoom => "滚轮缩放", + L10nKey::SettingsMouseZoomDesc => "按住该修饰键滚轮时缩放终端字号,而不是滚动。", + L10nKey::SettingsMouseZoomOff => "关闭", L10nKey::SettingsReportMouseToApps => "向应用报告鼠标", L10nKey::SettingsReportMouseToAppsDesc => { "让全屏应用(如 vim、tmux)处理点击和滚动;按住 Shift 可让操作保持本地。" diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 709f47c9..81335a02 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -23,8 +23,8 @@ use std::sync::Arc; use uuid::Uuid; use crate::core::config::{ - BellMode, Config, CursorStyle, LinkFileOpen, NewTabPosition, NotifyMode, TabBarPosition, - UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop, + BellMode, Config, CursorStyle, LinkFileOpen, MouseZoomModifier, NewTabPosition, NotifyMode, + TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop, }; use crate::core::keychain::CredentialRef; use crate::core::ssh_profile::{ @@ -5232,6 +5232,7 @@ impl Tty7App { let scroll_mult = cfg.mouse_scroll_multiplier; let smooth_scroll = cfg.smooth_scroll; let mouse_reporting = cfg.mouse_reporting; + let mouse_zoom = cfg.mouse_zoom_modifier; let bell = cfg.bell; // A bucket highlights only on an exact match; any other value gets a // "Custom (N)" cell so the highlight never claims a number the config @@ -5319,6 +5320,42 @@ impl Tty7App { .checked(mouse_reporting) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_mouse_reporting(*on, cx))) .into_any_element(); + // Ctrl only earns a cell where it is a different key from the + // platform modifier: off macOS the two are the same key, and a + // segmented control with the same key twice is a bug the user has to + // decode. A config that names `ctrl` there still highlights it, in the + // one cell that means it. + let mac = cfg!(target_os = "macos"); + let zoom_labels: Vec<&str> = if mac { + vec!["⌘", "⌃", "⌥", t(L10nKey::SettingsMouseZoomOff)] + } else { + vec!["Ctrl", "Alt", t(L10nKey::SettingsMouseZoomOff)] + }; + let zoom_idx = match (mouse_zoom, mac) { + (MouseZoomModifier::Platform, _) => 0, + (MouseZoomModifier::Ctrl, true) => 1, + (MouseZoomModifier::Ctrl, false) => 0, + (MouseZoomModifier::Alt, true) => 2, + (MouseZoomModifier::Alt, false) => 1, + (MouseZoomModifier::None, true) => 3, + (MouseZoomModifier::None, false) => 2, + }; + let zoom_control = self.segmented( + "term-mouse-zoom", + &zoom_labels, + zoom_idx, + cx, + move |this, ix, _w, cx| { + let modifier = match (ix, mac) { + (0, _) => MouseZoomModifier::Platform, + (1, true) => MouseZoomModifier::Ctrl, + (1, false) => MouseZoomModifier::Alt, + (2, true) => MouseZoomModifier::Alt, + _ => MouseZoomModifier::None, + }; + this.set_mouse_zoom_modifier(modifier, cx); + }, + ); let bell_idx = match bell { BellMode::None => 0, BellMode::Visual => 1, @@ -5410,6 +5447,12 @@ impl Tty7App { mouse_report_switch, cx, )) + .child(self.settings_row( + t(L10nKey::SettingsMouseZoom), + t(L10nKey::SettingsMouseZoomDesc), + zoom_control, + cx, + )) .child(self.section_rule(cx)) .child(self.section_header(t(L10nKey::SettingsBell), cx)) .child(self.settings_row( @@ -8027,7 +8070,7 @@ mod tests { #[cfg(test)] mod gpui_tests { use super::SettingsSection; - use crate::core::config::Config; + use crate::core::config::{Config, MouseZoomModifier}; use crate::core::session::Session; use crate::ui::app::Tty7App; use gpui::{AppContext as _, Entity, TestAppContext, VisualTestContext, px, size}; @@ -8083,6 +8126,34 @@ mod gpui_tests { ); } + /// #668: the Terminal page carries the control that moves the zoom off the + /// platform modifier, so the page has to paint with it, and the pick has to + /// reach the config the wheel reads. + #[gpui::test] + fn the_terminal_page_paints_the_zoom_modifier_row(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + app.update_in(&mut vcx, |app, window, cx| { + app.open_settings_section(SettingsSection::Terminal, window, cx); + }); + vcx.simulate_resize(size(px(1100.), px(800.))); + vcx.run_until_parked(); + + let modifier = vcx.update(|_, cx| cx.global::().mouse_zoom_modifier); + assert_eq!( + modifier, + MouseZoomModifier::Platform, + "the wheel still zooms out of the box" + ); + + app.update_in(&mut vcx, |app, _, cx| { + app.set_mouse_zoom_modifier(MouseZoomModifier::None, cx) + }); + vcx.run_until_parked(); + let modifier = vcx.update(|_, cx| cx.global::().mouse_zoom_modifier); + assert_eq!(modifier, MouseZoomModifier::None, "and the pick sticks"); + } + /// The Input page paints with the prompt editor off — that is the state /// where two of its rows are greyed out and their switches disabled — and /// the cascade only *disables* those two. It must not rewrite what they From 3c95995e828cbdf5a4ed5411fd76fcffbb3e7327 Mon Sep 17 00:00:00 2001 From: webdev <86946125+biztex@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:28:01 +0700 Subject: [PATCH 16/33] fix(input): hand Ctrl+V to a full-screen program on the alternate screen (#677) (#682) In vim or neovim on Windows and Linux, Ctrl+V pasted the clipboard where the editor expected blockwise Visual mode. Windows Terminal (with its ctrl+v binding removed), WezTerm and Alacritty all send the key; macOS was never affected, since Cmd+V is the paste chord there. Ctrl+V was not a keybinding at all. `on_key_down` hands plain Ctrl+C, V and X to `handle_cmd_shortcut` off macOS, and of the three the "v" arm was the only unconditional one: Ctrl+C copies with a selection and otherwise falls through to SIGINT, Ctrl+X falls through outside the editor, but Ctrl+V always consumed, so SYN never reached the PTY -- `input.rs` had the byte, unreachably -- and an empty clipboard turned the key into nothing at all. #270 set the rule that off macOS ctrl- belongs to the terminal and anything sitting on one must fall through; Ctrl+V was the exception that had escaped it. The arm is now contextual like its neighbours. On the alternate screen it falls through, and `keystroke_to_bytes` sends 0x16, or the CSI u form when the program has the kitty protocol on; off it Ctrl+V pastes exactly as before, and Cmd+V on macOS is untouched. The alternate screen is the gate rather than `input_active` because the editor is inactive whenever shell integration is missing or the prompt editor is off, and gating on that would take paste away from every such user; a program that has switched screens is precisely the case reported. Inside such a program paste is Ctrl+Shift+V, Shift+Insert or the right-click menu, all of which still stage a clipboard image for an agent. The same block did not exclude Shift, so Ctrl+Shift+C/V/X reached the hardcoded path whenever the keymap had nothing on them -- exactly the state rebinding Paste leaves behind, which #271 promised would retire Ctrl+Shift+V, but it went on pasting behind the user's back. Only unshifted chords enter the block now; the shifted ones are the keymap's alone. The right-click menu advertised Ctrl+C, Ctrl+X and Ctrl+V off macOS as though they were the bindings, next to a Select All row that already showed its hint on macOS only. The three rows take the same treatment, which is also what the command palette does. Three view tests pin the split -- Ctrl+V falls through on the alternate screen while Cmd+V still pastes there, Ctrl+V pastes off it, and a key down on the alternate screen arrives at the PTY as SYN and nothing else -- and the keymap's paste test now asserts that no default claims ctrl-v in the Terminal context. The shortcuts reference notes where plain Ctrl+V pastes and where it is the program's. Fixes #677. --- docs/reference/keyboard-shortcuts.mdx | 5 ++ src/terminal/view.rs | 113 ++++++++++++++++++++++++-- src/ui/keymap.rs | 6 ++ 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/docs/reference/keyboard-shortcuts.mdx b/docs/reference/keyboard-shortcuts.mdx index 495e2445..be91bb37 100644 --- a/docs/reference/keyboard-shortcuts.mdx +++ b/docs/reference/keyboard-shortcuts.mdx @@ -53,6 +53,11 @@ it shows what *your* copy is bound to. This is the shipped default. | Accept ghost suggestion | | same | | Completion menu | | same | +On Windows and Linux plain Ctrl V also pastes at a shell prompt. +Inside a full-screen application — vim, less, tmux — it is passed through as +the key, so it means what the application says it means (blockwise Visual mode +in vim); paste there with Ctrl ⇧ V or ⇧ Insert. + ## Git and SSH | Action | macOS | Windows / Linux | diff --git a/src/terminal/view.rs b/src/terminal/view.rs index f2665f00..53fd74b6 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1844,10 +1844,13 @@ impl TerminalView { } } + // Ctrl+Shift+C/V/X are the keymap's alone: rebinding Paste has to + // retire Ctrl+Shift+V, which it cannot if this path answers it too. if cfg!(not(target_os = "macos")) && m.control && !m.platform && !m.alt + && !m.shift && matches!(ks.key.as_str(), "c" | "v" | "x") { match self.handle_cmd_shortcut(ks, window, cx) { @@ -1983,8 +1986,16 @@ impl TerminalView { } } "v" => { - self.paste_from_clipboard(cx); - CmdKey::Consumed + // Off macOS Ctrl+V is a control code first: on the alternate + // screen the key is the program's (vim's blockwise select), the + // way Ctrl+C is SIGINT when there is nothing to copy. Cmd+V + // pastes anywhere. + if m.control && !m.platform && self.on_alt_screen() { + CmdKey::FallThrough + } else { + self.paste_from_clipboard(cx); + CmdKey::Consumed + } } "a" => { self.select_all_contextual(cx); @@ -6139,16 +6150,16 @@ impl Render for TerminalView { .menu_element_with_disabled( Box::new(CopyText), !has_selection, - menu_row_with_hint(t(L10nKey::AppMenuCopy), Some("secondary-c")), + menu_row_with_hint(t(L10nKey::AppMenuCopy), mac_only("secondary-c")), ) .menu_element_with_disabled( Box::new(CutText), !has_selection, - menu_row_with_hint(t(L10nKey::AppMenuCut), Some("secondary-x")), + menu_row_with_hint(t(L10nKey::AppMenuCut), mac_only("secondary-x")), ) .menu_element( Box::new(PasteText), - menu_row_with_hint(t(L10nKey::AppMenuPaste), Some("secondary-v")), + menu_row_with_hint(t(L10nKey::AppMenuPaste), mac_only("secondary-v")), ) .menu_element( Box::new(SelectAll), @@ -8355,6 +8366,27 @@ mod gpui_tests { panic!("the prompt report never reached the view"); } + fn alt_screen_ready( + window: &gpui::WindowHandle, + cx: &mut TestAppContext, + daemon: &mut UnixStream, + ) { + DaemonMsg::Output(b"\x1b[?1049h".to_vec()) + .encode(daemon) + .unwrap(); + for _ in 0..400 { + cx.run_until_parked(); + if window + .update(cx, |view, _, _| view.on_alt_screen()) + .unwrap() + { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("the alternate-screen switch never reached the grid"); + } + #[gpui::test] fn an_agent_that_has_finished_its_turn_is_not_busy(cx: &mut TestAppContext) { use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent}; @@ -9113,6 +9145,34 @@ mod gpui_tests { ); } + #[gpui::test] + fn ctrl_v_on_the_alternate_screen_reaches_the_pty_as_syn(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("echo hi".into()))); + alt_screen_ready(&window, cx, &mut daemon); + window + .update(cx, |view, window, cx| { + assert!(!view.input_active(), "the full-screen program owns input"); + view.on_key_down( + &KeyDownEvent { + keystroke: key("ctrl-v"), + is_held: false, + prefer_character_input: false, + }, + window, + cx, + ); + }) + .unwrap(); + + assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x16])); + assert_eq!( + next_input_until_timeout(&mut daemon), + None, + "the clipboard must stay where it is: vim's Ctrl+V is blockwise select, not paste" + ); + } + #[gpui::test] fn shell_vi_mode_prompt_bypasses_the_local_editor(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -12068,6 +12128,49 @@ mod gpui_tests { assert_eq!(text.as_deref(), Some("hello")); } + #[gpui::test] + fn ctrl_v_reaches_a_tui_on_the_alternate_screen(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("echo hi".into()))); + alt_screen_ready(&window, cx, &mut daemon); + + window + .update(cx, |view, window, cx| { + let fell_through = view.handle_cmd_shortcut(&key("ctrl-v"), window, cx); + assert!( + matches!(fell_through, CmdKey::FallThrough), + "a full-screen program owns Ctrl+V" + ); + let pasted = view.handle_cmd_shortcut(&key("cmd-v"), window, cx); + assert!( + matches!(pasted, CmdKey::Consumed), + "Cmd+V is a paste chord on every screen" + ); + }) + .unwrap(); + assert_eq!( + next_input(&mut daemon), + b"echo hi".to_vec(), + "only the Cmd+V paste may reach the PTY" + ); + assert_eq!(next_input_until_timeout(&mut daemon), None); + } + + #[gpui::test] + fn ctrl_v_pastes_off_the_alternate_screen(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("echo hi".into()))); + + window + .update(cx, |view, window, cx| { + assert!(!view.on_alt_screen()); + let consumed = view.handle_cmd_shortcut(&key("ctrl-v"), window, cx); + assert!(matches!(consumed, CmdKey::Consumed)); + }) + .unwrap(); + assert_eq!(next_input(&mut daemon), b"echo hi".to_vec()); + } + #[cfg(target_os = "macos")] #[gpui::test] fn cmd_backspace_reaches_a_foreground_tui_as_ctrl_u(cx: &mut TestAppContext) { diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index a514fe95..ccc07265 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -1328,6 +1328,12 @@ mod tests { "{key} is a terminal chord and must not paste outside one" ); } + // Plain Ctrl+V is the terminal's, not the keymap's: the pane pastes on + // it at a prompt and hands it to a full-screen program otherwise. + assert!( + dispatched(&effective, "ctrl-v", "Terminal").is_empty(), + "ctrl-v is a control code and no default may claim it" + ); let rebound = vec![("PasteText".to_string(), "ctrl-alt-v".to_string())]; assert!( !extra_keystrokes(&rebound) From f44b667639475a1ccb41b7381d737040f3c879a5 Mon Sep 17 00:00:00 2001 From: webdev <86946125+biztex@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:24:12 +0700 Subject: [PATCH 17/33] fix(restart): fail a silent Attach, and hold the tabs a rebuild could not put up (#673) (#681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A restart on nightly 26.8.4 came back with every restored coding-agent pane locked: Ctrl-Z printed its suspended message and never returned to a shell, Ctrl-C did nothing, no keystroke reached anything (#673). Its sibling — a restart after an upgrade that came back to an empty workspace (#672) — was mostly closed by #554 and #579; what is left of it is closed here too, because both are the same mistake, a restart's rebuild reporting a success it did not have. The locked panes are an `Attach` the client took on trust. `attach_reply_prefix` reads far enough into the daemon's reply to tell an `Error` frame from a replay, and a read that timed out with nothing in the buffer fell through to the success branch: silence was read as "a quiet pane". But a quiet pane is never silent. `attach_subscriber` replays the pane's ring before the daemon reads a byte of our input, the ring always holds a segment (`ReplayRing::new` starts with one and every path that empties it puts one back), and every daemon build there has been queues a `Size` and then a `Snapshot` first — a pane that has printed nothing still answers with its geometry. So an `Attach` that produced no bytes in the whole wait is one nobody is serving: a daemon still mid-restart, or a socket some process holds open and will never read. Taken for an attach, it made `spawn_shell_terminal_in` report `restored = true`, the flag that skips the fresh spawn, the restored-screen banner and the agent's `--resume`; and `write` threw every encode error away, so the keystrokes, Ctrl-C and Ctrl-Z all went into that socket and vanished. Zero bytes is now the failure it is, and the caller falls through to the path it already had for a pane that is gone — a fresh shell under the old screen, with the resume typed. Nothing changes on the wire. That silence has a second reading, though, and only one of the two is safe to act on. A daemon merely slow to serve — an execve handoff keeps the listener and its backlog across the exec, and a fresh daemon adopts its panes and seeds ids before it takes an Attach — would have served the connection a moment later, and a fresh pane spawned over that live one carries its history across (`history::carry` is written for a dead pane) and starts the agent's resume against a session the old process still holds. So a silent local Attach is confirmed before it is acted on: the client asks the daemon `Version` on a fresh connection, which a daemon answers before it touches any state. Answered, the daemon is up and serving and the attach socket is one it will never serve — the verdict stands. Unanswered too, nobody is serving yet; there is no third path from a synchronous UI-thread call, so the attach still fails, but the error and the log line say which silence it was rather than claiming the pane is gone, since that is the line someone reads while diagnosing an orphaned shell. Only local routes probe: a remote attach already waits fifteen seconds and a second routed connection is a second bridge process. The two-second local budget is unchanged — only a silent connection ever pays it, and N silent panes hold the window still for N of them. `write` also stops swallowing the link refusing input. The first refusal is logged once from the writing side, and unless the reader was retired for a relink the pane is marked exited by the reader's own signal — `exited_flag`, then the `Exit` event — since it is the same socket, only found dead from the writing side first; the reader still raises its own when it gets there, and the handler is idempotent. A retired link stays quiet, for the reason the retired reader does. This is hardening for a closed link, not the cure for #673 — a socket held open and never read accepts writes into its buffer, and nothing here fires; the attach change is what keeps that pane from existing. The tabs that did not come back are the rebuild's licence outrunning what it rebuilt. `tabs_from_session` drops any tab none of whose panes would start; `settle_hydration` then marked the window `informed` as long as *some* tab rebuilt, while the mirror it had just installed still listed every tab the machine holds. The next `sync_window` ran at `SyncScope::Full`, and `diff` at that scope emits `TabClose` for every mirror tab not in `desired` — which the dropped tabs were not, and `held` did not cover them: it only covers tabs on screen whose panes cannot be represented. A partial rebuild deleted from the machine exactly the tabs it had failed to rebuild, panes and all. They are held now, rather than the licence withheld. `settle_rebuild` records the wanted ids the window is not showing (`not_rebuilt`), and `sync_window` carries them into `held`, whose contract in `diff` is already "mirror tabs the window cannot speak for — close nothing, and do not reorder around them". Withholding the licence would have been the smaller change, and it is what the none-rebuilt case does, but it takes `TabClose` away from the whole window for as long as the failure stands, and a failure can stand across every restart (a tab whose shell is no longer on the machine): every close the user made in the meantime would come back on the next rebuild. Holding only the tabs that failed leaves the window speaking for the ones it did put up. The set is rewritten by the next rebuild and pruned against the mirror on every sync, so a tab the machine lets go of stops being held. The none-rebuilt guard is unchanged: a window that put nothing up still does not speak for the workspace at all. Two things about the held set said out loud. It reads the count of tabs the tree asked for, not the ids it found: `tree_id` is not serialized, so a session that reached this path from disk would name no ids, and "no ids" must not read as "no tabs wanted" — that would hand the licence to a window that rebuilt nothing, which is #672 again. And holding has a cost with no retry: `diff` stops before its reorder pass and the active-tab op whenever anything is held, and nothing rewrites the set but the next rebuild — a re-prime and an `IfEmpty` hydrate on a populated window never get there — so a tab that fails to rebuild holds the window's tab order and active tab off the machine until the next restart. That state was already reachable, since a pane whose remote spawn failed stays connecting for the same span, held the same way; this widens a standing hole rather than opening one, and a retry, or a way to close a held tab from the window, is separate work. --- src/terminal/mod.rs | 2 +- src/terminal/remote.rs | 267 ++++++++++++++++++++++++++++++++++++++- src/terminal/view.rs | 7 ++ src/ui/tree_sync.rs | 274 ++++++++++++++++++++++++++++++++++++++--- 4 files changed, 527 insertions(+), 23 deletions(-) diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index fc6fdd33..59979e26 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -29,5 +29,5 @@ mod typeahead; pub mod view; pub(crate) use remote::notify_desktop; -pub use remote::{PaneRoute, PaneWorkspace, RemoteTerminal}; +pub use remote::{PaneRoute, PaneWorkspace, RemoteTerminal, attach_unanswered}; pub use size::TermSize; diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 15f78582..48aadb58 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -219,6 +219,10 @@ pub struct RemoteTerminal { /// flag under the term lock before every grid mutation, so once it is set /// the abandoned thread can only exit, never write. reader_quit: Arc, + /// Set by the first `Input` the link refused, so the loss is said once + /// rather than once per keystroke. Cleared when a relink installs a link + /// that has not refused anything yet. + input_lost: AtomicBool, } /// The workspace id a spawn carries, so the pane's shell gets `$TTY7_WS` and a @@ -405,7 +409,36 @@ impl RemoteTerminal { let win = win_size(size, cell_w, cell_h); ClientMsg::Attach { pane_id, size: win }.encode(&mut stream)?; - let buffered = attach_reply_prefix(&mut stream, pane_id, attach_reply_wait(route))?; + let buffered = match attach_reply_prefix(&mut stream, pane_id, attach_reply_wait(route)) { + Ok(buffered) => buffered, + Err(e) if route.is_local() && attach_unanswered(&e) => { + // Silence on the attach socket has two readings, and only one + // of them is safe to act on. Ask the daemon on a fresh + // connection: if it answers `Version` there, it is up and + // serving, and the attach connection is one it will never + // serve — a socket some process holds open, or one from a + // listener no longer drained — so the verdict stands and the + // caller spawns fresh. If it does not answer there either, it + // is not serving anyone yet — mid-restart, mid-handoff — and + // a fresh pane spawned now would land on a live one the moment + // it comes up (its history carried across, its agent session + // resumed twice). There is no third path from a synchronous + // UI-thread call, so the attach still fails, but says which + // silence it was: the log line someone reads while diagnosing + // an orphaned shell must not claim the pane was gone. + // + // Only local routes probe: a remote attach already waits 15 s + // and a second routed connection is a second bridge process. + if local_daemon_answers() { + return Err(e); + } + return Err(e.context( + "the daemon answered nothing on a fresh connection either — it is not \ + serving yet (restarting?), so this pane may still be alive", + )); + } + Err(e) => return Err(e), + }; let mut term = Self::from_stream_with(stream, size, buffered)?; term.route = route.clone(); Ok(term) @@ -480,6 +513,7 @@ impl RemoteTerminal { } self.reader_thread = Some(reader); self.reader_quit = quit; + self.input_lost.store(false, Ordering::SeqCst); self.route = route.clone(); self.synced_size = false; self.resize(size, cell_w, cell_h); @@ -577,6 +611,7 @@ impl RemoteTerminal { proxy, reader_thread: Some(reader_thread), reader_quit, + input_lost: AtomicBool::new(false), }) } @@ -1094,11 +1129,42 @@ impl RemoteTerminal { if bytes.is_empty() { return; } - if let Ok(mut writer) = self.writer.lock() { - let _ = ClientMsg::Input(bytes.into_owned()).encode(&mut *writer); + let Ok(mut writer) = self.writer.lock() else { + return; + }; + if let Err(e) = ClientMsg::Input(bytes.into_owned()).encode(&mut *writer) { + drop(writer); + self.note_input_lost(&e); } } + /// The link refused an `Input`. Every keystroke after the first would say + /// the same thing, so this side says it once; and unless the reader has + /// been retired for a relink or a release, the pane is marked exited the + /// way the reader marks it on EOF — it is the same socket, noticed from the + /// writing side first — so the window shows the pane as gone instead of + /// taking input into it that nothing will ever read. The reader still + /// raises its own `Exit` when it finds the same socket closed; the handler + /// is idempotent, so a link that is genuinely gone may be reported twice. + /// + /// This is hardening for a *closed* link, not the cure for #673: a socket + /// some process holds open and never reads accepts writes into its send + /// buffer, so they succeed and vanish until the buffer fills, and nothing + /// here fires. What stops that pane existing at all is `attach_on` + /// refusing to call a silent `Attach` attached. + fn note_input_lost(&self, err: &std::io::Error) { + if self.input_lost.swap(true, Ordering::SeqCst) { + return; + } + log::warn!("the daemon link stopped taking this pane's input: {err}"); + if self.reader_quit.load(Ordering::SeqCst) { + return; + } + self.exited_flag.store(true, Ordering::SeqCst); + self.proxy.send_event(AlacEvent::Wakeup); + self.proxy.send_event(AlacEvent::Exit); + } + /// Whether the daemon behind this pane echoes a `DaemonMsg::Size` into the /// output stream when it applies our `ClientMsg::Resize`. When it does, the /// local grid reflow is deferred to that echo on the reader thread: any @@ -1646,6 +1712,21 @@ fn daemon_not_listening(err: &anyhow::Error) -> bool { }) } +/// How long an `Attach` may stay silent before it is called unanswered. +/// +/// Only a silent connection ever pays this: a dead pane answers `Error` at +/// once and a live one answers `Size` at once, so the budget is not a cost in +/// the common case. What bounds it is the caller — a local restore attaches +/// synchronously on the UI thread, one pane after another, so N silent panes +/// hold the window still for N times this. What argues for more is the other +/// side of the same silence: a daemon merely slow to serve — an execve handoff +/// keeps the listener and its backlog across the exec, and a fresh daemon is +/// adopting panes and seeding ids before it can take an `Attach` — would have +/// served this connection a moment later, and calling it unanswered spawns a +/// fresh pane over a live one, carries the live pane's history onto it and +/// starts an agent resume against a session the old process still holds. The +/// [`AttachUnanswered`] verdict is confirmed against that case in `attach_on` +/// rather than by waiting longer here. fn attach_reply_wait(route: &PaneRoute) -> std::time::Duration { match route.is_local() { true => std::time::Duration::from_secs(2), @@ -1653,6 +1734,48 @@ fn attach_reply_wait(route: &PaneRoute) -> std::time::Duration { } } +/// An `Attach` that produced no bytes within its wait — nobody served the +/// connection. Distinct from a refusal (`Error` frame) and from a hangup so +/// the caller can say the true thing: the pane may well still exist. +#[derive(Debug)] +struct AttachUnanswered { + pane_id: u64, + wait: std::time::Duration, +} + +impl std::fmt::Display for AttachUnanswered { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "the daemon did not answer Attach for pane {} within {:?} (an attached pane replays \ + its screen at once, so nobody is serving this connection)", + self.pane_id, self.wait + ) + } +} + +impl std::error::Error for AttachUnanswered {} + +/// Whether `err` is an `Attach` that went unanswered, as opposed to refused or +/// hung up on. The pane behind an unanswered attach may still be alive. +pub fn attach_unanswered(err: &anyhow::Error) -> bool { + err.chain() + .any(|cause| cause.downcast_ref::().is_some()) +} + +/// Reads far enough into the daemon's answer to an `Attach` to classify it, and +/// hands back every byte read so the reader thread loses none of the replay. +/// +/// Silence is a verdict, not a quiet pane. A daemon that attached replays the +/// pane's ring before it reads a byte of our input, and the ring always holds +/// a segment, so the first frame on a good attach is a `Size` — a quiet pane +/// still sends that. An `Attach` that produced nothing within `wait` is one +/// nobody is serving: a daemon still mid-restart, a socket held open by a +/// process that will never read it. Taken for success it gave the window a +/// pane drawn as restored whose every keystroke went into that socket — no +/// fresh shell, no restored-screen banner, no agent resume, and Ctrl-C did +/// nothing (#673). Taken for the failure it is, the caller spawns fresh and +/// asks for the old screen back. fn attach_reply_prefix( stream: &mut Stream, pane_id: u64, @@ -1684,6 +1807,9 @@ fn attach_reply_prefix( kind = crate::daemon::protocol::peek_frame_kind(&buffered); } let _ = stream.set_read_timeout(None); + if buffered.is_empty() { + return Err(anyhow::Error::new(AttachUnanswered { pane_id, wait })); + } if !kind.is_some_and(crate::daemon::protocol::is_error_kind) { return Ok(buffered); } @@ -2155,6 +2281,30 @@ fn connect() -> anyhow::Result { }) } +/// Whether the local daemon answers `Version` on a fresh connection right now. +/// +/// The one question a silent `Attach` leaves open — is the daemon serving and +/// this socket orphaned, or is nobody serving yet? A daemon answers `Version` +/// before it touches any state, so this is the cheapest thing it can say. One +/// second is the same budget `spawn` gives the same handshake; a healthy +/// daemon answers in microseconds. +fn local_daemon_answers() -> bool { + use std::io::Write as _; + + let Ok(mut stream) = transport::connect() else { + return false; + }; + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(1))); + if ClientMsg::Version + .encode(&mut stream) + .and_then(|()| stream.flush()) + .is_err() + { + return false; + } + matches!(DaemonMsg::read(&mut stream), Ok(DaemonMsg::Version(_))) +} + fn connect_routed(route: &PaneRoute) -> anyhow::Result { if let PaneRoute::Unroutable(reason) = route { return Err(anyhow::anyhow!("{reason}")); @@ -2760,6 +2910,61 @@ mod tests { ); } + /// #673: a daemon that attached replays the pane's ring at once, and the + /// ring always holds a segment, so even a pane that has printed nothing + /// answers with a `Size`. A connection that stays silent for the whole wait + /// is therefore one nobody is serving — read as success, it became a pane + /// the window drew as restored while every keystroke, Ctrl-C included, + /// went into a socket nothing drained. + #[test] + fn an_attach_nobody_answers_is_not_an_attach() { + let (mut client_side, _daemon_side) = UnixStream::pair().unwrap(); + let wait = std::time::Duration::from_millis(200); + let err = attach_reply_prefix(&mut client_side, 7, wait) + .expect_err("silence for the whole wait must not pass for an attached pane"); + assert!( + format!("{err:#}").contains("did not answer"), + "the failure has to say the daemon never answered, not that the pane is gone: {err:#}" + ); + assert!( + attach_unanswered(&err), + "silence is its own verdict, told apart from a refusal or a hangup" + ); + // A refusal is not it: the pane really is gone then, and the caller + // may say so. + let (mut client_side, mut daemon_side) = UnixStream::pair().unwrap(); + DaemonMsg::Error("no such pane 7".into()) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + let refused = attach_reply_prefix(&mut client_side, 7, wait).expect_err("refused"); + assert!(!attach_unanswered(&refused)); + } + + /// The other side of the rule above: the frame a quiet pane does send is + /// enough. Nothing else is required of the daemon for the attach to stand. + #[test] + fn a_size_frame_alone_is_a_live_attach() { + let (mut client_side, mut daemon_side) = UnixStream::pair().unwrap(); + DaemonMsg::Size(WinSize { + cols: 80, + rows: 24, + cell_w: 8, + cell_h: 17, + }) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + + let buffered = + attach_reply_prefix(&mut client_side, 7, std::time::Duration::from_millis(200)) + .expect("the daemon's first frame is the attach ack"); + assert!( + !buffered.is_empty(), + "the Size frame was read to classify the reply; it must reach the reader" + ); + } + #[test] fn a_local_attach_does_not_wait_as_long_as_a_remote_one() { let local = attach_reply_wait(&PaneRoute::Local); @@ -3151,6 +3356,62 @@ mod tests { } } + /// Input the link refuses used to vanish: `write` threw the error away, so a + /// pane whose daemon had stopped reading kept taking keystrokes into + /// nothing. The refusal now marks the pane exited by the reader's own signal + /// — only our sending half is shut here, so the reader is still parked on + /// an open receiving half and the writing side is the one that finds out. + /// (Shutting the peer's receiving half instead is not portable: Linux + /// answers the next write with EPIPE, macOS buffers it.) + #[test] + fn a_write_the_link_refuses_marks_the_pane_gone_once() { + crate::core::config::pin_test_config_dir(); + let (client_side, _daemon_side) = UnixStream::pair().unwrap(); + let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); + term.writer + .lock() + .unwrap() + .shutdown(std::net::Shutdown::Write) + .unwrap(); + + term.write(b"echo hi\r".to_vec()); + assert!( + term.exited_flag.load(Ordering::SeqCst), + "a refused Input is the link gone, and the pane has to say so" + ); + let mut exits = 0; + while let Ok(ev) = term.events.try_recv() { + exits += usize::from(matches!(ev, AlacEvent::Exit)); + } + assert_eq!( + exits, 1, + "reported through the same event the reader raises on EOF" + ); + + term.write(b"echo again\r".to_vec()); + while let Ok(ev) = term.events.try_recv() { + exits += usize::from(matches!(ev, AlacEvent::Exit)); + } + assert_eq!(exits, 1, "said once, not once per keystroke"); + } + + /// A link retired for a relink refuses writes too — `stop_reader` shuts it + /// down — and that must not read as the pane dying under the swap, for the + /// same reason the retired reader exits silently. + #[test] + fn a_write_on_a_retired_link_does_not_mark_the_pane_gone() { + crate::core::config::pin_test_config_dir(); + let (client_side, _daemon_side) = UnixStream::pair().unwrap(); + let mut term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); + term.stop_reader(); + + term.write(b"echo hi\r".to_vec()); + assert!( + !term.exited_flag.load(Ordering::SeqCst), + "the pane is being relinked or released, not dying" + ); + } + #[test] fn attach_replay_runs_at_the_daemon_reported_size() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 53fd74b6..3291cef7 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1008,6 +1008,13 @@ impl TerminalView { let attached = match restore_pane { Some(id) => match RemoteTerminal::attach_on(&route, TermSize::new(80, 24), 8, 17, id) { Ok(terminal) => Some((terminal, id, None)), + Err(e) if crate::terminal::attach_unanswered(&e) => { + // Not "gone": nobody answered, which a daemon still coming + // up also does. Whoever finds an orphaned shell later + // reads this line. + log::warn!("attach to pane {id} went unanswered ({e:#}); spawning fresh"); + None + } Err(e) => { log::info!("pane {id} is gone on its machine ({e:#}); spawning fresh"); None diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index abdf5e42..3b083430 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -873,6 +873,19 @@ struct WsState { /// Rewritten by every attempt, and only ever read while `rehydrate` is /// outstanding, so it always describes the debt currently standing. owed_over: Vec, + /// The tabs the last rebuild was handed and could not put up — on the + /// machine and in the mirror, but not on screen, because no pane in them + /// would start (`tabs_from_session` drops such a tab, and says so). + /// + /// Held out of every diff, the way a tab still connecting is: the window + /// cannot speak for them, and its licence to prune must not read their + /// absence as the user closing them. A rebuild that put up some of its tabs + /// earned the licence — it did put a layout up — and used it to `TabClose` + /// exactly the tabs it had failed to rebuild, deleting them off the machine, + /// panes and all (#672). Rewritten by the next rebuild, which either puts + /// them up or fails them again; a tab the machine drops meanwhile drops + /// out here too, so nothing stays held for a tab nobody has. + not_rebuilt: Vec, /// How many pulls in a row this window has owed, which paces the retry. /// /// Counts consecutive failures, so it is cleared by anything that ends the @@ -926,6 +939,7 @@ impl Default for WsState { epoch: 0, rehydrate: None, owed_over: Vec::new(), + not_rebuilt: Vec::new(), rehydrate_attempts: 0, then_open: None, chosen_name: None, @@ -957,7 +971,7 @@ pub(crate) fn sync_window(app: &Tty7App, cx: &mut App) { return; } adopt_tab_ids(app, cx); - let (desired, desired_active, held) = desired_tabs(app, cx); + let (desired, desired_active, mut held) = desired_tabs(app, cx); let machine_ws = tree_workspace_id(cx, client_ws); let state = cx @@ -979,6 +993,13 @@ pub(crate) fn sync_window(app: &Tty7App, cx: &mut App) { } else { SyncScope::Additive }; + // The tabs the last rebuild could not put up are the machine's to + // keep: not on screen, so `desired` cannot speak for them, and held + // so their absence is not read as a close. + state + .not_rebuilt + .retain(|id| mirror.tabs.iter().any(|t| t.id == *id)); + held.extend(state.not_rebuilt.iter().copied()); let ops = diff(machine_ws, mirror, &desired, desired_active, scope, &held); if !ops.is_empty() { let (tabs, active) = (mirror.tabs.clone(), mirror.active); @@ -2073,30 +2094,99 @@ fn settle_hydration( return false; }; let wanted = session.tabs.len(); + // `tree_id` is not serialized, so only a session built from the tree + // carries one on every tab (which is what reaches here today). The guard + // below counts the tabs asked for, not the ids found: a session with tabs + // and no ids must not read as "nothing was wanted". + let wanted_ids: Vec = session.tabs.iter().filter_map(|t| t.tree_id).collect(); + debug_assert_eq!( + wanted_ids.len(), + wanted, + "a session rebuilt from the tree names every tab it holds" + ); log::info!("rebuilding {wanted} tab(s) of workspace {client_ws} from its machine's tree"); let _ = handle.update(cx, move |_, window, cx| { app.update(cx, |app, cx| { app.adopt_workspace(client_ws, session, window, cx) }); }); + let showing = tabs_on_screen(cx, client_ws); + settle_rebuild(cx, client_ws, wanted, &wanted_ids, &showing); + true +} - // Informed *after* the rebuild, and only if the rebuild produced something. - // - // The licence means "this window knows what belongs in this workspace", and - // `switch_workspace` / `detach_workspace` read it as permission to delete a - // workspace that has no tabs — from the machine tree and from the store - // both. Granting it before the rebuild handed that permission to a window - // whose rebuild had not happened yet, and a rebuild can produce nothing: - // `tabs_from_session` drops any tab whose panes all fail to start, which is - // what every tab does when the pane socket is unreachable. The window then - // sat there, empty and authoritative, and the next switch deleted a - // workspace with ten live tabs in it. - // - // Emptiness that came from a failure has to stay indistinguishable from not - // knowing, because that is what it is. - let rebuilt = crate::ui::windows::WindowRegistry::app_for(cx, client_ws) - .and_then(|app| app.upgrade()) - .is_some_and(|app| !app.read(cx).tabs.is_empty()); +/// What a rebuild leaves the window entitled to say, from how many tabs the +/// tree asked it to put up (`wanted`), which ids those were (`wanted_ids`), +/// and the tabs it is showing now (`showing`). +/// +/// Informed *after* the rebuild, and only if the rebuild produced something. +/// +/// The licence means "this window knows what belongs in this workspace", and +/// `switch_workspace` / `detach_workspace` read it as permission to delete a +/// workspace that has no tabs — from the machine tree and from the store +/// both. Granting it before the rebuild handed that permission to a window +/// whose rebuild had not happened yet, and a rebuild can produce nothing: +/// `tabs_from_session` drops any tab whose panes all fail to start, which is +/// what every tab does when the pane socket is unreachable. The window then +/// sat there, empty and authoritative, and the next switch deleted a +/// workspace with ten live tabs in it. +/// +/// Emptiness that came from a failure has to stay indistinguishable from not +/// knowing, because that is what it is. +/// +/// A rebuild that produced *some* of its tabs is the same failure, one tab at +/// a time, and the licence it does earn — it put a layout up, and closes and +/// splits in it must reach the machine — cannot be allowed to speak for the +/// tabs it did not: at `SyncScope::Full` every mirror tab missing from the +/// window is a `TabClose`, and the tabs missing were exactly the ones that +/// failed to rebuild, so the sync deleted them off the machine, panes and all +/// (#672). They are held instead (`not_rebuilt`), out of the diff's reach until +/// a later rebuild puts them up or the machine lets them go. +/// +/// Holding has a cost the caller should know: `diff` stops before its +/// reorder pass and the active-tab op whenever anything is held, so while +/// this set stands the window's tab order and active tab do not reach the +/// machine — a restart restores the order and focus from before the drag. +/// And nothing retries a held tab: the set is rewritten only by the next +/// `settle_rebuild`, which runs only from a hydration that rebuilds, and a +/// re-prime or an `Adopt::IfEmpty` hydrate on a populated window never gets +/// there. A tab that fails to rebuild stays held, and holds the ordering +/// with it, until the next restart. That state was already reachable — a +/// pane whose remote spawn failed stays connecting for the same span, held +/// the same way — so this widens a standing hole rather than opening one; a +/// retry, or a way to close a held tab from the window, is separate work. +/// +/// The none-rebuilt guard reads the *count* asked for, not the ids found: +/// `tree_id` is not serialized, so a session that reached here from disk +/// would name no ids at all, and "no ids" must not read as "no tabs wanted" +/// — that would grant the licence to a window that rebuilt nothing, which is +/// #672 again. +fn settle_rebuild( + cx: &mut App, + client_ws: WorkspaceId, + wanted: usize, + wanted_ids: &[TabId], + showing: &[TabId], +) { + let not_rebuilt: Vec = wanted_ids + .iter() + .copied() + .filter(|id| !showing.contains(id)) + .collect(); + if !not_rebuilt.is_empty() && !showing.is_empty() { + log::warn!( + "workspace {client_ws}: {} of its {wanted} tab(s) could not be rebuilt; they stay on \ + the machine, held out of this window's sync (tab order and active tab are not \ + synced while a tab is held)", + not_rebuilt.len() + ); + } + let rebuilt = !showing.is_empty(); + cx.default_global::() + .windows + .entry(client_ws) + .or_default() + .not_rebuilt = not_rebuilt; if rebuilt || wanted == 0 { mark_window_informed(cx, client_ws); } else { @@ -2105,7 +2195,6 @@ fn settle_hydration( window uninformed so the layout is not mistaken for an empty workspace" ); } - true } /// Someone else removed this workspace from its machine — `tty7 ws rm`, or @@ -3528,6 +3617,153 @@ mod tests { }); } + /// #672's residual: a rebuild that put up some of the tabs the tree asked + /// for and dropped the rest (`tabs_from_session` drops a tab none of whose + /// panes would start). It earns the licence — it did put a layout up — and + /// the tabs it dropped are not tabs the user closed, so they must be held + /// out of the diff rather than the licence withheld from the whole window. + #[gpui::test] + fn a_partial_rebuild_holds_the_tabs_it_could_not_put_up(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let ws = WorkspaceId::new(); + let (put_up, failed) = (TabId::new(), TabId::new()); + let settled = |cx: &mut App| { + let state = &cx.default_global::().windows[&ws]; + (state.informed, state.not_rebuilt.clone()) + }; + + settle_rebuild(cx, ws, 2, &[put_up, failed], &[put_up]); + assert_eq!( + settled(cx), + (true, vec![failed]), + "the window speaks for the tab it put up, and holds the one it could not" + ); + + settle_rebuild(cx, ws, 2, &[put_up, failed], &[put_up, failed]); + assert_eq!( + settled(cx), + (true, vec![]), + "a later rebuild that puts them all up holds nothing back" + ); + + cx.default_global::() + .windows + .get_mut(&ws) + .unwrap() + .informed = false; + settle_rebuild(cx, ws, 2, &[put_up, failed], &[]); + assert_eq!( + settled(cx), + (false, vec![put_up, failed]), + "a rebuild that produced nothing still does not get to speak for the workspace" + ); + + settle_rebuild(cx, ws, 0, &[], &[]); + assert_eq!( + settled(cx), + (true, vec![]), + "an empty tree rebuilt into an empty window is the one case that is genuinely empty" + ); + + // A session with tabs but no ids — what a disk-loaded session + // looks like, since `tree_id` is not serialized — that rebuilt + // nothing. "No ids" must not read as "no tabs wanted". + cx.default_global::() + .windows + .get_mut(&ws) + .unwrap() + .informed = false; + settle_rebuild(cx, ws, 2, &[], &[]); + assert_eq!( + settled(cx), + (false, vec![]), + "the guard counts the tabs asked for, not the ids found" + ); + }); + } + + /// The consequence, on the sync that follows: the mirror holds both tabs, + /// the window shows one, and the one it could not put up must not come out + /// of a `Full` diff as `TabClose` — that op deleted from the machine exactly + /// the tabs a restart had failed to bring back, panes and all (#672). + #[cfg(unix)] + #[gpui::test] + fn the_next_sync_leaves_a_tab_the_rebuild_could_not_put_up_on_the_machine( + cx: &mut gpui::TestAppContext, + ) { + let (app, mut vcx, _pane_stream) = crate::ui::app::test_window::harness_with_pane(cx); + let (put_up, failed) = (TabId::new(), TabId::new()); + app.update_in(&mut vcx, |app, _, cx| { + let view = crate::core::session::WindowView::default(); + let ws = view.id; + WorkspaceStore::install_for_test( + cx, + crate::core::session::WindowViews { + views: vec![view], + active: Some(ws), + }, + ); + app.workspace = ws; + app.tabs[0].tree_id.set(put_up); + { + let state = cx + .default_global::() + .windows + .entry(ws) + .or_default(); + state.sync = SyncPhase::Primed(WsMirror { + tabs: vec![ + TreeTab { + id: put_up, + name: None, + sidebar_group: None, + root: PaneNode::Leaf { pane: 1 }, + }, + TreeTab { + id: failed, + name: None, + sidebar_group: None, + root: PaneNode::Leaf { pane: 2 }, + }, + ], + active: Some(put_up), + }); + // Keeps whatever the sync queues where the test can read it: + // with no link, `pump` would otherwise clear the queue and drop + // the mirror on its way to a re-pull. + state.inflight = true; + } + settle_rebuild(cx, ws, 2, &[put_up, failed], &[put_up]); + assert!( + cx.default_global::().windows[&ws].informed, + "the shape the damage needs: a window licensed to prune, over a mirror \ + holding a tab it is not showing" + ); + + sync_window(app, cx); + + let state = &cx.default_global::().windows[&ws]; + assert!( + !state + .queue + .iter() + .any(|op| matches!(op, ControlRequest::TabClose { .. })), + "the tab that failed to come back is not one the user closed: {:?}", + state.queue + ); + match &state.sync { + SyncPhase::Primed(mirror) => assert_eq!( + mirror.tabs.iter().map(|t| t.id).collect::>(), + vec![put_up, failed], + "and it stays on the machine for the next rebuild to put up" + ), + SyncPhase::Unprimed { .. } => { + panic!("the sync must not have thrown the mirror away") + } + } + }); + } + #[test] fn a_ratio_delta_is_clamped_to_the_servers_band_not_a_narrower_one() { let mut pane = Pane::split_node(gpui::Axis::Horizontal, 0.5, Pane::Empty, Pane::Empty); From 7bcb91d8af3013cad6c00952d477aa70b2ddf9ca Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:38:28 +0800 Subject: [PATCH 18/33] fix(input): give the PTY back the Ctrl chords tty7 was eating (#684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(input): give the PTY back the Ctrl chords tty7 was eating Follow-up to #682, which handed Ctrl+V to a full-screen program but left three neighbouring holes of the same shape: a key the terminal answers without the keymap ever seeing it. The C0 table was half a table. `input.rs` mapped the alphabet, `[ \ ]` and Ctrl+2, and nothing else — so `Ctrl-^` (Ctrl+6, vim's alternate file), `Ctrl-_` (readline's undo, typed as Ctrl+/ or Ctrl+Shift+-) and Ctrl+3..8 produced no bytes at all. They were not mis-encoded, they were silent: gpui filters control characters out of `key_char` on all three backends, so the text fallback had nothing to offer either. The table is now the VT-220 one, each digit beside the punctuation that shares its key, because every platform hands Ctrl+Shift+6 over as `^` with the Shift already spent. Ctrl+/ is xterm's addition rather than VT-220's and is spelled out with the reason. The twenty-six letters fold to `& 0x1f`. `on_key_down` swallowed plain Ctrl+1..9 off macOS with a bare `return`, left over from when tabs lived on ctrl-digits — they have been on Alt+1..9 for a long time, so nothing claimed those chords and the block only deleted keys. It also sat before `keystroke_to_bytes`, so not even the kitty protocol got through it. Gone. Ctrl+V is now a binding. `AlternatePaste` carries `ctrl-v` off macOS in a `Terminal && !alt_screen` context, and the pane declares `alt_screen` whenever a full-screen program owns the grid, so the behaviour #682 settled on is unchanged — paste at a prompt, SYN inside vim — while the keymap can finally express it, the Keybindings page lists it, and the user gets a say: `"AlternatePaste": ""` hands Ctrl+V to the shell everywhere, including readline's `quoted-insert`, and `"PasteText": "ctrl-v"` pastes on every screen the way Windows Terminal does. That cohort is real — Warp keeps Ctrl+V pasting on Windows on purpose, as a removable binding, for exactly this reason. The hardcoded arm in `handle_cmd_shortcut` now answers Cmd+V alone, which is macOS's only paste chord and carries no control code to lose. Last, the rule about control codes is one function instead of an assertion buried in a test. `steals_a_control_code` plus a commented `control_code_binding_allowed` back both the defaults test and a new runtime warning, so a hand-edited config.json that takes EOF away from every shell says so in the log. It warns rather than refuses: a chord the user asked for by name is theirs to spend, the way the tmux preset spends Ctrl+B. The invariant that still fails a build is that no *default* spends one silently. Tests: `cargo test --bin tty7-app` 1360 passed, 1 known flake (`a_routed_auth_prompt_carries_the_machine_that_raised_it`, green on a rerun and on a clean tree). New: the whole VT-220 table asserted byte by byte, with Ctrl+- held out; `ctrl_6_reaches_the_pty_as_rs`, `ctrl_v_pastes_at_a_prompt` and `ctrl_v_reaches_a_full_screen_program_as_syn` drive the real keymap through `simulate_keystrokes` rather than calling into the view; the keymap tests cover both escape hatches and the context that withholds the binding. #682's two `handle_cmd_shortcut` tests are replaced by those three, which assert the same behaviour at the layer that now decides it; its end-to-end SYN test stands unchanged. The gpui tests are unix-only, so CI is what runs them. * fix(input): ask the grid, not the last frame, before Ctrl+V pastes `AlternatePaste` carries `Terminal && !alt_screen`, but gpui matches a keystroke against the frame it last painted, so the context outlives the switch: a full-screen program that took the screen after that paint is still "at a prompt" as far as the keymap is concerned, and the clipboard lands in it. In vim's normal mode that runs as commands. The action now re-reads the terminal mode and propagates instead, which hands the chord to `on_key_down` and encodes it as the SYN the program is waiting for. Also: - the two escape-hatch assertions in `paste_ships_both_terminal_chords_off_macos_and_retires_together` built a one-entry binding table instead of the default one, so both passed without the hatch working — an emptied `AlternatePaste` cannot dispatch anything when it is the only entry in the table. They now apply the config line on top of the whole default table, and the `PasteText: ctrl-v` case checks both screens; - the keyboard-shortcuts page claimed every other Ctrl chord reaches the program, which Ctrl+Tab and the Windows/Linux font-size chords do not; - `steals_a_control_code` documents `@` and the backtick, which are in the set it walks but were not in the list beside it. --- docs/reference/keyboard-shortcuts.mdx | 23 ++++ src/terminal/input.rs | 104 +++++++++----- src/terminal/view.rs | 186 +++++++++++++++++++------ src/ui/i18n/en.rs | 1 + src/ui/i18n/ja.rs | 1 + src/ui/i18n/mod.rs | 1 + src/ui/i18n/zh.rs | 1 + src/ui/keymap.rs | 188 ++++++++++++++++++++++---- 8 files changed, 404 insertions(+), 101 deletions(-) diff --git a/docs/reference/keyboard-shortcuts.mdx b/docs/reference/keyboard-shortcuts.mdx index be91bb37..e82a1077 100644 --- a/docs/reference/keyboard-shortcuts.mdx +++ b/docs/reference/keyboard-shortcuts.mdx @@ -58,6 +58,29 @@ Inside a full-screen application — vim, less, tmux — it is passed through as the key, so it means what the application says it means (blockwise Visual mode in vim); paste there with Ctrl ⇧ V or ⇧ Insert. +That convenience is the **Paste (outside full-screen apps)** binding, and it is +yours to move. Clearing it hands Ctrl V to the shell everywhere, +including readline's `quoted-insert`: + +```json +{ "keybindings": { "AlternatePaste": "" } } +``` + +Putting Paste itself on the chord goes the other way, pasting on every screen +the way Windows Terminal does out of the box: + +```json +{ "keybindings": { "PasteText": "ctrl-v" } } +``` + +Every Ctrl chord that stands for a control code reaches the program +you are running: Ctrl 6 and Ctrl ⇧ 6 are `^^` (vim's +alternate file), Ctrl / and Ctrl ⇧ − are `^_` (readline's +undo), Ctrl 3 is Escape. The plain Ctrl chords tty7 keeps +are the ones that stand for nothing: Ctrl ⇥ for the next tab, and on +Windows and Linux Ctrl + · Ctrl − · Ctrl 0 for +the font size. + ## Git and SSH | Action | macOS | Windows / Linux | diff --git a/src/terminal/input.rs b/src/terminal/input.rs index b5473dd1..0fe392ac 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -239,45 +239,45 @@ fn associated_text(ks: &gpui::Keystroke) -> Option> { (!cps.is_empty()).then_some(cps) } +/// The C0 control byte a `Ctrl+` chord stands for, or `None` when the +/// chord is not a control code at all. +/// +/// The letters fold onto `0x01..=0x1A` — `Ctrl+A` is 1, `Ctrl+Z` is 26 — which +/// is the whole alphabet in one line instead of twenty-six. The rest is the +/// VT-220 table (chapter 3.2.5): the digits 2..8, and beside each the +/// punctuation that shares its key, because `Ctrl+^` is typed as Ctrl+Shift+6 +/// and every platform hands that over as `^` with the Shift already spent. +/// +/// `Ctrl+/` is not in that table. xterm and every terminal since encode it as +/// US and editors bind against it — vim's `` — but unlike `Ctrl+[` and +/// its neighbours no keyboard layer folds it into a control byte for us, so it +/// has to be spelled out here. `Ctrl+-` is deliberately absent: off macOS that +/// is Decrease Font Size, and the chord this table owes readline's undo is +/// `Ctrl+_`, which arrives as `_`. +fn ctrl_c0(key: &str) -> Option { + if let [b] = key.as_bytes() + && b.is_ascii_alphabetic() + { + return Some(b.to_ascii_uppercase() & 0x1f); + } + Some(match key { + "space" | "2" | "@" => 0x00, + "3" | "[" => 0x1b, + "4" | "\\" => 0x1c, + "5" | "]" => 0x1d, + "6" | "^" => 0x1e, + "7" | "_" | "/" => 0x1f, + "8" | "?" => 0x7f, + _ => return None, + }) +} + fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke, flags: KeyFlags) -> Option> { let m = &ks.modifiers; let key = ks.key.as_str(); if m.control && !m.platform { - let b = match key { - "space" | "2" => Some(0x00), - "a" => Some(0x01), - "b" => Some(0x02), - "c" => Some(0x03), - "d" => Some(0x04), - "e" => Some(0x05), - "f" => Some(0x06), - "g" => Some(0x07), - "h" => Some(0x08), - "i" => Some(0x09), - "j" => Some(0x0a), - "k" => Some(0x0b), - "l" => Some(0x0c), - "m" => Some(0x0d), - "n" => Some(0x0e), - "o" => Some(0x0f), - "p" => Some(0x10), - "q" => Some(0x11), - "r" => Some(0x12), - "s" => Some(0x13), - "t" => Some(0x14), - "u" => Some(0x15), - "v" => Some(0x16), - "w" => Some(0x17), - "x" => Some(0x18), - "y" => Some(0x19), - "z" => Some(0x1a), - "[" => Some(0x1b), - "\\" => Some(0x1c), - "]" => Some(0x1d), - _ => None, - }; - if let Some(b) = b { + if let Some(b) = ctrl_c0(key) { if m.alt { return Some(vec![0x1b, b]); } @@ -610,6 +610,42 @@ mod tests { assert_eq!(legacy(&ks(ctrl, "z", None)), Some(vec![0x1a])); } + #[test] + fn keystroke_to_bytes_maps_the_whole_vt220_control_table() { + let ctrl = Modifiers { + control: true, + ..Default::default() + }; + // The VT-220 table, each digit next to the punctuation that shares its + // key: whichever of the two the platform reports, the byte is the same. + let cases: &[(&str, u8)] = &[ + ("2", 0x00), + ("@", 0x00), + ("3", 0x1b), + ("4", 0x1c), + ("5", 0x1d), + ("6", 0x1e), + // vim's `Ctrl-^`, the whole reason the digits are here. + ("^", 0x1e), + ("7", 0x1f), + ("_", 0x1f), + // readline's undo; xterm's addition to the table, not VT-220's. + ("/", 0x1f), + ("8", 0x7f), + ("?", 0x7f), + ]; + for (key, byte) in cases { + assert_eq!( + legacy(&ks(ctrl, key, None)), + Some(vec![*byte]), + "ctrl-{key}" + ); + } + // Decrease Font Size owns Ctrl+- off macOS, and readline is served by + // Ctrl+_ above, so the bare minus stays out of the table. + assert_eq!(legacy(&ks(ctrl, "-", None)), None); + } + #[test] fn keystroke_to_bytes_ctrl_plus_cmd_is_not_a_c0_byte() { let ctrl_cmd = Modifiers { diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 3291cef7..e0f7e6a4 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -88,6 +88,7 @@ actions!( CopyText, CutText, PasteText, + AlternatePaste, SelectAll, UndoEdit, RedoEdit, @@ -1851,14 +1852,20 @@ impl TerminalView { } } - // Ctrl+Shift+C/V/X are the keymap's alone: rebinding Paste has to - // retire Ctrl+Shift+V, which it cannot if this path answers it too. + // Ctrl+V is not here: off macOS it is the `AlternatePaste` binding, + // which the keymap withholds on the alternate screen so a full-screen + // program gets its SYN, and which the user can retire outright. Copy + // and cut stay, because both answer a selection this view owns and + // fall through to the PTY when there is none. + // + // Ctrl+Shift+C/X are the keymap's alone: rebinding Copy has to retire + // Ctrl+Shift+C, which it cannot if this path answers it too. if cfg!(not(target_os = "macos")) && m.control && !m.platform && !m.alt && !m.shift - && matches!(ks.key.as_str(), "c" | "v" | "x") + && matches!(ks.key.as_str(), "c" | "x") { match self.handle_cmd_shortcut(ks, window, cx) { CmdKey::Consumed => { @@ -1869,18 +1876,6 @@ impl TerminalView { } } - if cfg!(not(target_os = "macos")) - && m.control - && !m.platform - && !m.alt - && matches!( - ks.key.as_str(), - "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" - ) - { - return; - } - if !self.accepts_input(cx) { return; } @@ -1992,17 +1987,12 @@ impl TerminalView { CmdKey::FallThrough } } + // Cmd+V only: macOS leaves `PasteText` unbound and pastes from + // here, and Cmd carries no control code to lose. The Ctrl+V half + // of this key lives in the keymap as `AlternatePaste`. "v" => { - // Off macOS Ctrl+V is a control code first: on the alternate - // screen the key is the program's (vim's blockwise select), the - // way Ctrl+C is SIGINT when there is nothing to copy. Cmd+V - // pastes anywhere. - if m.control && !m.platform && self.on_alt_screen() { - CmdKey::FallThrough - } else { - self.paste_from_clipboard(cx); - CmdKey::Consumed - } + self.paste_from_clipboard(cx); + CmdKey::Consumed } "a" => { self.select_all_contextual(cx); @@ -2473,6 +2463,41 @@ impl TerminalView { self.has_selection() || (self.input_active() && self.cmd.selected_text().is_some()) } + /// The keymap context this pane declares each frame. + /// + /// `alt_screen` is how a binding steps aside for a full-screen program: + /// `AlternatePaste` carries `Terminal && !alt_screen`, so Ctrl+V pastes at + /// a prompt, reaches vim as SYN, and can still be handed the whole screen + /// by rebinding `PasteText` onto it (#677). + pub(super) fn key_context(&self) -> gpui::KeyContext { + let mut context = gpui::KeyContext::new_with_defaults(); + context.add("Terminal"); + if self.on_alt_screen() { + context.add("alt_screen"); + } + context + } + + /// `AlternatePaste`, with the grid asked again before it pastes. + /// + /// The `!alt_screen` half of the binding's context comes from the frame + /// that was last *painted*, and gpui matches keystrokes against that frame + /// — so a program that took the alternate screen after the last paint is + /// still "at a prompt" as far as the keymap is concerned. One frame is + /// enough: the keystroke that launches a full-screen program and the + /// Ctrl+V after it can land either side of a paint. Pasting there is not a + /// mistake the user can take back — vim in normal mode runs the clipboard + /// as commands — so the last word belongs to the terminal mode, not to the + /// frame. Propagating hands the chord on to `on_key_down`, which encodes it + /// as the SYN the program is waiting for. + fn alternate_paste(&mut self, cx: &mut Context) { + if self.on_alt_screen() { + cx.propagate(); + return; + } + self.paste_from_clipboard(cx); + } + pub(super) fn key_flags(&self) -> super::input::KeyFlags { super::input::KeyFlags::from_mode(self.terminal.term.lock().mode()) } @@ -6064,7 +6089,7 @@ impl Render for TerminalView { div() .id("terminal-surface") .track_focus(&self.focus_handle) - .key_context("Terminal") + .key_context(self.key_context()) .size_full() .relative() .overflow_hidden() @@ -6108,6 +6133,7 @@ impl Render for TerminalView { this.cut_contextual(cx); })) .on_action(cx.listener(|this, _: &PasteText, _w, cx| this.paste_from_clipboard(cx))) + .on_action(cx.listener(|this, _: &AlternatePaste, _w, cx| this.alternate_paste(cx))) .on_action(cx.listener(|this, _: &SelectAll, _w, cx| this.select_all_contextual(cx))) .on_action(cx.listener(|this, _: &UndoEdit, _w, cx| this.undo_edit(false, cx))) .on_action(cx.listener(|this, _: &RedoEdit, _w, cx| this.undo_edit(true, cx))) @@ -12136,46 +12162,122 @@ mod gpui_tests { } #[gpui::test] - fn ctrl_v_reaches_a_tui_on_the_alternate_screen(cx: &mut TestAppContext) { + fn cmd_v_pastes_on_the_alternate_screen(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("echo hi".into()))); alt_screen_ready(&window, cx, &mut daemon); window .update(cx, |view, window, cx| { - let fell_through = view.handle_cmd_shortcut(&key("ctrl-v"), window, cx); - assert!( - matches!(fell_through, CmdKey::FallThrough), - "a full-screen program owns Ctrl+V" - ); let pasted = view.handle_cmd_shortcut(&key("cmd-v"), window, cx); assert!( matches!(pasted, CmdKey::Consumed), - "Cmd+V is a paste chord on every screen" + "Cmd+V carries no control code and pastes on every screen" ); }) .unwrap(); - assert_eq!( - next_input(&mut daemon), - b"echo hi".to_vec(), - "only the Cmd+V paste may reach the PTY" - ); + assert_eq!(next_input(&mut daemon), b"echo hi".to_vec()); assert_eq!(next_input_until_timeout(&mut daemon), None); } + /// The Ctrl+V half of the same key, through the real keymap: whether it + /// pastes is the `AlternatePaste` binding's decision, not this view's, so + /// these two drive it the way a user does rather than calling in. + #[cfg(not(target_os = "macos"))] #[gpui::test] - fn ctrl_v_pastes_off_the_alternate_screen(cx: &mut TestAppContext) { + fn ctrl_v_pastes_at_a_prompt(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + cx.update(|cx| crate::ui::keymap::init(cx)); + cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("echo hi".into()))); + window + .update(cx, |view, window, cx| { + assert!(!view.on_alt_screen()); + window.activate_window(); + view.focus_handle.focus(window, cx); + }) + .unwrap(); + + let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx); + vcx.simulate_keystrokes("ctrl-v"); + + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"echo hi".to_vec()) + ); + } + + #[cfg(not(target_os = "macos"))] + #[gpui::test] + fn ctrl_v_reaches_a_full_screen_program_as_syn(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + cx.update(|cx| crate::ui::keymap::init(cx)); + cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("echo hi".into()))); + alt_screen_ready(&window, cx, &mut daemon); + window + .update(cx, |view, window, cx| { + window.activate_window(); + view.focus_handle.focus(window, cx); + }) + .unwrap(); + + let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx); + vcx.simulate_keystrokes("ctrl-v"); + + assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x16])); + } + + /// The other half of that rule, which the binding's context cannot state. + /// + /// gpui matches a keystroke against the frame it last *painted*, so + /// `!alt_screen` outlives the switch by a frame: launch a full-screen + /// program and hit Ctrl+V before the next paint and the keymap still + /// believes the pane is at a prompt. The action asks the grid itself, so + /// the clipboard never lands in a program that would run it as commands. + #[gpui::test] + fn alternate_paste_asks_the_grid_and_not_the_last_frame(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("echo hi".into()))); window - .update(cx, |view, window, cx| { + .update(cx, |view, _window, cx| { assert!(!view.on_alt_screen()); - let consumed = view.handle_cmd_shortcut(&key("ctrl-v"), window, cx); - assert!(matches!(consumed, CmdKey::Consumed)); + view.alternate_paste(cx); }) .unwrap(); assert_eq!(next_input(&mut daemon), b"echo hi".to_vec()); + + alt_screen_ready(&window, cx, &mut daemon); + window + .update(cx, |view, _window, cx| view.alternate_paste(cx)) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + None, + "the paste is withheld even when the frame that matched said otherwise" + ); + } + + /// `Ctrl-^`, which used to die in a hardcoded block that swallowed every + /// Ctrl+digit off macOS — nothing has claimed those chords since tabs + /// moved to Alt+1..9. + #[gpui::test] + fn ctrl_6_reaches_the_pty_as_rs(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + cx.update(|cx| crate::ui::keymap::init(cx)); + window + .update(cx, |view, window, cx| { + window.activate_window(); + view.focus_handle.focus(window, cx); + }) + .unwrap(); + + let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx); + vcx.simulate_keystrokes("ctrl-6"); + + assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x1e])); } #[cfg(target_os = "macos")] diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 1db56b4f..ce843c1b 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1434,6 +1434,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdCopy => "Copy", L10nKey::CmdCut => "Cut", L10nKey::CmdPaste => "Paste", + L10nKey::CmdAlternatePaste => "Paste (outside full-screen apps)", L10nKey::CmdSelectAll => "Select All", L10nKey::CmdSshAddConnection => "SSH: Add Connection…", L10nKey::CmdSshManageProfiles => "SSH: Manage Profiles…", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 4b7edcd9..633e98bd 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1489,6 +1489,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdCopy => "コピー", L10nKey::CmdCut => "切り取り", L10nKey::CmdPaste => "貼り付け", + L10nKey::CmdAlternatePaste => "貼り付け(全画面アプリを除く)", L10nKey::CmdSelectAll => "すべて選択", L10nKey::CmdSshAddConnection => "SSH: 接続を追加…", L10nKey::CmdSshManageProfiles => "SSH: プロファイルを管理…", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 61872198..5f7e060b 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1182,6 +1182,7 @@ l10n_keys! { CmdCopy, CmdCut, CmdPaste, + CmdAlternatePaste, CmdSelectAll, CmdSshAddConnection, CmdSshManageProfiles, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index da4ed249..731aefe0 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1352,6 +1352,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdCopy => "复制", L10nKey::CmdCut => "剪切", L10nKey::CmdPaste => "粘贴", + L10nKey::CmdAlternatePaste => "粘贴(全屏程序中除外)", L10nKey::CmdSelectAll => "全选", L10nKey::CmdSshAddConnection => "SSH:添加连接…", L10nKey::CmdSshManageProfiles => "SSH:管理主机配置…", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index ccc07265..d5da0c10 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -3,8 +3,8 @@ use gpui::{App, Global, KeyBinding, Keystroke, NoAction}; use crate::core::actions::*; use crate::core::config::Config; use crate::terminal::view::{ - ClearScrollback, CopyText, FindInTerminal, FindNext, FindPrevious, InsertNewline, - InsertNewlineFallback, PasteText, + AlternatePaste, ClearScrollback, CopyText, FindInTerminal, FindNext, FindPrevious, + InsertNewline, InsertNewlineFallback, PasteText, }; use crate::ui::i18n::{L10nKey, t, t_fmt}; use crate::ui::palette::CommandGroup; @@ -119,6 +119,18 @@ fn paste_text_default() -> &'static str { per_platform("", "ctrl-shift-v") } +/// Windows and Linux paste with Ctrl+V everywhere else in the desktop, so the +/// terminal answers it too — but only off the alternate screen, where the key +/// is a control code a full-screen program is waiting for (#677). It is a +/// binding of its own rather than a second keystroke on `PasteText` so that it +/// can carry that narrower context, and so that a user who wants the Windows +/// Terminal behaviour back can say so: `"AlternatePaste": ""` hands Ctrl+V to +/// the shell at the prompt as well, and `"PasteText": "ctrl-v"` pastes with it +/// on every screen. +fn alternate_paste_default() -> &'static str { + per_platform("", "ctrl-v") +} + fn extra_defaults() -> Vec<(&'static str, &'static str, &'static str)> { vec![ ( @@ -166,6 +178,20 @@ fn action_bindings(effective: &[(String, String)]) -> Vec { log::warn!("ignoring keybinding for '{action}': invalid keystroke '{key}'"); continue; } + // Said once, and the binding still installs: a chord the user asked + // for by name is the user's to spend, the way the tmux preset spends + // Ctrl+B. The invariant this guards is that no *default* spends one + // without saying so — `no_default_binding_sits_on_a_terminal_control_code` + // is the half of it that fails a build. A single chord only, since a + // prefix like `ctrl-b n` is that choice made deliberately. + if !key.contains(' ') + && steals_a_control_code(key) + && !control_code_binding_allowed(action, key) + { + log::warn!( + "keybinding '{key}' for '{action}' takes a control code away from the shell" + ); + } match make_binding(action, key) { Some(b) => { bindings.push(b); @@ -183,6 +209,50 @@ fn action_bindings(effective: &[(String, String)]) -> Vec { bindings } +/// Whether a chord is one the terminal owes the PTY as a control code. +/// +/// Ctrl and nothing else, over the keys that carry a C0 byte: the alphabet, +/// `@ [ \ ] ^ _ / ?`, the digits 2..8 and Space — `ctrl_c0` in +/// `terminal::input` is the table this mirrors. A binding sitting on one of +/// these does not merely shadow the shell, it deletes a byte the program on +/// the far end is waiting for — Ctrl+D is EOF, Ctrl+W deletes a word, Ctrl+^ +/// is vim's alternate file. +/// +/// The backtick is in the set without being in that table — it is held over +/// from when this rule lived inside the defaults test. It errs the safe way: +/// a chord that encodes nothing gets a warning it did not strictly earn, +/// which is cheaper than a default quietly eating one that does. +fn steals_a_control_code(chord: &str) -> bool { + let Ok(ks) = Keystroke::parse(chord) else { + return false; + }; + let m = &ks.modifiers; + if !m.control || m.alt || m.shift || m.platform || m.function { + return false; + } + ks.key == "space" + || ks.key.len() == 1 + && ks.key.chars().next().is_some_and(|c| { + c.is_ascii_alphabetic() || "[]\\`^_/?@".contains(c) || ('2'..='8').contains(&c) + }) +} + +/// The bindings allowed to sit on a control code anyway. +/// +/// `EditorSave` stays on Ctrl+S because its handler in `app.rs` calls +/// `cx.propagate()` whenever the editor does not have focus, so the keystroke +/// reaches the terminal as XOFF instead of dying at the window. Ctrl+V is the +/// paste chord every Windows and Linux desktop trains its users on; tty7 +/// answers it the way Windows Terminal does, and keeps it off the alternate +/// screen (see `alternate_paste_default`), so it is allowed under any action — +/// including a `PasteText` a user deliberately moves onto it (#677). +/// +/// Anything else added here needs a fall-through of its own; a binding that +/// simply swallows the byte does not belong on this list. +fn control_code_binding_allowed(action: &str, chord: &str) -> bool { + action == "EditorSave" || (cfg!(not(target_os = "macos")) && chord == "ctrl-v") +} + fn per_platform(mac: &'static str, other: &'static str) -> &'static str { if cfg!(target_os = "macos") { mac @@ -323,6 +393,7 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ("InsertNewline", INSERT_NEWLINE_DEFAULT), ("CopyText", per_platform("", "ctrl-shift-c")), ("PasteText", paste_text_default()), + ("AlternatePaste", alternate_paste_default()), ("OpenSettings", "secondary-,"), ( "ShowKeyboardShortcuts", @@ -595,6 +666,10 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { ), "CopyText" => (CommandGroup::Terminal, t(L10nKey::CmdCopy).to_string()), "PasteText" => (CommandGroup::Terminal, t(L10nKey::CmdPaste).to_string()), + "AlternatePaste" => ( + CommandGroup::Terminal, + t(L10nKey::CmdAlternatePaste).to_string(), + ), "InsertNewline" => ( CommandGroup::Terminal, t(L10nKey::KeybindInsertNewline).to_string(), @@ -921,6 +996,10 @@ fn action_context(action: &str) -> Option<&'static str> { match action { "FindInTerminal" | "FindNext" | "FindPrevious" | "ClearScrollback" | "InsertNewline" | "CopyText" | "PasteText" => Some("Terminal"), + // `alt_screen` is declared by the pane whenever a full-screen program + // owns the grid, so this binding is simply absent there and Ctrl+V + // carries on to the PTY as SYN (#677). + "AlternatePaste" => Some("Terminal && !alt_screen"), "ScmCommit" | "ScmCommitAmend" => Some("ScmCommit"), _ => None, } @@ -1015,6 +1094,7 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "InsertNewline" => KeyBinding::new(keystroke, InsertNewline, action_context(action)), "CopyText" => KeyBinding::new(keystroke, CopyText, action_context(action)), "PasteText" => KeyBinding::new(keystroke, PasteText, action_context(action)), + "AlternatePaste" => KeyBinding::new(keystroke, AlternatePaste, action_context(action)), "OpenSettings" => KeyBinding::new(keystroke, OpenSettings, None), "ShowKeyboardShortcuts" => KeyBinding::new(keystroke, ShowKeyboardShortcuts, None), "About" => KeyBinding::new(keystroke, About, None), @@ -1328,12 +1408,41 @@ mod tests { "{key} is a terminal chord and must not paste outside one" ); } - // Plain Ctrl+V is the terminal's, not the keymap's: the pane pastes on - // it at a prompt and hands it to a full-screen program otherwise. + // Plain Ctrl+V is `AlternatePaste`, and only where no full-screen + // program is running: on the alternate screen the chord belongs to + // that program and reaches it as SYN. assert!( - dispatched(&effective, "ctrl-v", "Terminal").is_empty(), - "ctrl-v is a control code and no default may claim it" + dispatched(&effective, "ctrl-v", "Terminal").contains(&AlternatePaste::name_for_type()), + "ctrl-v pastes at a prompt off macOS" ); + assert!( + dispatched(&effective, "ctrl-v", "Terminal alt_screen").is_empty(), + "a full-screen program owns ctrl-v" + ); + assert!( + dispatched(&effective, "ctrl-shift-v", "Terminal alt_screen") + .contains(&PasteText::name_for_type()), + "Ctrl+Shift+V is the paste that works on every screen" + ); + // The two ways out, both of which the control-code validator has to + // let through: retire the chord, or hand it the whole screen. Both are + // one line in a `config.json`, so both are asserted against the whole + // default table with that line applied — a bare one-entry table would + // pass either assertion without the escape hatch working at all. + let mut retired = effective.clone(); + set_binding(&mut retired, "AlternatePaste", String::new()); + assert!( + dispatched(&retired, "ctrl-v", "Terminal").is_empty(), + "an emptied AlternatePaste gives Ctrl+V back to the shell" + ); + let mut everywhere = effective.clone(); + set_binding(&mut everywhere, "PasteText", "ctrl-v".to_string()); + for context in ["Terminal", "Terminal alt_screen"] { + assert!( + dispatched(&everywhere, "ctrl-v", context).contains(&PasteText::name_for_type()), + "a user may put Paste itself on Ctrl+V and have it on every screen" + ); + } let rebound = vec![("PasteText".to_string(), "ctrl-alt-v".to_string())]; assert!( !extra_keystrokes(&rebound) @@ -1449,28 +1558,14 @@ mod tests { #[test] fn no_default_binding_sits_on_a_terminal_control_code() { // The invariant is "no default may *swallow* a terminal control code". - // EditorSave deliberately stays on Ctrl+S: its handler in `app.rs` calls - // `cx.propagate()` whenever the editor does not have focus, so the - // keystroke falls through to the terminal as XOFF instead of dying at - // the window. Anything added here must have such a fall-through. - const FALLS_THROUGH_TO_TERMINAL: [&str; 1] = ["EditorSave"]; + // The exceptions are named and justified in + // `control_code_binding_allowed`; anything new needs a fall-through of + // its own to join them. for (action, spec) in default_bindings() { - if FALLS_THROUGH_TO_TERMINAL.contains(&action) { - continue; - } for chord in spec.split_whitespace() { - let ks = Keystroke::parse(chord).expect("default chords parse"); - let m = &ks.modifiers; - if !m.control || m.alt || m.shift || m.platform || m.function { - continue; - } - let steals = ks.key.len() == 1 - && ks.key.chars().next().is_some_and(|c| { - c.is_ascii_alphabetic() || "[]\\`".contains(c) || ('2'..='8').contains(&c) - }) - || ks.key == "space"; + Keystroke::parse(chord).expect("default chords parse"); assert!( - !steals, + !steals_a_control_code(chord) || control_code_binding_allowed(action, chord), "{action} is bound to {chord}, which the shell needs as a control code \ (Ctrl+[ is ESC, Ctrl+D is EOF, Ctrl+W deletes a word, \ Ctrl+2..8 are NUL/ESC/FS/GS/RS/US/DEL). \ @@ -1480,6 +1575,49 @@ mod tests { } } + #[test] + fn the_control_code_rule_knows_what_the_shell_needs() { + // The keys with a C0 byte behind them, and the modifier shape that + // reaches it: Ctrl alone. This is the predicate the defaults are held + // to above and the one `action_bindings` warns on. + for chord in [ + "ctrl-d", + "ctrl-c", + "ctrl-[", + "ctrl-2", + "ctrl-6", + "ctrl-8", + "ctrl-/", + "ctrl-space", + ] { + assert!(steals_a_control_code(chord), "{chord} is a control code"); + } + for chord in [ + "ctrl-shift-v", + "ctrl-alt-v", + "secondary-shift-t", + "ctrl-1", + "ctrl-9", + "ctrl--", + "ctrl-f3", + "alt-enter", + ] { + assert!( + !steals_a_control_code(chord), + "{chord} carries no control code" + ); + } + // Ctrl+V is allowed to anyone off macOS — it is how the default paste + // reaches the chord, and how a user moves the full-screen paste onto + // it — while Ctrl+D stays refused whoever asks. + assert_eq!( + control_code_binding_allowed("PasteText", "ctrl-v"), + cfg!(not(target_os = "macos")) + ); + assert!(!control_code_binding_allowed("PasteText", "ctrl-d")); + assert!(control_code_binding_allowed("EditorSave", "secondary-s")); + } + #[test] fn every_default_chord_is_claimed_by_exactly_one_action() { // Per context, not globally: gpui resolves a keystroke by walking the From 958d8b74428e42615158822605cb1694d0e40de8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:03:51 +0800 Subject: [PATCH 19/33] feat(window): dock the code panel and the diff overlay beside the terminal (#625) (#685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(window): dock the code panel and the diff overlay beside the terminal (#625) Opening a file covered the workspace. The terminal underneath kept running and was neither visible nor typeable, so reading a file while an agent talked was a toggle loop: open it, close it to read the reply, open it again. The Files tree already docks; the two surfaces you go to *from* it did not. They dock now, as a flex sibling of the terminal column rather than a narrower overlay — that distinction is the feature. `set_grid_size` is driven by the terminal element's laid-out bounds, so a column takes width away from the grid and the PTY reflows into what is left; a card painted over half the workspace would have left the grid full width with half of it hidden. `overlay_top` stops ordering a pair and starts choosing between them: a column has one child, and two `flex_1` siblings would split it and fight. Fill mode keeps the old vector, the old opaque paint and the old platform hoist untouched, so nothing about today's overlay changes for anyone who picks it. - Half the terminal column by default; drag the divider, double-click it to cycle a third / half / two thirds, or use the palette commands. Two thirds deliberately runs past the half-window cap the side panels obey — only the terminal's floor binds it. - `DOCUMENT_MIN_W` joins the width budget: both side panels reserve it the way they already reserve each other, and the column is derived from the *live* sidebar and panel widths rather than their floors, so a panel someone dragged wider is width the terminal keeps. - A window too narrow to seat both fills for that frame. The fallback is derived at render time and never stored, so widening re-docks on the next frame with nothing to undo. - Fill or dock is per tab, on the header's context menu. Reading a long file over the whole window in one tab while an agent keeps half of another is the normal case, and one global switch made each of those flip the other. A tab that has not been told reads `document_layout` from the config, which is what a fresh tab starts as — and which the menu therefore does not write, since every untold tab is reading it. - Everywhere but macOS the title bar spans the workspace, which left a bar's height of nothing above the column. The header is drawn into it, and behaves like the title bar it now sits in. With the detail panel closed the column reaches the window's right edge, so the header stops short of the trailing chrome through a width the tab strip's own reservation shares. - The docked headers drop the traffic-light inset they never had to clear, and the diff header's branch name becomes the thing that yields so the view toggle and the close tile survive a column's width. New in `config.json`: `document_ratio`, and `document_layout` for what a fresh tab starts as. Four new actions, bindable and unbound by default. * fix(window): hold the docked column to widths the strip and the file agree on Three defects in the document column, each with a guard test that fails without its fix. The tab strip did not know a column had taken width off it. On macOS the strip lives inside the terminal column and sizes itself to the window less the detail panel, so a docked document left it 340 points wider than the column it sits in and the chips ran on under the column — the same overrun the panel's own reservation was added for. Everywhere else the strip spans the workspace and the column's hoisted header is drawn over its trailing end with no fill of its own, so a chip left under it showed through the file name and stayed clickable through it. The column's width now comes off `strip_w` on macOS and off `corner_w` elsewhere, which is where the panel's already goes. The divider wrote widths the file would not keep. `Config::sanitize` holds `document_ratio` to 0.2..=0.8; the drag clamped in pixels only, so a column pushed against either edge of a wide window was saved outside that band and reopened somewhere else — on a 2560-point body, 232 points from where it was dropped. The band is a pair of shared constants now and the drag clamps to it, the way the font size and its stepper were made to agree in #550. The palette named the config's layout rather than the tab's. Fill is per tab, so a tab told to fill was still offered "Document: Fill Window" — a row that named the state it was already in and did the opposite. It reads the active tab through `ChromeState` now. Also: `document_layout`'s doc comment still described the global switch an earlier draft had, three lines after the field became a per-tab default. --- CHANGELOG.md | 15 + crates/tty7-core/src/core/config.rs | 95 +++ docs/git/diffs.mdx | 5 +- docs/reference/configuration.mdx | 2 + docs/window/side-panel.mdx | 26 + src/core/actions.rs | 4 + src/ui/app.rs | 296 +++++++-- src/ui/code_editor.rs | 59 +- src/ui/diff_overlay.rs | 102 +++- src/ui/document_column.rs | 904 ++++++++++++++++++++++++++++ src/ui/i18n/en.rs | 8 + src/ui/i18n/ja.rs | 8 + src/ui/i18n/mod.rs | 8 + src/ui/i18n/zh.rs | 8 + src/ui/keymap.rs | 27 + src/ui/mod.rs | 1 + src/ui/palette.rs | 31 + src/ui/right_panel.rs | 6 +- src/ui/tab_sidebar.rs | 19 +- src/ui/tab_strip.rs | 48 +- 20 files changed, 1579 insertions(+), 93 deletions(-) create mode 100644 src/ui/document_column.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index cf81ddd6..425504e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Documents dock beside the terminal** (#625). Opening a file, toggling the + code panel or opening a diff no longer covers the workspace: the document + takes a column to the right of the terminal — half the space between the + sidebar and the right panel by default — and the pane you were reading stays + visible and typeable underneath none of it. Reviewing a file while an agent + talks stopped being a toggle loop. Drag the divider for any width, double-click + it to cycle a third, a half and two thirds, or use **Document: Third / Half / + Two-Thirds Width** in the palette. Right-click the document's header for + **Fill window** — the old overlay, unchanged, and per tab, so a file read + over the whole window in one tab leaves the agent beside its own in the next. + The terminal keeps its floor through all of it, and a window too narrow to + seat both fills for that file only, without changing what any tab chose. New + in `config.json`: `document_ratio`, and `document_layout` for what a fresh + tab starts as. + - **A tab can be dropped into another tab, as a pane of it** (#621). Drag a tab by its chip or by its sidebar row, out over the panes, and it lands where the highlight says — the same reading as dragging a pane, minus the middle, which diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index ef6309ad..4aec4ccb 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -183,6 +183,20 @@ pub struct Config { /// `diffEditor.renderSideBySide` makes. #[serde(default, deserialize_with = "de_lenient")] pub diff_view: DiffViewMode, + /// How the code / diff surface shares the window with the terminal — for a + /// tab that has not been told otherwise. The choice itself is per tab, made + /// on the document header's context menu; this is the value a fresh tab + /// starts from, and therefore the one every untold tab is still reading, + /// which is why the menu never writes it. + #[serde(default, deserialize_with = "de_lenient")] + pub document_layout: DocumentLayout, + /// The share of the terminal column — the flex area between the sidebar and + /// the right panel — the document column takes when docked. The named + /// widths land on a third, a half and two thirds; a drag leaves whatever it + /// leaves, held to [`DOCUMENT_RATIO_MIN`]..=[`DOCUMENT_RATIO_MAX`]. Live + /// layout narrows it further when the terminal's floor needs the width. + #[serde(default = "default_document_ratio")] + pub document_ratio: f32, /// The source control panel's history section starts collapsed: a graph /// unfurling the first time someone opens the panel is a worse first /// impression than one they asked for. @@ -586,6 +600,8 @@ impl Default for Config { right_panel_width: default_right_panel_width(), right_panel_tab: RightPanelTab::Info, diff_view: DiffViewMode::Split, + document_layout: DocumentLayout::default(), + document_ratio: default_document_ratio(), scm_graph_expanded: false, sidebar_grouping: SidebarGrouping::Repo, sidebar_diff_preview: true, @@ -759,6 +775,12 @@ impl Config { self.right_panel_width = default_right_panel_width(); } self.right_panel_width = self.right_panel_width.clamp(100.0, 2000.0); + if !self.document_ratio.is_finite() || self.document_ratio <= 0.0 { + self.document_ratio = default_document_ratio(); + } + self.document_ratio = self + .document_ratio + .clamp(DOCUMENT_RATIO_MIN, DOCUMENT_RATIO_MAX); if let Some(command) = &self.link_file_command && command.trim().is_empty() { @@ -1073,6 +1095,48 @@ fn default_right_panel_width() -> f32 { 260. } +/// Where the code / diff surface is drawn. +/// +/// It used to be one thing — a full-workspace overlay — so there was nothing to +/// name. Docking it beside the terminal is the default now: opening a file to +/// read it while an agent talks underneath was the reason the built-in editor +/// exists, and an overlay covers the agent. `Fill` is that overlay, kept for +/// anyone who wants the whole window for the file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DocumentLayout { + #[default] + Dock, + Fill, +} + +fn default_document_ratio() -> f32 { + 0.5 +} + +/// The band `document_ratio` is held to — in the file *and* at the divider. +/// +/// One shared pair rather than two, for the reason `FONT_SIZE_MIN` and its +/// stepper are one pair (#550): a GUI that clamps somewhere the file does not +/// writes a value `sanitize` then moves, and the user finds the thing they +/// dropped somewhere else on the next launch. The divider clamps in *pixels* +/// against the terminal's floor as well, which is the tighter limit on a narrow +/// window; on a wide one this band is, and both ends of it have to be reachable +/// and keepable. +pub const DOCUMENT_RATIO_MIN: f32 = 0.2; +pub const DOCUMENT_RATIO_MAX: f32 = 0.8; + +/// The named shares of the terminal column a document column can be snapped to, +/// in the order the segmented control and the divider's double-click cycle use. +pub const DOCUMENT_RATIO_THIRD: f32 = 1. / 3.; +pub const DOCUMENT_RATIO_HALF: f32 = 0.5; +pub const DOCUMENT_RATIO_TWO_THIRDS: f32 = 2. / 3.; +pub const DOCUMENT_RATIO_STOPS: [f32; 3] = [ + DOCUMENT_RATIO_THIRD, + DOCUMENT_RATIO_HALF, + DOCUMENT_RATIO_TWO_THIRDS, +]; + /// The rem the chrome has always been laid out against — gpui's own default, /// which is what `text_sm()` and `text_xs()` resolve 14px and 12px from. Left /// alone, the interface looks exactly as it did before the size was settable. @@ -1564,6 +1628,37 @@ mod tests { assert_eq!(clamp(0.01), 0.1); } + /// A width the user dropped the divider at has to come back where they left + /// it. `sanitize` holds `document_ratio` to a band; the divider clamps to + /// the same one, through these constants, so nothing it can write is + /// something the next launch moves. The regression this pins: the drag used + /// to clamp in pixels alone, so a column pushed against either edge of a + /// wide window was saved outside the band and reopened hundreds of points + /// from where it was dropped. + #[test] + fn sanitize_holds_document_ratio_to_the_band_the_divider_clamps_to() { + let clamp = |r: f32| { + let mut cfg = Config { + document_ratio: r, + ..Config::default() + }; + cfg.sanitize(); + cfg.document_ratio + }; + // Both edges are legal, so a divider dropped on one has somewhere to + // stop rather than a value that keeps being rewritten. + assert_eq!(clamp(DOCUMENT_RATIO_MIN), DOCUMENT_RATIO_MIN); + assert_eq!(clamp(DOCUMENT_RATIO_MAX), DOCUMENT_RATIO_MAX); + for stop in DOCUMENT_RATIO_STOPS { + assert_eq!(clamp(stop), stop, "a named width must survive the file"); + } + assert_eq!(clamp(0.05), DOCUMENT_RATIO_MIN); + assert_eq!(clamp(0.95), DOCUMENT_RATIO_MAX); + assert_eq!(clamp(0.0), default_document_ratio()); + assert_eq!(clamp(-1.0), default_document_ratio()); + assert_eq!(clamp(f32::NAN), default_document_ratio()); + } + #[test] fn sanitize_clamps_window_opacity_override() { let clamp = |o: Option| { diff --git a/docs/git/diffs.mdx b/docs/git/diffs.mdx index 836d825a..8915db72 100644 --- a/docs/git/diffs.mdx +++ b/docs/git/diffs.mdx @@ -11,7 +11,10 @@ description: "The diff overlay: side-by-side or unified, from the sidebar or the | Source Control | **Open Changes** on a file, or click the row | | History | Click a file inside a commit's detail view | -The overlay covers the window; Esc closes it. +A diff docks beside the terminal, in the same column an open file uses, so the +pane you were reading stays on screen. Esc closes it, and its header +right-clicks to a menu that fills the window with it if you prefer that — +[more about the column →](/window/side-panel#where-it-opens) The tty7 diff overlay diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index 3ff0d300..8e32b60e 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -77,6 +77,8 @@ their id from the file name. [More about themes →](/customization/themes) | `right_panel_width` | number | `260` | Pixels (100–2000). | | `right_panel_tab` | enum | `"info"` | `info`, `changes`, `files`. | | `diff_view` | enum | `"split"` | Or `unified`. Global, not per file. | +| `document_layout` | enum | `"dock"` | Where an open file or diff is drawn: `dock` beside the terminal, or `fill` over the workspace. What a fresh tab starts as — each tab keeps its own from there. | +| `document_ratio` | number | `0.5` | The docked column’s share of the terminal column (0.2–0.8). Named widths are `0.333`, `0.5`, `0.667`. | | `scm_graph_expanded` | bool | `false` | Whether the history section starts open. | | `show_tray_icon` | bool | `true` | The tray / menu bar status item. | diff --git a/docs/window/side-panel.mdx b/docs/window/side-panel.mdx index 71277d56..31172220 100644 --- a/docs/window/side-panel.mdx +++ b/docs/window/side-panel.mdx @@ -75,3 +75,29 @@ have opened `vim` for. Files are watched on disk: a change underneath you is picked up, and closing with unsaved edits asks before discarding them. Files over 4 MB and anything that looks binary are refused with a note rather than opened badly. + +### Where it opens + +The editor docks beside the terminal, taking half the space between the sidebar +and the right panel. The terminal keeps running, stays visible, and stays +typeable — click it, read what your agent said, click back. Diffs open in the +same column. + +Drag the divider for any width between a fifth and four fifths of that space — +the range `document_ratio` keeps — or double-click it to cycle a third, a half +and two thirds. **Document: Third / Half / Two-Thirds Width** in the command +palette do the same. On a narrow window the terminal's own floor stops the +divider sooner. + +Right-click the document's header for **Fill window**, which is the old +full-workspace overlay, unchanged. **Document: Fill Window** and **Document: +Dock Beside Terminal** in the palette are the same switch. + +Fill or dock is **per tab**: read a long file over the whole window in one tab +while an agent keeps half of another, and neither moves the other. A fresh tab +starts from `document_layout` in `config.json`. The width is shared, and +persists as `document_ratio`. + +A window too narrow to give both the terminal and the document a readable width +fills for that file only — widen it and the column comes back, without your +setting having changed. diff --git a/src/core/actions.rs b/src/core/actions.rs index d4689f7b..09604411 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -102,6 +102,10 @@ actions!( ToggleSftp, ShowSshForwards, ToggleCodePanel, + ToggleDocumentFill, + DocumentWidthThird, + DocumentWidthHalf, + DocumentWidthTwoThirds, EditorSave, OpenSshProfiles, RestartSshSession, diff --git a/src/ui/app.rs b/src/ui/app.rs index eb2c7fc6..3f7c4fe2 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -175,10 +175,12 @@ pub(crate) const HOME_CURSOR_BLINK_MS: u64 = 600; /// dropping it would have let a panel grow past where it could before under /// cover of a change that is only meant to take width away from panels. /// -/// `other_floor` is zero when the other panel is closed, which is why this -/// takes floors rather than reading them: only the caller knows what is up. -pub(crate) fn side_panel_max(viewport: f32, own_floor: f32, other_floor: f32) -> f32 { - (viewport - TERMINAL_MIN_W - other_floor) +/// `others_floor` is the sum of the floors under every *other* column that is +/// open — the panel opposite, and the document column when a file or a diff is +/// docked. It is zero for each of them that is closed, which is why this takes +/// floors rather than reading them: only the caller knows what is up. +pub(crate) fn side_panel_max(viewport: f32, own_floor: f32, others_floor: f32) -> f32 { + (viewport - TERMINAL_MIN_W - others_floor) .min(viewport * SIDE_PANEL_MAX_RATIO) .max(own_floor) } @@ -186,6 +188,35 @@ pub(crate) fn side_panel_max(viewport: f32, own_floor: f32, other_floor: f32) -> /// The half of the window neither panel may grow past on its own. const SIDE_PANEL_MAX_RATIO: f32 = 0.5; +/// The narrowest a docked document column may be squeezed to: the header, a +/// readable run of about thirty columns, and the status bar under them. Below +/// this a file is a ribbon of hyphenated fragments and the column is worth +/// less than the terminal width it costs. +pub(crate) const DOCUMENT_MIN_W: f32 = 280.; + +/// How wide the docked document column is, given the width the terminal and the +/// document share — the window less the sidebar and the right panel — and the +/// share of it the user asked for. +/// +/// `None` is the narrow-window answer: there is no way to give both the +/// terminal and a document a width worth reading, so the caller falls back to +/// filling the workspace for this frame. That fallback is *derived*, never +/// stored — widening the window docks again on the next frame, and the user's +/// saved `document_layout` is untouched throughout. +/// +/// The named two-thirds deliberately runs past the half-window cap the side +/// panels obey. That cap is there so neither *panel* can dominate a wide +/// display; the document is the thing the user is reading, and an increment +/// that silently became a half on every window wider than about 720 points of +/// body would be a lie. The terminal's floor still binds. +pub(crate) fn document_column_px(body: f32, ratio: f32) -> Option { + if !body.is_finite() || body < TERMINAL_MIN_W + DOCUMENT_MIN_W { + return None; + } + let ratio = if ratio.is_finite() { ratio } else { 0.5 }; + Some((body * ratio).clamp(DOCUMENT_MIN_W, body - TERMINAL_MIN_W)) +} + pub(crate) const TITLE_BAR_HEIGHT: f32 = 40.; pub(crate) const TILE_SIZE: f32 = 32.; @@ -353,6 +384,16 @@ pub struct Tab { pub(crate) code: Option>, pub(crate) sidebar_group: std::cell::RefCell>, pub(crate) overlay_top: OverlayTop, + /// Whether this tab's document fills the workspace or docks beside the + /// terminal, once the tab has been told. `None` follows `document_layout` + /// in the config, which is the default a fresh tab starts from and the + /// last explicit choice anyone made. + /// + /// Per tab rather than per window because what you are doing differs per + /// tab: reading a long file in one while an agent works in another wants + /// the whole window here and half of it there, and a global switch made + /// each of those flip the other. + pub(crate) document_layout: Option, pub(crate) tree_id: std::cell::Cell, /// Monotonic stamp of when this tab was last activated, used to order the /// switcher's tab column most-recently-used first. Zero means never. @@ -367,7 +408,7 @@ pub(crate) enum OverlayTop { } impl Tab { - fn new(pane: Pane) -> Self { + pub(crate) fn new(pane: Pane) -> Self { Self { pane, name: None, @@ -376,6 +417,7 @@ impl Tab { diff_overlay: None, code: None, overlay_top: OverlayTop::default(), + document_layout: None, sidebar_group: std::cell::RefCell::new(None), tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), @@ -391,6 +433,7 @@ impl Tab { diff_overlay: None, code: None, overlay_top: OverlayTop::default(), + document_layout: None, sidebar_group: std::cell::RefCell::new( tree.sidebar_group.clone().map(std::path::PathBuf::from), ), @@ -636,6 +679,12 @@ pub struct Tty7App { pub(crate) settings_hit_anchored: Cell, pub(crate) right_panel_width: Rc>, pub(crate) right_panel_dragging: Rc>, + /// The docked document column's share of the terminal column, live. Held + /// beside the config value rather than in it for the same reason the two + /// panel widths are: a drag writes this cell on every mouse move and the + /// config once, on mouse up. + pub(crate) document_ratio: Rc>, + pub(crate) document_dragging: Rc>, pub(crate) right_panel_visible: bool, pub(crate) right_panel_tab: RightPanelTab, pub(crate) sidebar_collapsed: bool, @@ -1057,6 +1106,7 @@ impl Tty7App { }); let sidebar_width = cx.global::().sidebar_width; let right_panel_width = cx.global::().right_panel_width; + let document_ratio = cx.global::().document_ratio; let right_panel_visible = cx.global::().right_panel_visible; let right_panel_tab = cx.global::().right_panel_tab; let scm_graph_expanded = cx.global::().scm_graph_expanded; @@ -1227,6 +1277,8 @@ impl Tty7App { settings_hit_anchored: Cell::new(false), right_panel_width: Rc::new(Cell::new(right_panel_width)), right_panel_dragging: Rc::new(Cell::new(false)), + document_ratio: Rc::new(Cell::new(document_ratio)), + document_dragging: Rc::new(Cell::new(false)), right_panel_visible, right_panel_tab, sidebar_collapsed, @@ -1550,6 +1602,7 @@ impl Tty7App { diff_overlay: None, code: None, overlay_top: OverlayTop::default(), + document_layout: None, sidebar_group: std::cell::RefCell::new(st.sidebar_group), tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), @@ -4638,12 +4691,14 @@ impl Tty7App { cx.notify(); } - fn palette_commands(&self, window: &Window, cx: &App) -> Vec { + pub(crate) fn palette_commands(&self, window: &Window, cx: &App) -> Vec { let mut commands = Command::base_commands( cx, ChromeState { rail_collapsed: self.sidebar_collapsed, right_panel_visible: self.right_panel_visible, + document_filled: self.document_layout(cx) + == crate::core::config::DocumentLayout::Fill, }, ); @@ -4878,6 +4933,16 @@ impl Tty7App { ToggleSftp => self.toggle_sftp(window, cx), ShowSshForwards => self.show_ssh_forwards(window, cx), ToggleCodePanel => self.toggle_code_panel(window, cx), + ToggleDocumentFill => self.toggle_document_fill(cx), + DocumentWidthThird => { + self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_THIRD, cx) + } + DocumentWidthHalf => { + self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_HALF, cx) + } + DocumentWidthTwoThirds => { + self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_TWO_THIRDS, cx) + } RestartSshSession => self.restart_ssh_session(window, cx), SetTheme(i) => { if let Some(id) = crate::ui::presets::all(cx).get(i).map(|t| t.id.clone()) { @@ -5735,6 +5800,8 @@ impl Tty7App { self.sidebar_width.set(cx.global::().sidebar_width); self.right_panel_width .set(cx.global::().right_panel_width); + self.document_ratio + .set(cx.global::().document_ratio); if font_size != self.font_size { self.font_size = font_size; let px_size = px(font_size); @@ -6972,27 +7039,72 @@ impl Render for Tty7App { this.child(el) }); - let diff_overlay = self.render_diff_overlay(window, cx); - - let code_overlay = self.render_code_overlay(window, cx); - - let overlays: Vec = { - let mut pair = vec![ - (OverlayTop::Diff, diff_overlay), - (OverlayTop::Code, code_overlay), - ]; - if self - .tabs - .get(self.active) - .is_some_and(|t| t.overlay_top == OverlayTop::Diff) - { - pair.reverse(); - } - pair.into_iter().filter_map(|(_, el)| el).collect() + // One decision for the whole document surface. Docked, exactly one of + // the two surfaces is drawn — a column has one child, and two `flex_1` + // siblings would split it and fight — so `overlay_top` stops ordering a + // pair and starts choosing between them. Filling, nothing changes: both + // are rendered, ordered by `overlay_top`, and the front one wins on + // paint order as it always has. + let document_dock_px = self.document_dock_px(window, cx); + // Where the docked header sits. Everywhere but macOS the title bar + // spans the workspace and leaves the strip above the column empty, so + // the header goes up into it; on macOS the column already reaches the + // top of the window and its own first row lands there. + let document_chrome = if cfg!(target_os = "macos") { + crate::ui::document_column::DocumentChrome::Dock + } else { + crate::ui::document_column::DocumentChrome::DockHoisted }; + let document_header = document_dock_px + .is_some() + .then(|| self.render_document_header(document_chrome, window, cx)) + .flatten(); + let (overlays, document_column) = match document_dock_px { + Some(w) => ( + Vec::new(), + self.render_document_column(w, document_chrome, window, cx), + ), + None => { + let diff_overlay = self.render_diff_overlay( + crate::ui::document_column::DocumentChrome::Fill, + window, + cx, + ); + let code_overlay = self.render_code_overlay( + crate::ui::document_column::DocumentChrome::Fill, + window, + cx, + ); + let mut pair = vec![ + (OverlayTop::Diff, diff_overlay), + (OverlayTop::Code, code_overlay), + ]; + if self + .tabs + .get(self.active) + .is_some_and(|t| t.overlay_top == OverlayTop::Diff) + { + pair.reverse(); + } + ( + pair.into_iter() + .filter_map(|(_, el)| el) + .collect::>(), + None, + ) + } + }; + let document_px = document_column + .as_ref() + .map_or(0., |_| document_dock_px.unwrap_or_default()); let right_panel = self.render_right_panel(window, cx); - let panel_below_title_bar = right_panel.is_some() && !cfg!(target_os = "macos"); + // A docked document takes the same fork the right panel does: on + // Windows and Linux the window controls live at the right end of the + // title bar, so the bar has to span the workspace rather than sit + // inside the terminal column with a column drawn to the right of it. + let panel_below_title_bar = + (right_panel.is_some() || document_column.is_some()) && !cfg!(target_os = "macos"); let (column_title_bar, spanning_title_bar) = if panel_below_title_bar { (None, Some(title_bar)) } else { @@ -7003,7 +7115,11 @@ impl Render for Tty7App { } else { (overlays, Vec::new()) }; - let panel_px = self.right_panel_px(window, cx); + let panel_px = if right_panel.is_some() { + self.right_panel_px(window, cx) + } else { + 0. + }; let terminal_column = div() .flex_1() .min_w_0() @@ -7020,6 +7136,7 @@ impl Render for Tty7App { .flex() .flex_row() .child(terminal_column) + .when_some(document_column, |this, column| this.child(column)) .when_some(right_panel, |this, panel| this.child(panel)); let main_layout = div() .flex_1() @@ -7039,18 +7156,59 @@ impl Render for Tty7App { div() .relative() .flex_none() - .child( - div() - .absolute() - .top_0() - .bottom_0() - .right_0() - .w(px(self.right_panel_px(window, cx))) - .bg(crate::ui::theme::workspace_surface_color(cx)) - .border_l_1() - .border_color(cx.theme().sidebar_border), - ) - .child(bar), + // One patch per column below, rather than one for + // both: each carries the left border its own column + // carries, so the rule between the document and the + // detail panel runs the full height of the window + // instead of stopping at the title bar. + .when(panel_px > 0., |this| { + this.child( + div() + .absolute() + .top_0() + .bottom_0() + .right_0() + .w(px(panel_px)) + .bg(crate::ui::theme::workspace_surface_color(cx)) + .border_l_1() + .border_color(cx.theme().sidebar_border), + ) + }) + .when(document_px > 0., |this| { + this.child( + div() + .absolute() + .top_0() + .bottom_0() + .right(px(panel_px)) + .w(px(document_px)) + .bg(crate::ui::theme::workspace_surface_color(cx)) + .border_l_1() + .border_color(cx.theme().sidebar_border), + ) + }) + .child(bar) + // The document's header, in the strip the spanning + // title bar leaves empty above its column. Drawn + // after the bar so it sits over the tab strip's + // slack — and stopping short of the trailing + // chrome, which is only in the way when the detail + // panel is closed and this column is the one at the + // window's right edge. + .when_some(document_header, |this, header| { + this.child( + div() + .absolute() + .top_0() + .h(px(TITLE_BAR_HEIGHT)) + .right(px(panel_px)) + .w(px(document_px)) + .when(panel_px <= 0., |d| { + d.pr(px(crate::ui::tab_strip::trailing_chrome_w())) + }) + .child(header), + ) + }), ) .child(panel_row) .children(hoisted_overlays.into_iter().map(|overlay| { @@ -7266,6 +7424,20 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &ToggleDiffViewMode, _window, cx| { this.toggle_diff_view_mode(cx) })) + .on_action(cx.listener(|this, _: &ToggleDocumentFill, _window, cx| { + this.toggle_document_fill(cx) + })) + .on_action(cx.listener(|this, _: &DocumentWidthThird, _window, cx| { + this.set_document_ratio(crate::core::config::DOCUMENT_RATIO_THIRD, cx) + })) + .on_action(cx.listener(|this, _: &DocumentWidthHalf, _window, cx| { + this.set_document_ratio(crate::core::config::DOCUMENT_RATIO_HALF, cx) + })) + .on_action( + cx.listener(|this, _: &DocumentWidthTwoThirds, _window, cx| { + this.set_document_ratio(crate::core::config::DOCUMENT_RATIO_TWO_THIRDS, cx) + }), + ) .on_action(cx.listener(|this, _: &ScmCommit, window, cx| { this.run_scm_action(ScmIntent::Commit, window, cx) })) @@ -7642,6 +7814,7 @@ fn tabs_from_session( diff_overlay: None, code: None, overlay_top: OverlayTop::default(), + document_layout: None, sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()), tree_id: std::cell::Cell::new( st.tree_id @@ -8439,10 +8612,10 @@ mod window_drag_tests { #[cfg(test)] mod tests { use super::{ - CloseReason, TERMINAL_MIN_W, TabAgentSession, clear_window_override_values, close_prompt, - join_shell_args, leaf_shares_the_window_daemon, mru_order, pane_free_for, - parse_ssh_connect_input, parse_ssh_option_words, side_panel_max, split_shell_args, - wd_path_saveable, + CloseReason, DOCUMENT_MIN_W, TERMINAL_MIN_W, TabAgentSession, clear_window_override_values, + close_prompt, document_column_px, join_shell_args, leaf_shares_the_window_daemon, + mru_order, pane_free_for, parse_ssh_connect_input, parse_ssh_option_words, side_panel_max, + split_shell_args, wd_path_saveable, }; const SIDEBAR_MIN: f32 = crate::ui::tab_sidebar::MIN_SIDEBAR_WIDTH; @@ -8468,6 +8641,47 @@ mod tests { assert_eq!(side_panel_max(mid, SIDEBAR_MIN, 0.), mid / 2.); } + /// A docked document is a third column in the same budget, so it has to be + /// reserved by the two panels the way they already reserve each other — + /// otherwise a panel dragged to its old limit takes the width out of the + /// document, which then has nowhere to take it from but the terminal. + #[test] + fn a_docked_document_is_reserved_by_the_panels_too() { + let wide = 1440.; + let max = side_panel_max(wide, PANEL_MIN, SIDEBAR_MIN + DOCUMENT_MIN_W); + assert_eq!( + wide - SIDEBAR_MIN - DOCUMENT_MIN_W - max, + TERMINAL_MIN_W, + "a panel at its cap, with both other columns at their floors, leaves the terminal exactly its floor" + ); + assert!( + max < side_panel_max(wide, PANEL_MIN, SIDEBAR_MIN), + "the reservation only ever takes width away" + ); + } + + /// The floors are not what the panels are actually drawn at. Both are + /// draggable and both persist, so the budget has to be fed the live widths + /// or a widened sidebar is width the terminal silently loses. + #[test] + fn widened_panels_still_leave_the_terminal_its_floor() { + let viewport = 1440.; + // Both dragged well past their floors, and the document asked for the + // widest named share there is. + let body = viewport - 400. - 320.; + let document = document_column_px(body, 2. / 3.).expect("720 points seats both"); + assert!( + body - document >= TERMINAL_MIN_W, + "terminal got {}", + body - document + ); + + // Squeezed further, the document is the one that gives up first — and + // then stops existing rather than dropping under its own floor. + let squeezed = viewport - 600. - 400.; + assert_eq!(document_column_px(squeezed, 0.5), None); + } + /// The reservation must only ever take width away from a panel. On a wide /// window it works out *larger* than the half-window cap that was already /// there, and letting it win would widen the ceiling instead. diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 150b49b4..428714d6 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -9,11 +9,13 @@ use gpui::{ }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::{Input, InputEvent, InputState, Position, TabSize}; +use gpui_component::menu::ContextMenuExt as _; use gpui_component::{ ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex, v_flex, }; use crate::ui::app::Tty7App; +use crate::ui::document_column::DocumentChrome; use crate::ui::host_ops::{HostId, HostOps, MTime, SharedHost, WatchSub}; use crate::ui::i18n::{L10nKey, t, t_fmt}; @@ -1081,6 +1083,7 @@ impl Tty7App { impl Tty7App { pub(crate) fn render_code_overlay( &mut self, + chrome: DocumentChrome, window: &mut Window, cx: &mut Context, ) -> Option { @@ -1132,17 +1135,25 @@ impl Tty7App { .filter(|f| f.conflict) .map(|_| self.render_editor_conflict_banner(cx)); + let header = chrome + .renders_own_header() + .then(|| self.render_editor_header(chrome, window, cx)); let editor_col = v_flex() .flex_1() .min_w_0() .h_full() - .child(self.render_editor_header(window, cx)) + .children(header) .when_some(conflict_banner, |this, b| this.child(b)) .child(div().flex_1().min_h_0().child(body)); - Some( - v_flex() - .id("code-panel") + // The panel's own paint is the same either way; only the box is not. + // Filling the workspace means stopping the window's translucency and + // repainting the theme image the root's copy now sits under; docking + // means sitting in the same plane as the right panel, which the column + // wrapper has already painted. + let shell = v_flex().id("code-panel"); + let shell = match chrome { + DocumentChrome::Fill => shell .absolute() .inset_0() .occlude() @@ -1154,33 +1165,58 @@ impl Tty7App { // theme background image is repainted on top of it, since the // root's copy now sits below this fill. .bg(crate::ui::theme::overlay_background(cx)) + .children(crate::ui::app::overlay_surface_layers(cx)), + DocumentChrome::Dock | DocumentChrome::DockHoisted => shell.size_full().min_w_0(), + }; + Some( + shell .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { if ev.keystroke.key == "escape" { this.toggle_code_panel(window, cx); } })) - .children(crate::ui::app::overlay_surface_layers(cx)) .child(h_flex().flex_1().min_h_0().w_full().child(editor_col)) .child(self.render_code_status_bar(window, cx)) .into_any_element(), ) } - fn render_editor_header( + /// The editor header alone, for the strip above a docked column. + pub(crate) fn render_editor_header_only( &self, + chrome: DocumentChrome, window: &mut Window, cx: &mut Context, - ) -> gpui::Stateful { + ) -> gpui::AnyElement { + self.render_editor_header(chrome, window, cx) + .into_any_element() + } + + fn render_editor_header( + &self, + chrome: DocumentChrome, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement + use<> { let active = self.tab_code().and_then(|c| c.active_file()); let name = active.map(|f| f.label()); let dirty = active.is_some_and(|f| f.dirty); - let lead = if self.left_panel_open(cx) { + // `TITLE_BAR_LEAD` is the room macOS's traffic lights need. Only a + // header that starts at the left edge of the window has them to clear, + // and a docked column never does. + let lead = if self.left_panel_open(cx) || chrome.is_dock() { crate::ui::app::CONTENT_INSET } else { crate::ui::app::TITLE_BAR_LEAD }; - crate::ui::app::title_bar_drag(h_flex().id("editor-header"), "editor-header", window, cx) - .flex_none() + let row = h_flex().id("editor-header"); + let row = if chrome.header_is_title_strip() { + crate::ui::app::title_bar_drag(row, "editor-header", window, cx) + } else { + row + }; + let menu_app = cx.entity().downgrade(); + row.flex_none() .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) .items_center() .gap_1p5() @@ -1226,6 +1262,9 @@ impl Tty7App { })), ), ) + .context_menu(move |menu, _window, cx| { + Tty7App::document_header_menu(menu, &menu_app, cx) + }) } fn render_code_status_bar(&self, _window: &Window, cx: &mut Context) -> gpui::Div { diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index a3706413..2e869e31 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -7,6 +7,7 @@ use gpui::{ Window, div, prelude::*, px, }; use gpui_component::button::Button; +use gpui_component::menu::ContextMenuExt as _; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; use crate::core::config::{Config, DiffViewMode}; @@ -22,6 +23,7 @@ use crate::terminal::git_diff::{ const MAX_PREVIEW_BYTES: u64 = 4 * 1024 * 1024; use crate::ui::app::Tty7App; use crate::ui::diff_rows::{Side, SplitCell, SplitRow, UnifiedRow, split_hunk, unified_rows}; +use crate::ui::document_column::DocumentChrome; use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; use crate::ui::right_panel::info_chip; use crate::ui::rounding; @@ -388,6 +390,7 @@ impl Tty7App { pub(crate) fn render_diff_overlay( &mut self, + chrome: DocumentChrome, window: &mut Window, cx: &mut Context, ) -> Option { @@ -426,10 +429,14 @@ impl Tty7App { ), }; - let header = self.diff_header(overlay, window, cx); + let header = chrome + .renders_own_header() + .then(|| self.diff_header(overlay, chrome, window, cx)); + let focus_handle = overlay.focus_handle.clone(); - Some( - v_flex() + let shell = v_flex(); + let shell = match chrome { + DocumentChrome::Fill => shell .absolute() .inset_0() .occlude() @@ -439,27 +446,49 @@ impl Tty7App { cx.try_global::(), cx.theme().background, )) + // The opaque fill above covers the theme background image the + // workspace root paints, so the overlay carries its own copy, + // dimmed back to the strength it had when this overlay was + // itself translucent. + .children(crate::ui::app::overlay_surface_layers(cx)), + // Docked, the column wrapper has already painted the surface this + // sits on — the same one the right panel uses — and nothing behind + // it needs stopping. + DocumentChrome::Dock | DocumentChrome::DockHoisted => shell.size_full().min_w_0(), + }; + Some( + shell .text_color(cx.theme().foreground) - .track_focus(&overlay.focus_handle) + .track_focus(&focus_handle) .on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| { if ev.keystroke.key.as_str() == "escape" { this.close_diff_overlay(window, cx); } })) - // The opaque fill above covers the theme background image the - // workspace root paints, so the overlay carries its own copy, - // dimmed back to the strength it had when this overlay was - // itself translucent. - .children(crate::ui::app::overlay_surface_layers(cx)) - .child(header) + .children(header) .child(content) .into_any_element(), ) } + /// The diff header alone, for the strip above a docked column. + pub(crate) fn render_diff_header_only( + &mut self, + chrome: DocumentChrome, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?; + Some( + self.diff_header(overlay, chrome, window, cx) + .into_any_element(), + ) + } + fn diff_header( &self, overlay: &DiffOverlayState, + chrome: DocumentChrome, window: &mut Window, cx: &mut Context, ) -> impl IntoElement + use<> { @@ -471,19 +500,25 @@ impl Tty7App { } _ => (String::new(), 0, 0, 0, 0), }; - let lead = if self.left_panel_open(cx) { + // See `render_editor_header`: the traffic-light inset belongs to a + // header that starts at the window's left edge, which a column's does + // not. + let lead = if self.left_panel_open(cx) || chrome.is_dock() { crate::ui::app::CONTENT_INSET } else { crate::ui::app::TITLE_BAR_LEAD }; let mono = SharedString::from(self.font_family.clone()); let subject = source_subject(&overlay.source, branch); - let row = crate::ui::app::title_bar_drag( - h_flex().id("diff-overlay-header"), - "diff-overlay-header", - window, - cx, - ); + let subject_takes_the_slack = + chrome.is_dock() && !subject.is_rev && subject.label.is_none(); + let menu_app = cx.entity().downgrade(); + let row = h_flex().id("diff-overlay-header"); + let row = if chrome.header_is_title_strip() { + crate::ui::app::title_bar_drag(row, "diff-overlay-header", window, cx) + } else { + row + }; row.flex_shrink_0() .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) .pl(px(lead)) @@ -509,7 +544,16 @@ impl Tty7App { .child(subject.text) .into_any_element() } else { + // Docked, this is the name that gives: the header has a + // column's width rather than a window's, and a branch name that + // refused to yield any of it pushed the view toggle and the + // close tile off the end. It takes the slack the spacer below + // would otherwise have — the same trade the label branch makes, + // and for the same reason two `flex_1` siblings would split the + // line and truncate the name with empty space beside it. div() + .when(subject_takes_the_slack, |d| d.flex_1().min_w_0().truncate()) + .when(!subject_takes_the_slack, |d| d.flex_shrink_0()) .text_sm() .font_weight(FontWeight::MEDIUM) .child(subject.text) @@ -588,12 +632,17 @@ impl Tty7App { if untracked > 0 { summary.push_str(&t_plural(L10nKey::DiffUntrackedCount, untracked, &[])); } - bar.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(summary), - ) + // The file count is the first thing a column drops: the + // same number is one line down, at the top of the list. + // The totals stay — they have no second home. + bar.when(!chrome.is_dock(), |bar| { + bar.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(summary), + ) + }) .when(added > 0, |bar| { bar.child( div() @@ -623,7 +672,9 @@ impl Tty7App { ) }, ) - .when(subject.label.is_none(), |bar| bar.child(div().flex_1())) + .when(subject.label.is_none() && !subject_takes_the_slack, |bar| { + bar.child(div().flex_1()) + }) .child(div().occlude().flex_shrink_0().child({ let sf = cx.global::().window; let selected = usize::from(view_mode(cx) == DiffViewMode::Unified); @@ -659,6 +710,9 @@ impl Tty7App { })), ), ) + .context_menu(move |menu, _window, cx| { + Tty7App::document_header_menu(menu, &menu_app, cx) + }) } /// Dispatch the byte read behind an untracked file's preview, at most diff --git a/src/ui/document_column.rs b/src/ui/document_column.rs new file mode 100644 index 00000000..447f7389 --- /dev/null +++ b/src/ui/document_column.rs @@ -0,0 +1,904 @@ +//! The slot the code panel and the diff overlay are drawn in. +//! +//! Both surfaces used to be full-workspace overlays — `absolute`, `inset_0`, +//! `occlude` — so opening a file to read it hid the agent that told you to read +//! it, and reviewing one turned into a toggle loop. They now dock as a column +//! beside the terminal instead, on the same pattern the right panel has always +//! used: a flex sibling with a drag handle and a persisted share of the width. +//! +//! A sibling column, rather than a narrower overlay, is the whole point: the +//! terminal element's laid-out bounds are what drive `set_grid_size`, so a +//! column takes width *away* from the grid and the PTY reflows to what is left. +//! An overlay painted over half the workspace would leave the grid full width +//! with half of it under a card. +//! +//! The overlay is not gone — [`DocumentLayout::Fill`] is exactly the old paint, +//! one command away, and a window too narrow to seat both a terminal and a +//! document falls back to it for that frame without touching what the user +//! saved. + +use gpui::{AnyElement, Context, Window, div, prelude::*, px}; +use gpui_component::menu::{PopupMenu, PopupMenuItem}; +use gpui_component::{ActiveTheme as _, InteractiveElementExt as _, v_flex}; +use std::cell::Cell as StdCell; +use std::rc::Rc; + +use crate::core::config::{ + Config, DOCUMENT_RATIO_MAX, DOCUMENT_RATIO_MIN, DOCUMENT_RATIO_STOPS, DocumentLayout, +}; +use crate::ui::app::{DOCUMENT_MIN_W, OverlayTop, TERMINAL_MIN_W, Tty7App, document_column_px}; +use crate::ui::i18n::{L10nKey, t}; +use crate::ui::right_panel::RESIZE_HANDLE_WIDTH; + +/// Which wrapper a document surface is being asked to paint itself in. +/// +/// The *content* of the code panel and of the diff overlay is the same in all +/// three; only the box around it changes, and with it where the header sits and +/// what its gestures mean. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DocumentChrome { + /// The historical full-workspace overlay. + Fill, + /// A column beside the terminal, carrying its own header as its first row. + /// macOS only: there the title bar lives inside the terminal column, so the + /// document column runs to the top of the window and its header lands in + /// the title strip by itself. + Dock, + /// A column beside the terminal whose header has been lifted into the + /// spanning title bar above it. + /// + /// Windows and Linux put the window controls at the right end of a title + /// bar that spans the workspace, which leaves the strip directly above the + /// document column empty — a title bar's height of nothing, with the file + /// name one row below it. The header goes there instead, and the column + /// renders its body alone. + DockHoisted, +} + +impl DocumentChrome { + /// Whether this is one of the two column layouts. + pub(crate) fn is_dock(self) -> bool { + !matches!(self, DocumentChrome::Fill) + } + + /// Whether the surface draws its own header, or has had it lifted away. + pub(crate) fn renders_own_header(self) -> bool { + !matches!(self, DocumentChrome::DockHoisted) + } + + /// Whether the header is the strip along the top of the window, and so has + /// to behave like a title bar — dragging the window, zooming on a + /// double-click. + /// + /// True for the overlay, whose header stands in for the title bar. True for + /// a hoisted header, which is drawn *into* the title bar. True for a docked + /// column only on macOS, where the column reaches the top of the window + /// anyway. False for a plain docked header, which sits inside the workspace + /// and would be a second, fake title bar if it moved the window. + pub(crate) fn header_is_title_strip(self) -> bool { + match self { + DocumentChrome::Fill | DocumentChrome::DockHoisted => true, + DocumentChrome::Dock => cfg!(target_os = "macos"), + } + } +} + +impl Tty7App { + /// The layout the active tab's document was asked for, which is not always + /// the one it gets — see [`Tty7App::document_dock_px`]. + /// + /// A tab that has never been told follows the config, which holds the last + /// explicit choice anyone made and is therefore what a fresh tab starts + /// from. Telling one tab never moves another. + pub(crate) fn document_layout(&self, cx: &gpui::App) -> DocumentLayout { + self.tabs + .get(self.active) + .and_then(|t| t.document_layout) + .unwrap_or_else(|| cx.global::().document_layout) + } + + /// Which of the two surfaces the docked column shows. + /// + /// `overlay_top` orders a *pair* of overlays in fill mode, where both are + /// painted and the front one wins on paint order. A column has one child, + /// so the ordering has to become a choice: the surface on top, unless it is + /// closed, in which case there is no front and the survivor is it. + pub(crate) fn document_front(&self) -> Option { + let tab = self.tabs.get(self.active)?; + let code = tab.code.as_ref().is_some_and(|c| c.visible); + let diff = tab.diff_overlay.is_some(); + match (tab.overlay_top, code, diff) { + (_, false, false) => None, + (OverlayTop::Code, true, _) | (OverlayTop::Diff, true, false) => Some(OverlayTop::Code), + (OverlayTop::Diff, _, true) | (OverlayTop::Code, false, true) => Some(OverlayTop::Diff), + } + } + + /// What the document column has reserved, from a side panel's point of + /// view. + /// + /// Read from the user's intent and from whether a surface is open — never + /// from the *effective* layout, which is derived from the widths this feeds + /// and would close the loop on itself. + pub(crate) fn document_floor(&self, cx: &gpui::App) -> f32 { + if self.document_layout(cx) == DocumentLayout::Dock && self.document_front().is_some() { + DOCUMENT_MIN_W + } else { + 0. + } + } + + /// The width the terminal and a docked document share: the window less + /// whichever side panels are open, at the widths they are actually drawn + /// at rather than at their floors. A sidebar someone dragged wider is width + /// the terminal no longer has. + pub(crate) fn document_body_px(&self, window: &Window, cx: &gpui::App) -> f32 { + let viewport = window.viewport_size().width.as_f32(); + let sidebar = if self.sidebar_open(cx) { + self.sidebar_px(window, cx) + } else { + 0. + }; + let panel = if self.right_panel_open(cx) { + self.right_panel_px(window, cx) + } else { + 0. + }; + viewport - sidebar - panel + } + + /// How wide the document column is drawn this frame, or `None` when the + /// surface is closed, the user chose fill, or the window is too narrow to + /// seat both. + pub(crate) fn document_dock_px(&self, window: &Window, cx: &gpui::App) -> Option { + if self.document_layout(cx) != DocumentLayout::Dock || self.document_front().is_none() { + return None; + } + document_column_px(self.document_body_px(window, cx), self.document_ratio.get()) + } + + /// Fill ↔ dock, for the active tab. One of the two writers of the layout; + /// the narrow window fallback is not, on purpose — running this while the + /// fallback is showing is the user saying they meant the overlay, and that + /// is worth keeping. + pub(crate) fn toggle_document_fill(&mut self, cx: &mut Context) { + let next = match self.document_layout(cx) { + DocumentLayout::Dock => DocumentLayout::Fill, + DocumentLayout::Fill => DocumentLayout::Dock, + }; + self.set_document_layout(next, cx); + } + + /// Snap the column to a named share of the terminal column. Docks first if + /// the surface is filling the window: asking for a third of the width is + /// asking for a column. + pub(crate) fn set_document_ratio(&mut self, ratio: f32, cx: &mut Context) { + self.document_ratio.set(ratio); + self.update_config(cx, |cfg| cfg.document_ratio = ratio); + self.set_document_layout(DocumentLayout::Dock, cx); + } + + /// Third → half → two thirds → third, the double-click on the divider. + /// Starts from whichever named width the current one is nearest, so a + /// dragged column joins the cycle where it looks like it is. + pub(crate) fn cycle_document_ratio(&mut self, cx: &mut Context) { + let current = self.document_ratio.get(); + let nearest = next_ratio_stop(current); + self.set_document_ratio(nearest, cx); + } + + /// Point the active tab at a layout, and only that tab. + /// + /// Deliberately not written back to `document_layout` in the config: that + /// key is the value a tab starts from, and a tab that has not been told is + /// still reading it. Writing it here would reach every one of those at + /// once, which is the window-wide switch this is not. + /// + /// The narrow-window fallback does not come through here either — it is + /// derived at render time and stored nowhere. + pub(crate) fn set_document_layout(&mut self, layout: DocumentLayout, cx: &mut Context) { + if let Some(tab) = self.tabs.get_mut(self.active) { + tab.document_layout = Some(layout); + } + cx.notify(); + } + + /// What right-clicking a document's header offers: where it sits. + /// + /// On the header rather than in Settings because this is where the question + /// comes up — the moment a file covers the terminal is the moment you want + /// it not to, and a preference three pages into a settings panel is not an + /// answer to that. On the header rather than on a tile beside the close + /// button because the row already carries a file name, a dirty dot and, for + /// a diff, a view toggle; a fifth control in a column's width is one too + /// many for something you set once. + /// + pub(crate) fn document_header_menu( + menu: PopupMenu, + app: &gpui::WeakEntity, + cx: &gpui::App, + ) -> PopupMenu { + let docked = app + .upgrade() + .is_none_or(|this| this.read(cx).document_layout(cx) == DocumentLayout::Dock); + let mut menu = menu.min_w(px(220.)); + + for (label, layout) in [ + (L10nKey::DocumentDock, DocumentLayout::Dock), + (L10nKey::DocumentFill, DocumentLayout::Fill), + ] { + menu = menu.item( + PopupMenuItem::new(t(label)) + .checked(docked == (layout == DocumentLayout::Dock)) + .on_click({ + let app = app.clone(); + move |_, _window, cx| { + let _ = app.update(cx, |this, cx| this.set_document_layout(layout, cx)); + } + }), + ); + } + menu + } + + /// The column itself: the surface `overlay_top` selects, sized, bordered, + /// and given the divider on its left edge. + /// The header on its own, for the strip above the column — see + /// [`DocumentChrome::DockHoisted`]. Returns `None` for the layouts that + /// keep their header inside the surface. + pub(crate) fn render_document_header( + &mut self, + chrome: DocumentChrome, + window: &mut Window, + cx: &mut Context, + ) -> Option { + if chrome.renders_own_header() { + return None; + } + match self.document_front()? { + OverlayTop::Code => Some(self.render_editor_header_only(chrome, window, cx)), + OverlayTop::Diff => self.render_diff_header_only(chrome, window, cx), + } + } + + pub(crate) fn render_document_column( + &mut self, + width: f32, + chrome: DocumentChrome, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let body = self.document_body_px(window, cx); + let surface = match self.document_front()? { + OverlayTop::Code => self.render_code_overlay(chrome, window, cx), + OverlayTop::Diff => self.render_diff_overlay(chrome, window, cx), + }?; + let (backing, handle) = self.document_resize(body, cx); + Some( + v_flex() + .id("document-column") + .relative() + .flex_none() + .w(px(width)) + .h_full() + .bg(crate::ui::theme::workspace_surface_color(cx)) + .border_l_1() + .border_color(cx.theme().sidebar_border) + .child(backing) + // The clip belongs to the content, not to the column: the + // divider hangs half a handle past the left edge, the way the + // panels' do, and clipping the column would have taken that + // half — and the grab with it — away. + .child( + div() + .flex_1() + .min_h_0() + .w_full() + .overflow_hidden() + .child(surface), + ) + .child(handle) + .into_any_element(), + ) + } + + /// The divider, on the same contract as the right panel's: the cell moves + /// on every mouse move, the config is written once, on mouse up. + /// + /// `body` is read here, while there is still a `cx` to read it from — the + /// drag handler only ever sees a `Window`, and a limit that disagreed with + /// the one the layout applies would spring the column back from wherever it + /// was dropped. + fn document_resize(&self, body: f32, cx: &mut Context) -> (AnyElement, AnyElement) { + use gpui::{Bounds, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, canvas}; + + let container: Rc>>> = Rc::new(StdCell::new(None)); + let backing = canvas( + { + let container = container.clone(); + move |bounds, _window, _cx| container.set(Some(bounds)) + }, + { + let container = container.clone(); + let ratio_cell = self.document_ratio.clone(); + let dragging = self.document_dragging.clone(); + move |_bounds, _state, window, _cx| { + window.on_mouse_event({ + let container = container.clone(); + let ratio_cell = ratio_cell.clone(); + let dragging = dragging.clone(); + move |ev: &MouseMoveEvent, _phase, window, _cx| { + if !dragging.get() || body <= 0. { + return; + } + let Some(b) = container.get() else { + return; + }; + let right = b.origin.x + b.size.width; + let raw = (right - ev.position.x).as_f32(); + ratio_cell.set(dragged_ratio(body, raw)); + window.refresh(); + } + }); + window.on_mouse_event({ + let ratio_cell = ratio_cell.clone(); + let dragging = dragging.clone(); + move |_ev: &MouseUpEvent, _phase, window, cx| { + if !dragging.get() { + return; + } + dragging.set(false); + let r = ratio_cell.get(); + let cfg = cx.global_mut::(); + if cfg.document_ratio != r { + cfg.document_ratio = r; + cfg.save(); + } + window.refresh(); + } + }); + } + }, + ) + .absolute() + .size_full() + .into_any_element(); + + let active = self.document_dragging.get(); + let handle = div() + .id("document-resize") + .group("document-resize") + .occlude() + .absolute() + .top_0() + .left(px(-(RESIZE_HANDLE_WIDTH / 2.))) + .w(px(RESIZE_HANDLE_WIDTH)) + .h_full() + .flex() + .items_center() + .justify_center() + .cursor_col_resize() + .child( + div() + .w(px(1.)) + .h_full() + .when(active, |d| d.bg(cx.theme().drag_border)) + .group_hover("document-resize", |s| s.bg(cx.theme().drag_border)), + ) + .on_mouse_down(MouseButton::Left, { + let dragging = self.document_dragging.clone(); + move |_ev, window, _cx| { + dragging.set(true); + window.refresh(); + } + }) + // A double-click lands a mouse-down first, which arms the drag; the + // mouse-up disarms it without having moved, so the cycle below is + // the only thing that ends up happening. + .on_double_click(cx.listener(|this, _, _window, cx| { + this.document_dragging.set(false); + this.cycle_document_ratio(cx); + })) + .into_any_element(); + + (backing, handle) + } +} + +/// The ratio a divider dropped `raw` points from the right edge of a `body` +/// wide area settles on. +/// +/// Two clamps, and both are load-bearing. The pixel one keeps the terminal and +/// the document each above their floor, and is what binds on a narrow window. +/// The ratio one is the band the *file* keeps — `Config::sanitize` holds +/// `document_ratio` to it, so a drag that wrote outside it would be moved on +/// the next launch, and a column dropped against the edge of a wide window +/// would reopen hundreds of points from where it was left. +pub(crate) fn dragged_ratio(body: f32, raw: f32) -> f32 { + let w = raw.clamp(DOCUMENT_MIN_W, (body - TERMINAL_MIN_W).max(DOCUMENT_MIN_W)); + (w / body).clamp(DOCUMENT_RATIO_MIN, DOCUMENT_RATIO_MAX) +} + +/// The width the divider's double-click moves to from `current`: the one after +/// whichever named stop `current` is nearest, wrapping round. +pub(crate) fn next_ratio_stop(current: f32) -> f32 { + let nearest = DOCUMENT_RATIO_STOPS + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| (*a - current).abs().total_cmp(&(*b - current).abs())) + .map(|(i, _)| i) + .unwrap_or(1); + DOCUMENT_RATIO_STOPS[(nearest + 1) % DOCUMENT_RATIO_STOPS.len()] +} + +#[cfg(test)] +mod tests { + use super::{dragged_ratio, next_ratio_stop}; + use crate::core::config::{ + DOCUMENT_RATIO_HALF, DOCUMENT_RATIO_MAX, DOCUMENT_RATIO_MIN, DOCUMENT_RATIO_THIRD, + DOCUMENT_RATIO_TWO_THIRDS, + }; + use crate::ui::app::{DOCUMENT_MIN_W, TERMINAL_MIN_W, document_column_px}; + + /// Nothing the divider can write is something the next launch moves. + /// `Config::sanitize` clamps `document_ratio` into a band, so a drag that + /// left the band was saved and then quietly relocated: on a 2560-point body + /// the narrowest the divider went was a ratio of 0.11, which came back as + /// 0.2 — a 232-point jump the user never asked for. The drag clamps to the + /// file's band as well as to the two floors now, and this is what says so. + #[test] + fn a_dragged_width_is_one_the_config_can_keep() { + for body in [640., 900., 1440., 2560., 5120.] { + for raw in [-500., 0., 1., DOCUMENT_MIN_W, body / 2., body, body + 900.] { + let r = dragged_ratio(body, raw); + assert!( + (DOCUMENT_RATIO_MIN..=DOCUMENT_RATIO_MAX).contains(&r), + "body {body} dropped at {raw} wrote {r}, which the file would not keep" + ); + // And the width that ratio draws still holds the invariant the + // whole budget exists for. + let drawn = document_column_px(body, r).expect("a body that seats both"); + assert!(body - drawn >= TERMINAL_MIN_W - f32::EPSILON); + assert!(drawn >= DOCUMENT_MIN_W - f32::EPSILON); + } + } + } + + /// Third, half, two thirds, round again — and a dragged width joins at + /// whichever stop it looks nearest to rather than always restarting. + #[test] + fn the_divider_cycles_the_named_widths() { + assert_eq!(next_ratio_stop(DOCUMENT_RATIO_THIRD), DOCUMENT_RATIO_HALF); + assert_eq!( + next_ratio_stop(DOCUMENT_RATIO_HALF), + DOCUMENT_RATIO_TWO_THIRDS + ); + assert_eq!( + next_ratio_stop(DOCUMENT_RATIO_TWO_THIRDS), + DOCUMENT_RATIO_THIRD + ); + // Dragged to just under half: nearest stop is half, so the cycle goes + // on to two thirds rather than back to a third. + assert_eq!(next_ratio_stop(0.47), DOCUMENT_RATIO_TWO_THIRDS); + assert_eq!(next_ratio_stop(0.8), DOCUMENT_RATIO_THIRD); + } + + /// The default is half of what the terminal and the document share, and + /// what they share is the window less the panels — not the window. + #[test] + fn half_of_the_body_is_half_of_the_body() { + let body = 1440. - 220. - 260.; + assert_eq!(document_column_px(body, 0.5), Some(body / 2.)); + } + + /// Two thirds is allowed past the half-window cap the side panels obey. + /// Only the terminal's floor binds it. + #[test] + fn two_thirds_is_two_thirds_until_the_terminal_floor_says_otherwise() { + let wide = 1200.; + assert_eq!(document_column_px(wide, 2. / 3.), Some(wide * 2. / 3.)); + + // 800 * 2/3 is 533, which would leave the terminal 267 — under its + // floor — so the column stops where the terminal starts. + let tight = 800.; + assert_eq!( + document_column_px(tight, 2. / 3.), + Some(tight - TERMINAL_MIN_W) + ); + } + + /// A column narrower than it can be read at is not a column. The ratio + /// floors out rather than shrinking with the window. + #[test] + fn a_thin_share_still_gets_the_documents_floor() { + let body = 700.; + assert_eq!(document_column_px(body, 0.2), Some(DOCUMENT_MIN_W)); + } + + /// Below the width where both fit there is no docked layout to draw, and + /// the caller falls back to the overlay for the frame. The threshold is + /// exact so that widening by a point re-docks. + #[test] + fn a_window_too_narrow_for_both_has_no_docked_width() { + let floor = TERMINAL_MIN_W + DOCUMENT_MIN_W; + assert_eq!(document_column_px(floor - 1., 0.5), None); + assert_eq!(document_column_px(floor, 0.5), Some(DOCUMENT_MIN_W)); + assert_eq!(document_column_px(f32::NAN, 0.5), None); + } + + /// Whatever the terminal is left, it is never less than its floor. That is + /// the invariant the whole budget exists for. + #[test] + fn the_terminal_keeps_its_floor_at_every_share() { + for body in [640., 700., 900., 1200., 2400.] { + for ratio in [0.2, 1. / 3., 0.5, 2. / 3., 0.8] { + let Some(doc) = document_column_px(body, ratio) else { + continue; + }; + assert!( + body - doc >= TERMINAL_MIN_W - f32::EPSILON, + "body {body} ratio {ratio} left the terminal {}", + body - doc + ); + assert!(doc >= DOCUMENT_MIN_W - f32::EPSILON); + } + } + } +} + +#[cfg(test)] +mod gpui_tests { + use super::*; + use crate::core::config::{DOCUMENT_RATIO_TWO_THIRDS, DocumentLayout}; + use crate::ui::app::test_window; + use crate::ui::pane::{Pane, PaneSlot}; + use crate::ui::pending_pane::{PendingPane, PendingSpawn}; + use gpui::{Entity, TestAppContext, VisualTestContext, px, size}; + + /// One quiet tab. The pane is a *connecting* one so the harness needs no + /// PTY and runs on every platform. + fn push_tab(app: &mut Tty7App, cx: &mut Context) { + let pending = cx.new(|cx| { + PendingPane::new( + "test-box", + PendingSpawn { + workspace: None, + working_directory: None, + restore_pane: None, + shell: None, + agent: None, + agent_session_id: None, + agent_launch_argv: None, + owner: None, + font_size: 14.0, + }, + cx, + ) + }); + app.tabs + .push(crate::ui::app::Tab::new(Pane::leaf(PaneSlot::Connecting( + pending, + )))); + cx.notify(); + } + + /// A window with `tabs` quiet tabs, active on the first, sized to order. + fn window_with( + cx: &mut TestAppContext, + w: f32, + tabs: usize, + ) -> (Entity, VisualTestContext) { + let (app, mut vcx) = test_window::harness(cx); + app.update_in(&mut vcx, |app, _, cx| { + for _ in 0..tabs { + push_tab(app, cx); + } + app.active = 0; + }); + vcx.simulate_resize(size(px(w), px(900.))); + vcx.run_until_parked(); + (app, vcx) + } + + fn window(cx: &mut TestAppContext, w: f32) -> (Entity, VisualTestContext) { + window_with(cx, w, 1) + } + + fn dock_px(app: &Entity, vcx: &mut VisualTestContext) -> Option { + app.update_in(vcx, |app, window, cx| app.document_dock_px(window, cx)) + } + + /// The config value: the default under tabs that were never told, not + /// necessarily what the active tab is doing. + fn layout(vcx: &mut VisualTestContext) -> DocumentLayout { + vcx.update(|_, cx| cx.global::().document_layout) + } + + /// What the active tab is actually doing. + fn tab_layout(app: &Entity, vcx: &mut VisualTestContext) -> DocumentLayout { + app.update_in(vcx, |app, _, cx| app.document_layout(cx)) + } + + /// Fill is one tab's answer, not the window's. Reading a long file in one + /// tab while an agent works in another wants the whole window here and half + /// of it there, and a global switch made each of those flip the other. + #[gpui::test] + fn one_tabs_fill_leaves_the_other_docked(cx: &mut TestAppContext) { + let (app, mut vcx) = window_with(cx, 1440., 2); + + // A file open in each tab, both docked to start with. + for i in [0, 1] { + app.update_in(&mut vcx, |app, window, cx| { + app.active = i; + app.toggle_code_panel(window, cx); + }); + } + vcx.run_until_parked(); + + app.update_in(&mut vcx, |app, _, cx| { + app.active = 1; + app.toggle_document_fill(cx); + }); + vcx.run_until_parked(); + assert_eq!(dock_px(&app, &mut vcx), None, "the tab that asked, fills"); + + app.update_in(&mut vcx, |app, _, _cx| app.active = 0); + vcx.run_until_parked(); + assert!( + dock_px(&app, &mut vcx).is_some(), + "the tab that did not ask, does not" + ); + + // The config is untouched — it is what every tab that was never told is + // still reading, so writing it would have reached all of them at once. + assert_eq!(layout(&mut vcx), DocumentLayout::Dock); + app.update_in(&mut vcx, |app, window, cx| { + push_tab(app, cx); + app.active = 2; + app.toggle_code_panel(window, cx); + }); + vcx.run_until_parked(); + assert!( + dock_px(&app, &mut vcx).is_some(), + "a fresh tab starts from the config, not from what tab 1 chose" + ); + } + + /// A docked column takes width off the tab strip too, and the strip has to + /// be told. Where the header is hoisted — Windows, Linux — it is drawn over + /// the trailing end of the spanning title bar with no fill of its own, so a + /// chip left under it shows through the file name and stays clickable + /// through it. On macOS the strip sits inside the terminal column, and + /// sizing it to the window rather than to that column is what pushed the + /// New Tab button off the end before the detail panel got the same + /// reservation. + #[gpui::test] + fn the_tab_chips_stop_where_the_document_column_starts(cx: &mut TestAppContext) { + let (app, mut vcx) = window_with(cx, 1200., 10); + // Chips only exist with the bar along the top; with the rail up the + // strip is the sidebar's and has nothing in this row to collide with. + vcx.update(|_, cx| { + cx.global_mut::().tab_bar_position = crate::core::config::TabBarPosition::Top; + }); + app.update_in(&mut vcx, |app, window, cx| { + for (i, tab) in app.tabs.iter_mut().enumerate() { + tab.name = Some(format!("a tab with a long enough name {i}")); + } + app.active = 0; + app.toggle_code_panel(window, cx); + }); + vcx.run_until_parked(); + + let (viewport, panel, docked) = app.update_in(&mut vcx, |app, window, cx| { + ( + window.viewport_size().width.as_f32(), + if app.right_panel_open(cx) { + app.right_panel_px(window, cx) + } else { + 0. + }, + app.document_dock_px(window, cx) + .expect("a 1200 window seats both"), + ) + }); + let column_left = viewport - panel - docked; + let chips = app.update_in(&mut vcx, |app, _, _| app.strip_slots.borrow().clone()); + let far = chips + .iter() + .map(|b| (b.origin.x + b.size.width).as_f32()) + .fold(0., f32::max); + assert!(far > 0., "the strip has to have drawn chips to measure"); + assert!( + far <= column_left + 0.5, + "a chip reached {far}, past the column's left edge at {column_left}" + ); + } + + /// The palette row has to name what running it will do. Fill is per tab, so + /// a tab told to fill must be offered "Dock" — even though the config, which + /// every tab that was never told is still reading, still says dock. Reading + /// the config here offered "Fill" to a tab that was already filling. + #[gpui::test] + fn the_palette_offers_the_active_tabs_layout_not_the_configs(cx: &mut TestAppContext) { + use crate::ui::i18n::L10nKey; + use crate::ui::palette::CommandKind; + + let (app, mut vcx) = window_with(cx, 1440., 2); + crate::ui::i18n::set_locale("en"); + let row = |app: &Entity, vcx: &mut VisualTestContext| { + app.update_in(vcx, |app, window, cx| { + app.palette_commands(window, cx) + .into_iter() + .find(|c| c.kind == CommandKind::ToggleDocumentFill) + .expect("the palette offers the fill toggle") + .title + }) + }; + assert_eq!(row(&app, &mut vcx), t(L10nKey::CmdDocumentFill).to_string()); + + app.update_in(&mut vcx, |app, _, cx| { + app.active = 1; + app.toggle_document_fill(cx); + }); + vcx.run_until_parked(); + assert_eq!( + row(&app, &mut vcx), + t(L10nKey::CmdDocumentDock).to_string(), + "a tab that is filling is offered the way back" + ); + assert_eq!( + layout(&mut vcx), + DocumentLayout::Dock, + "and the config it did not write still reads dock" + ); + + app.update_in(&mut vcx, |app, _, _| app.active = 0); + vcx.run_until_parked(); + assert_eq!( + row(&app, &mut vcx), + t(L10nKey::CmdDocumentFill).to_string(), + "the other tab is untouched, and is still offered fill" + ); + } + + /// The complaint in #625: opening a file must leave the terminal on screen. + /// The column is half of what the terminal and the document share, and the + /// terminal's own laid-out area gives up exactly that width — which is what + /// makes the PTY reflow rather than hide half its columns under a card. + #[gpui::test] + fn opening_the_code_panel_docks_a_column_beside_the_terminal(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx, 1440.); + + app.update_in(&mut vcx, |app, window, cx| { + app.toggle_code_panel(window, cx); + }); + vcx.run_until_parked(); + + let body = app.update_in(&mut vcx, |app, window, cx| app.document_body_px(window, cx)); + let docked = dock_px(&app, &mut vcx).expect("a 1440 window seats both"); + assert!( + (docked - body / 2.).abs() < 0.5, + "half the terminal column, not half the window: {docked} of {body}" + ); + + // The terminal is laid out at what is left, not at the full width with + // a card over half of it. `pane_area` is the rectangle the grid sizes + // itself from, so this is the assertion the PTY reflow rests on. + let pane = app + .update_in(&mut vcx, |app, _, _| app.pane_area.get()) + .expect("the body area painted"); + assert!( + (pane.size.width.as_f32() - (body - docked)).abs() < 1.5, + "terminal laid out at {} of a {body} body beside a {docked} column", + pane.size.width.as_f32() + ); + assert!(pane.size.width.as_f32() >= TERMINAL_MIN_W); + } + + /// Esc — which is what `toggle_code_panel` runs — puts the width back. + #[gpui::test] + fn closing_the_surface_gives_the_width_back(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx, 1440.); + app.update_in(&mut vcx, |app, window, cx| { + app.toggle_code_panel(window, cx); + }); + vcx.run_until_parked(); + assert!(dock_px(&app, &mut vcx).is_some()); + + app.update_in(&mut vcx, |app, window, cx| { + app.toggle_code_panel(window, cx); + }); + vcx.run_until_parked(); + assert_eq!( + app.update_in(&mut vcx, |app, _, _| app.document_front()), + None + ); + assert_eq!(dock_px(&app, &mut vcx), None); + assert_eq!( + app.update_in(&mut vcx, |app, _, cx| app.document_floor(cx)), + 0., + "a closed surface reserves nothing from the panels" + ); + } + + /// A window with no room for both falls back to the overlay for the frame + /// and leaves the saved layout alone. Widening re-docks with no command run + /// in between — the fallback is derived, not stored. + #[gpui::test] + fn a_narrow_window_falls_back_without_saving_it(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx, 560.); + app.update_in(&mut vcx, |app, window, cx| { + app.toggle_code_panel(window, cx); + }); + vcx.run_until_parked(); + + assert_eq!(dock_px(&app, &mut vcx), None, "no room for both"); + assert_eq!( + layout(&mut vcx), + DocumentLayout::Dock, + "the fallback must never write the user's choice" + ); + + vcx.simulate_resize(size(px(1440.), px(900.))); + vcx.run_until_parked(); + assert!( + dock_px(&app, &mut vcx).is_some(), + "widening re-docks on the next frame" + ); + assert_eq!(layout(&mut vcx), DocumentLayout::Dock); + } + + /// Filling is still there, and asking for it *is* a choice worth keeping — + /// including from inside the narrow-window fallback, where the user is + /// looking at an overlay and saying they want it. + #[gpui::test] + fn asking_to_fill_is_kept_and_a_named_width_docks_again(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx, 560.); + app.update_in(&mut vcx, |app, window, cx| { + app.toggle_code_panel(window, cx); + }); + app.update_in(&mut vcx, |app, _, cx| app.toggle_document_fill(cx)); + vcx.run_until_parked(); + assert_eq!(tab_layout(&app, &mut vcx), DocumentLayout::Fill); + + vcx.simulate_resize(size(px(1440.), px(900.))); + vcx.run_until_parked(); + assert_eq!( + dock_px(&app, &mut vcx), + None, + "a wide window does not overrule a chosen fill" + ); + + // Asking for two thirds of the width is asking for a column. + app.update_in(&mut vcx, |app, _, cx| { + app.set_document_ratio(DOCUMENT_RATIO_TWO_THIRDS, cx) + }); + vcx.run_until_parked(); + assert_eq!(tab_layout(&app, &mut vcx), DocumentLayout::Dock); + let body = app.update_in(&mut vcx, |app, window, cx| app.document_body_px(window, cx)); + let docked = dock_px(&app, &mut vcx).expect("docked again"); + // Two thirds, or as near as the terminal's floor allows — with both + // side panels open a 1440 window has 960 to share, and two thirds of + // that would leave the terminal 320. + let want = (body * 2. / 3.).min(body - TERMINAL_MIN_W); + assert!((docked - want).abs() < 0.5, "{docked} of {body}"); + assert!(docked > body / 2., "wider than the half it started at"); + assert_eq!( + vcx.update(|_, cx| cx.global::().document_ratio), + DOCUMENT_RATIO_TWO_THIRDS + ); + + // The header menu writes the layout outright rather than toggling it. + app.update_in(&mut vcx, |app, _, cx| { + app.set_document_layout(DocumentLayout::Fill, cx) + }); + vcx.run_until_parked(); + assert_eq!(tab_layout(&app, &mut vcx), DocumentLayout::Fill); + assert_eq!(dock_px(&app, &mut vcx), None); + assert_eq!( + vcx.update(|_, cx| cx.global::().document_ratio), + DOCUMENT_RATIO_TWO_THIRDS, + "filling does not forget the width to come back to" + ); + } +} diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index ce843c1b..30371ff3 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -554,6 +554,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsDiffPreviewFromCountsDesc => { "Click a row's +N −N to open the working-tree diff in an overlay. Off leaves the counts visible, just not clickable." } + L10nKey::DocumentDock => "Dock beside terminal", + L10nKey::DocumentFill => "Fill window", L10nKey::SettingsNotifications => "Notifications", L10nKey::SettingsNotifyOnCommandFinish => "Notify on command finish", L10nKey::SettingsNotifyOnCommandFinishDesc => { @@ -1412,6 +1414,12 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdResetFontSize => "Reset Font Size", L10nKey::CmdEnterFullScreen => "Enter Full Screen", L10nKey::CmdToggleDiffViewMode => "Toggle Unified / Side-by-Side Diff", + L10nKey::CmdDocumentDock => "Document: Dock Beside Terminal", + L10nKey::CmdDocumentFill => "Document: Fill Window", + L10nKey::CmdToggleDocumentFill => "Toggle Document Fill / Dock", + L10nKey::CmdDocumentWidthThird => "Document: Third Width", + L10nKey::CmdDocumentWidthHalf => "Document: Half Width", + L10nKey::CmdDocumentWidthTwoThirds => "Document: Two-Thirds Width", L10nKey::CmdGitCommit => "Git: Commit", L10nKey::CmdGitStageAll => "Git: Stage All Changes", L10nKey::CmdGitUnstageAll => "Git: Unstage All Changes", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 633e98bd..2bc12f6c 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -563,6 +563,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsDiffPreviewFromCountsDesc => { "行の +N −N をクリックすると、オーバーレイでワーキングツリーの Diff を開きます。オフならカウントは表示されたまま、クリックだけできません" } + L10nKey::DocumentDock => "ターミナルの隣にドック", + L10nKey::DocumentFill => "ウィンドウ全体", L10nKey::SettingsNotifications => "通知", L10nKey::SettingsNotifyOnCommandFinish => "コマンド終了時に通知", L10nKey::SettingsNotifyOnCommandFinishDesc => { @@ -1469,6 +1471,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdResetFontSize => "フォントサイズをリセット", L10nKey::CmdEnterFullScreen => "全画面表示", L10nKey::CmdToggleDiffViewMode => "統合 / 左右分割の差分表示を切り替え", + L10nKey::CmdDocumentDock => "ドキュメント: ターミナルの隣にドック", + L10nKey::CmdDocumentFill => "ドキュメント: ウィンドウ全体", + L10nKey::CmdToggleDocumentFill => "ドキュメントのフィル / ドックを切り替え", + L10nKey::CmdDocumentWidthThird => "ドキュメント: 幅3分の1", + L10nKey::CmdDocumentWidthHalf => "ドキュメント: 幅半分", + L10nKey::CmdDocumentWidthTwoThirds => "ドキュメント: 幅3分の2", L10nKey::CmdGitCommit => "Git: コミット", L10nKey::CmdGitStageAll => "Git: すべての変更をステージ", L10nKey::CmdGitUnstageAll => "Git: すべてのステージを取り消す", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 5f7e060b..e24e1f56 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -464,6 +464,8 @@ l10n_keys! { SettingsSidebarGroupingDesc, SettingsDiffPreviewFromCounts, SettingsDiffPreviewFromCountsDesc, + DocumentDock, + DocumentFill, SettingsNotifications, SettingsWindow, SettingsNotifyOnCommandFinish, @@ -1162,6 +1164,12 @@ l10n_keys! { CmdResetFontSize, CmdEnterFullScreen, CmdToggleDiffViewMode, + CmdDocumentDock, + CmdDocumentFill, + CmdToggleDocumentFill, + CmdDocumentWidthThird, + CmdDocumentWidthHalf, + CmdDocumentWidthTwoThirds, CmdGitCommit, CmdGitStageAll, CmdGitUnstageAll, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 731aefe0..6948668d 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -488,6 +488,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsDiffPreviewFromCountsDesc => { "点击行上的 +N −N 在浮层中打开 worktree diff。关闭后计数仍显示,只是不可点击。" } + L10nKey::DocumentDock => "停靠在终端旁", + L10nKey::DocumentFill => "铺满窗口", L10nKey::SettingsNotifications => "通知", L10nKey::SettingsNotifyOnCommandFinish => "命令完成时通知", L10nKey::SettingsNotifyOnCommandFinishDesc => "较长的前台命令完成后发出桌面提醒。", @@ -1333,6 +1335,12 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdEnterFullScreen => "进入全屏", L10nKey::CmdClearScrollback => "清除回滚内容", L10nKey::CmdToggleDiffViewMode => "切换统一 / 并排差异视图", + L10nKey::CmdDocumentDock => "文档:停靠在终端旁", + L10nKey::CmdDocumentFill => "文档:铺满窗口", + L10nKey::CmdToggleDocumentFill => "切换文档铺满 / 停靠", + L10nKey::CmdDocumentWidthThird => "文档:三分之一宽", + L10nKey::CmdDocumentWidthHalf => "文档:一半宽", + L10nKey::CmdDocumentWidthTwoThirds => "文档:三分之二宽", L10nKey::CmdGitCommit => "Git:提交", L10nKey::CmdGitStageAll => "Git:暂存全部更改", L10nKey::CmdGitUnstageAll => "Git:取消暂存全部更改", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index d5da0c10..67fd2c97 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -454,6 +454,13 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ("ToggleSftp", ""), ("ShowSshForwards", ""), ("ToggleCodePanel", "secondary-shift-e"), + // Deliberately unbound. Docking is the default and Esc already gets the + // terminal back, so a default chord here would only be one more thing + // competing for a two-key combination nobody asked for. + ("ToggleDocumentFill", ""), + ("DocumentWidthThird", ""), + ("DocumentWidthHalf", ""), + ("DocumentWidthTwoThirds", ""), // Implemented, dispatchable, and until now unbindable: `set_binding` // only fills slots that exist here, so `"ShowRightPanelInfo": "ctrl-1"` // in config.json was dropped without a word, and the Keybindings page — @@ -639,6 +646,22 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { t(L10nKey::AppMenuRightPanel).to_string(), ), "ToggleCodePanel" => (CommandGroup::View, t(L10nKey::AppMenuCodePanel).to_string()), + "ToggleDocumentFill" => ( + CommandGroup::View, + t(L10nKey::CmdToggleDocumentFill).to_string(), + ), + "DocumentWidthThird" => ( + CommandGroup::View, + t(L10nKey::CmdDocumentWidthThird).to_string(), + ), + "DocumentWidthHalf" => ( + CommandGroup::View, + t(L10nKey::CmdDocumentWidthHalf).to_string(), + ), + "DocumentWidthTwoThirds" => ( + CommandGroup::View, + t(L10nKey::CmdDocumentWidthTwoThirds).to_string(), + ), "ShowRightPanelInfo" => ( CommandGroup::View, t(L10nKey::CmdRightPanelInfo).to_string(), @@ -1110,6 +1133,10 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "ToggleSftp" => KeyBinding::new(keystroke, ToggleSftp, None), "ShowSshForwards" => KeyBinding::new(keystroke, ShowSshForwards, None), "ToggleCodePanel" => KeyBinding::new(keystroke, ToggleCodePanel, None), + "ToggleDocumentFill" => KeyBinding::new(keystroke, ToggleDocumentFill, None), + "DocumentWidthThird" => KeyBinding::new(keystroke, DocumentWidthThird, None), + "DocumentWidthHalf" => KeyBinding::new(keystroke, DocumentWidthHalf, None), + "DocumentWidthTwoThirds" => KeyBinding::new(keystroke, DocumentWidthTwoThirds, None), "EditorSave" => KeyBinding::new(keystroke, EditorSave, None), "OpenSshProfiles" => KeyBinding::new(keystroke, OpenSshProfiles, None), "RestartSshSession" => KeyBinding::new(keystroke, RestartSshSession, None), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index df5799f4..31f360c8 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,6 +3,7 @@ pub mod assets; pub mod code_editor; pub mod diff_overlay; pub mod diff_rows; +pub mod document_column; pub mod file_copy; pub mod file_tree; pub mod forwards; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 84c7de8b..ead7ba16 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -75,6 +75,10 @@ pub enum CommandKind { ToggleSftp, ShowSshForwards, ToggleCodePanel, + ToggleDocumentFill, + DocumentWidthThird, + DocumentWidthHalf, + DocumentWidthTwoThirds, RestartSshSession, ScmCommit, ScmStageAll, @@ -182,6 +186,10 @@ impl CommandKind { ToggleSftp => "ssh-remote-files", ShowSshForwards => "ssh-port-forwarding", ToggleCodePanel => "code-panel", + ToggleDocumentFill => "document-fill", + DocumentWidthThird => "document-width-third", + DocumentWidthHalf => "document-width-half", + DocumentWidthTwoThirds => "document-width-two-thirds", RestartSshSession => "ssh-reconnect", ScmCommit => "git-commit", ScmStageAll => "git-stage-all", @@ -281,6 +289,10 @@ impl CommandKind { ToggleSftp => "ToggleSftp", ShowSshForwards => "ShowSshForwards", ToggleCodePanel => "ToggleCodePanel", + ToggleDocumentFill => "ToggleDocumentFill", + DocumentWidthThird => "DocumentWidthThird", + DocumentWidthHalf => "DocumentWidthHalf", + DocumentWidthTwoThirds => "DocumentWidthTwoThirds", RestartSshSession => "RestartSshSession", OpenSshProfiles => "OpenSshProfiles", ScmCommit => "ScmCommit", @@ -359,6 +371,11 @@ impl CommandGroup { pub struct ChromeState { pub rail_collapsed: bool, pub right_panel_visible: bool, + /// Whether the *active tab's* document is filling the window. Passed in + /// rather than read off the config here: `document_layout` in the config is + /// only what a tab that has never been told starts from, so a tab that was + /// told would have had the row offer it the state it is already in. + pub document_filled: bool, } #[derive(Clone)] @@ -410,6 +427,7 @@ impl Command { let tab_bar_left = cfg.tab_bar_position == TabBarPosition::Left; let sidebar_hidden = chrome.rail_collapsed || !tab_bar_left; let right_panel_open = chrome.right_panel_visible; + let document_filled = chrome.document_filled; let tabs = [ Command::localized(L10nKey::CmdNewTab, NewTab), @@ -473,6 +491,17 @@ impl Command { ToggleRightPanel, ), Command::localized(L10nKey::CmdShowCodePanel, ToggleCodePanel), + Command::localized( + if document_filled { + L10nKey::CmdDocumentDock + } else { + L10nKey::CmdDocumentFill + }, + ToggleDocumentFill, + ), + Command::localized(L10nKey::CmdDocumentWidthThird, DocumentWidthThird), + Command::localized(L10nKey::CmdDocumentWidthHalf, DocumentWidthHalf), + Command::localized(L10nKey::CmdDocumentWidthTwoThirds, DocumentWidthTwoThirds), Command::localized( if tab_bar_left { L10nKey::CmdTabBarMoveToTop @@ -1543,6 +1572,7 @@ mod gpui_tests { let chrome = ChromeState { rail_collapsed: false, right_panel_visible: false, + document_filled: false, }; let mut seen = std::collections::HashSet::new(); for cmd in Command::base_commands(cx, chrome) { @@ -1567,6 +1597,7 @@ mod gpui_tests { let chrome = ChromeState { rail_collapsed: false, right_panel_visible: false, + document_filled: false, }; let cmds = Command::base_commands(cx, chrome); let git = cmds.iter().filter(|c| c.group == CommandGroup::Git).count(); diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 2ae44ca1..ddb0a0b6 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -306,7 +306,7 @@ impl Tty7App { crate::ui::app::side_panel_max( window.viewport_size().width.as_f32(), MIN_WIDTH, - self.sidebar_floor(cx), + self.sidebar_floor(cx) + self.document_floor(cx), ) } @@ -406,7 +406,7 @@ impl Tty7App { // below only ever sees a `Window`, and the cap it clamps against has to // be the same one the layout applies or the panel springs back from // wherever it was dropped. - let sidebar_floor = self.sidebar_floor(cx); + let others_floor = self.sidebar_floor(cx) + self.document_floor(cx); let backing = canvas( { let container = container.clone(); @@ -433,7 +433,7 @@ impl Tty7App { let max = crate::ui::app::side_panel_max( window.viewport_size().width.as_f32(), MIN_WIDTH, - sidebar_floor, + others_floor, ); width_cell.set(raw.clamp(MIN_WIDTH, max)); window.refresh(); diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 449523ff..728d4407 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -117,10 +117,20 @@ impl Tty7App { crate::ui::app::side_panel_max( window.viewport_size().width.as_f32(), MIN_SIDEBAR_WIDTH, - self.right_panel_floor(cx), + self.right_panel_floor(cx) + self.document_floor(cx), ) } + /// How wide the sidebar is drawn, given the live cell and the cap the rest + /// of the window leaves it. Read here rather than clamped at each caller so + /// the document column's budget and the sidebar itself can never disagree + /// about how much width is already spoken for. + pub(crate) fn sidebar_px(&self, window: &Window, cx: &gpui::App) -> f32 { + self.sidebar_width + .get() + .clamp(MIN_SIDEBAR_WIDTH, self.sidebar_max_px(window, cx)) + } + pub(crate) fn tab_sidebar( &self, window: &mut Window, @@ -129,8 +139,7 @@ impl Tty7App { let active = self.active; let sf = cx.global::().sidebar; let show_badges = self.mod_hint_badges; - let max_width = self.sidebar_max_px(window, cx); - let width = self.sidebar_width.get().clamp(MIN_SIDEBAR_WIDTH, max_width); + let width = self.sidebar_px(window, cx); let query = self.sidebar_search.read(cx).value().trim().to_lowercase(); // Blanked here, written again from paint: a row filtered out by the // search — or hidden with its collapsed group — must leave no rectangle @@ -1040,7 +1049,7 @@ impl Tty7App { // below only ever sees a `Window`, and the cap it clamps against has to // be the same one the layout applies or the sidebar springs back from // wherever it was dropped. - let panel_floor = self.right_panel_floor(cx); + let others_floor = self.right_panel_floor(cx) + self.document_floor(cx); let backing = canvas( { let container = container.clone(); @@ -1066,7 +1075,7 @@ impl Tty7App { let max = crate::ui::app::side_panel_max( window.viewport_size().width.as_f32(), MIN_SIDEBAR_WIDTH, - panel_floor, + others_floor, ); width_cell.set(raw.clamp(MIN_SIDEBAR_WIDTH, max)); window.refresh(); diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 93fdfa33..3ce16a34 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -488,6 +488,27 @@ pub(crate) fn chrome_tile(button: Button, selected: bool, cx: &gpui::App) -> But chrome_tile_sized(button, TILE_SIZE, TILE_GLYPH, selected, cx) } +/// How wide the two chrome tiles at the trailing end of the title bar are, with +/// the padding around them. +pub(crate) fn trailing_chrome_tiles_w() -> f32 { + let trailing_pad = if cfg!(target_os = "macos") { + tile_trailing_inset() + } else { + 4. + }; + trailing_pad + crate::ui::app::TILE_SIZE + 2. + crate::ui::app::TILE_SIZE +} + +/// The whole trailing cluster: those tiles and the OS window buttons beyond +/// them. +/// +/// Anything else drawn into that end of the title bar has to stop short of it — +/// which for the hoisted document header means the case where the detail panel +/// is closed and the document column runs to the window's right edge. +pub(crate) fn trailing_chrome_w() -> f32 { + trailing_chrome_tiles_w() + crate::ui::app::WINDOW_CONTROLS_W +} + pub(crate) fn chrome_tile_sized( button: Button, tile: f32, @@ -1517,8 +1538,18 @@ impl Tty7App { true => self.right_panel_px(window, cx), false => 0., }; + // A docked document column has the same claim on this strip the detail + // panel does, and it is answered in the same two ways. On macOS the + // strip lives *inside* the terminal column, so the column's width comes + // off `strip_w` the way the panel's does — sizing the strip to more + // than it gets is what pushed the New Tab button out before. + // Everywhere else the strip spans the workspace and the column's header + // is drawn over its trailing end, so the width is reserved as a corner + // instead: that header carries no fill of its own, and a chip left + // under it showed through the file name while staying clickable. + let document_w = self.document_dock_px(window, cx).unwrap_or(0.); let strip_w = if cfg!(target_os = "macos") { - (window.viewport_size().width - px(80. + panel_w)).max(px(160.)) + (window.viewport_size().width - px(80. + panel_w + document_w)).max(px(160.)) } else { (window.viewport_size().width - px(114.)).max(px(140.)) }; @@ -1532,14 +1563,13 @@ impl Tty7App { let corner_w = if panel_w > 0. { 0. } else { - chrome_band_w.unwrap_or_else(|| { - let trailing_pad = if cfg!(target_os = "macos") { - tile_trailing_inset() - } else { - 4. - }; - trailing_pad + crate::ui::app::TILE_SIZE + 2. + crate::ui::app::TILE_SIZE - }) + chrome_band_w.unwrap_or_else(trailing_chrome_tiles_w) + } + if cfg!(target_os = "macos") { + // Already taken out of `strip_w` above; charging it here too would + // narrow the chips by a column's width twice over. + 0. + } else { + document_w }; let fixed_w = 3. * CHIP_GAP + crate::ui::app::TILE_SIZE + corner_w; let chips_avail = (strip_w - px(fixed_w + GRAB_HANDLE_W)).max(px(80.)); From 010457132fed8407278313ba027a73efcca6b436 Mon Sep 17 00:00:00 2001 From: webdev <86946125+biztex@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:56:09 +0700 Subject: [PATCH 20/33] fix(terminal): shape a regional-indicator pair as the one flag it is (#686) (#691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flag such as 🇨🇳 is two Regional Indicator symbols, U+1F1E8 U+1F1F3. Each is width 1 to unicode-width, so the grid gives each its own column and no spacer: the pair already sits in exactly the two columns a flag occupies. But `segment_row` sent each one to `Solo`, and a `Solo` is its own `shape_line` call. The shaper never saw the two together, so it had no chance to form the flag ligature, and each half came out as the letter-in-a-box glyph an emoji face draws for a lone indicator. A `Solo` also clips to two cells so a fallback face's advance has room, and that box is two cells wide, so each half spilled into the next column as well. Join a Regional Indicator and the one after it into a single two-cell `Cluster`, the move a7835a0 made for SARA AM: two width-1 codepoints that own a column each but are not atomic to the shaper. The check sits ahead of the marks branch so a stray mark on either half (a VS16 on the first, say) rides along in the cluster text instead of splitting the pair — split, the other half paints alone as a box again. `wide_base` stays false. With the ligature there is one glyph at position zero and the pinning is moot. Without it — a font that lacks the flag — the shaper returns two glyphs, and `force_width = cell_width` pins the second into the second column, where it stays visible — the two legible halves such a setup shows today. `wide_base: true` would pin it at `2 × cell_width`, past the cluster's two-cell clip, and swallow half the pair. The edges fall out of the scan. An indicator in the last column has no partner on its row and stays `Solo`; two halves of a flag on different rows cannot be joined, and drawing them apart is the honest answer. Three in a row pair greedily left to right, which is UAX #29's rule for them. A style change between the halves keeps the cluster under the first cell's style, as for SARA AM: a recoloured flag beats two boxes. Selection, copy, cursor placement and reflow read the alacritty grid, not `RowSeg`, and are untouched. A grid-level test feeds 🇨🇳x through the emulator, `snapshot_cell` and `segment_row`, pinning the premise that each indicator lands in one plain column. A unicode-width or alacritty bump that changes that fails there rather than misdrawing quietly. Out of scope: skin-tone modifiers and ZWJ sequences. Those are a grid-width problem — alacritty reserves four or six columns for them — and nothing here touches them. --- src/terminal/element.rs | 120 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/src/terminal/element.rs b/src/terminal/element.rs index da6ac6ce..a89c0396 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -661,6 +661,15 @@ fn sara_am_at(row: &[RenderCell], col: usize) -> Option<&RenderCell> { .filter(|cell| !cell.spacer && is_sara_am(cell.c)) } +fn is_regional_indicator(c: char) -> bool { + matches!(c, '\u{1F1E6}'..='\u{1F1FF}') +} + +fn regional_indicator_at(row: &[RenderCell], col: usize) -> Option<&RenderCell> { + row.get(col) + .filter(|cell| !cell.spacer && is_regional_indicator(cell.c)) +} + fn segment_row(row: &[RenderCell]) -> Vec { let mut segs = Vec::new(); let mut col = 0; @@ -693,6 +702,23 @@ fn segment_row(row: &[RenderCell]) -> Vec { }); continue; } + // Ahead of the marks branch: a stray mark on either half must not + // split the pair, or the other half paints as a lettered box. + if is_regional_indicator(cell.c) + && let Some(next) = regional_indicator_at(row, col + 1) + { + let mut text = String::with_capacity(8); + push_cell(&mut text, cell); + push_cell(&mut text, next); + segs.push(RowSeg::Cluster { + col, + cells: 2, + text, + wide_base: false, + }); + col += 2; + continue; + } if let Some(marks) = &cell.marks { let wide_base = col + 1 < row.len() && row[col + 1].spacer; let mut cells = if wide_base { 2 } else { 1 }; @@ -2707,6 +2733,100 @@ mod tests { ); } + #[test] + fn segment_row_joins_a_regional_indicator_pair() { + let row = vec![cell('\u{1F1E8}'), cell('\u{1F1F3}')]; + assert_eq!(segment_row(&row), [cluster(0, 2, "\u{1F1E8}\u{1F1F3}")]); + + let row = vec![cell('a'), cell('\u{1F1E8}'), cell('\u{1F1F3}'), cell('b')]; + assert_eq!( + segment_row(&row), + [ + run(0, 1, "a"), + cluster(1, 2, "\u{1F1E8}\u{1F1F3}"), + run(3, 1, "b"), + ] + ); + + let row = vec![ + cell('\u{1F1E8}'), + cell('\u{1F1F3}'), + cell('\u{1F1FA}'), + cell('\u{1F1F8}'), + ]; + assert_eq!( + segment_row(&row), + [ + cluster(0, 2, "\u{1F1E8}\u{1F1F3}"), + cluster(2, 2, "\u{1F1FA}\u{1F1F8}"), + ] + ); + + let mut row = vec![cell('\u{1F1E8}'), cell('\u{1F1F3}')]; + row[1].fg = gpui::red(); + assert_eq!(segment_row(&row), [cluster(0, 2, "\u{1F1E8}\u{1F1F3}")]); + + let mut row = vec![cell('\u{1F1E8}'), cell('\u{1F1F3}')]; + row[0].marks = Some(Box::from(['\u{FE0F}'])); + assert_eq!( + segment_row(&row), + [cluster(0, 2, "\u{1F1E8}\u{FE0F}\u{1F1F3}")] + ); + + let mut row = vec![cell('\u{1F1E8}'), cell('\u{1F1F3}')]; + row[1].marks = Some(Box::from(['\u{FE0F}'])); + assert_eq!( + segment_row(&row), + [cluster(0, 2, "\u{1F1E8}\u{1F1F3}\u{FE0F}")] + ); + } + + #[test] + fn segment_row_leaves_an_unpaired_regional_indicator_alone() { + let row = vec![cell('\u{1F1E8}')]; + assert_eq!(segment_row(&row), [RowSeg::Solo { col: 0 }]); + + let row = vec![cell('\u{1F1E8}'), cell('\u{1F1F3}'), cell('\u{1F1FA}')]; + assert_eq!( + segment_row(&row), + [cluster(0, 2, "\u{1F1E8}\u{1F1F3}"), RowSeg::Solo { col: 2 }] + ); + + let row = vec![cell('\u{1F1E8}'), cell('a')]; + assert_eq!(segment_row(&row), [RowSeg::Solo { col: 0 }, run(1, 1, "a")]); + + let row = vec![cell('\u{1F1E8}'), cell(' '), cell('\u{1F1F3}')]; + assert_eq!( + segment_row(&row), + [RowSeg::Solo { col: 0 }, RowSeg::Solo { col: 2 }] + ); + } + + #[test] + fn a_regional_indicator_pair_reaches_segment_row_as_two_plain_columns() { + let mut term = alacritty_terminal::Term::new( + alacritty_terminal::term::Config::default(), + &crate::terminal::size::TermSize::new(80, 24), + alacritty_terminal::event::VoidListener, + ); + let mut parser: alacritty_terminal::vte::ansi::Processor = + alacritty_terminal::vte::ansi::Processor::new(); + parser.advance(&mut term, "\u{1F1E8}\u{1F1F3}x".as_bytes()); + + let palette = [Rgb { r: 0, g: 0, b: 0 }; 256]; + let colors = test_colors(); + let row: Vec<_> = (0..4) + .map(|col| { + let point = AlacPoint::new(AlacLine(0), AlacColumn(col)); + snapshot_cell(&term.grid()[point], point, &palette, &colors, None) + }) + .collect(); + assert_eq!( + segment_row(&row), + [cluster(0, 2, "\u{1F1E8}\u{1F1F3}"), run(2, 1, "x")] + ); + } + #[test] fn segment_row_never_batches_a_marked_cell() { let mut row: Vec<_> = "abc".chars().map(cell).collect(); From edfea5b830ac3747f44a22529715bd9289d0cd43 Mon Sep 17 00:00:00 2001 From: webdev <86946125+biztex@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:30:42 +0700 Subject: [PATCH 21/33] ci(macos): assert every Mach-O in the bundle is the arch it ships as (#687) (#692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A macOS 26 user opened the Apple Silicon build and was told it "contains Intel parts" (#687). Downloading what is actually published — v26.8.2, v26.8.3 and the nightly after #605 — and reading every file's Mach-O header says otherwise: the three binaries under Contents/MacOS are thin arm64, nothing else in the bundle is Mach-O at all, and tty7-app's load commands are all /System/Library/Frameworks and /usr/lib. The build is right today. The likeliest reading of the warning is macOS pinning an x86_64 program someone ran in a pane on tty7.app as the responsible process — the same attribution bundle-macos.sh already documents for TCC — and that belongs on the issue, not in this change. What does belong here is that nothing would have caught it if the report had been right. assert-macho.sh knows how to say "this is a 64-bit Mach-O for , it links only what macOS ships, and it is signed", and since #605 it has said it — about the standalone tty7-server asset, and only that. It has never been pointed at anything inside the .app. A helper built without --target on an Intel runner, a dylib dragged in from /opt/homebrew, a universal binary from a toolchain that decided to be helpful: each would have zipped, notarized and shipped, and the first check would have been a user's Finder. So check the bundle, in bundle-macos.sh, where release.yml and nightly.yml both build it. After the signing block — assert-macho.sh insists on a signature, and this way one pass covers Developer ID and adhoc alike — and before the update zip and the DMG, so a bundle that fails never becomes an artifact, and before the `mv` that dissolves dist/tty7.app. First the binaries the script staged itself: tty7-app, tty7 and, when it is packaged, tty7-updater, each through assert-macho.sh at the full standard the server asset is held to. That also leaves every shipped binary's load commands in the release log, which is where the next report of this kind gets answered from. Then a sweep of every file in the bundle: `file` says which are Mach-O of any kind, `lipo -archs` names the slices in each, and the answer has to be exactly the matrix arch. Any other name is the wrong build; two names is a universal binary, which is what the report described. lipo judges rather than a parse of `file`'s prose because Apple's `file` and upstream libmagic word the arch differently and lipo's slice names do not move. A sweep that finds fewer Mach-Os than the binaries staged above fails as well, so a changed wording cannot quietly turn it into a no-op. On a Developer ID build this runs after notarization, which spends a few minutes of notary time on a bundle that was never going to ship. Cheap next to carrying a second copy of the block inside each signing branch. Deliberately not a fix for what the reporter saw, if it is the child-process attribution: no check at build time can speak for a binary the user runs inside a pane. What it guarantees is narrower and worth having — the bundle named arm64 contains nothing but arm64, and a release where that stops being true fails on the runner. Validated with bash -n and shellcheck, and by running the sweep — and the whole script in its adhoc posture — on Linux against fake bundles with file, lipo, otool, codesign, ditto and hdiutil stubbed: a clean bundle passes and packages; a wrong-arch updater, a universal tty7-app, a stray x86_64 dylib, an arm64e nested bundle and an empty bundle each fail and name the file, and nothing is zipped after a failure. Not yet run on a Mac; the next nightly is what answers that. --- .github/scripts/bundle-macos.sh | 86 +++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/.github/scripts/bundle-macos.sh b/.github/scripts/bundle-macos.sh index 632a3c3a..f488a70c 100755 --- a/.github/scripts/bundle-macos.sh +++ b/.github/scripts/bundle-macos.sh @@ -9,6 +9,10 @@ # -> hardened-runtime signature, then notarize + staple. Passes Gatekeeper. # * Otherwise -> adhoc signature, same as before. Fine for local dev, but the # OS will quarantine it on other machines. +# +# Before either artifact is packaged, every Mach-O inside the bundle is +# asserted to be a thin binary (the sweep below), so a wrong-arch +# or universal file fails the build here instead of shipping. set -euo pipefail TARGET="$1" @@ -199,6 +203,88 @@ else codesign --force --deep --sign - "$APP" fi +# ---- Architecture sweep ---------------------------------------------------- +# Everything the bundle ships has to be the thin slice its filename claims. A +# macOS 26 user read "contains Intel parts" off the Apple Silicon bundle (#687). +# The published bundles turned out clean — every Mach-O in them thin arm64 — +# but nothing here had ever checked: assert-macho.sh only ever pointed at the +# standalone tty7-server asset, so a helper built without --target, a dylib +# dragged in from the runner, or a universal binary would have shipped, and been +# found by a user rather than by this script. +# +# After the signing block, because assert-macho.sh also insists on a code +# signature, and after both postures so one pass covers Developer ID and adhoc +# alike. Before the zip and the DMG, so a bundle that fails here never becomes +# an artifact — and before the `mv` below, after which dist/tty7.app no longer +# exists. For a Developer ID build that puts it after notarization, which +# spends a few minutes of notary time on a bundle that was never going to ship; +# cheap next to carrying a second copy of this block inside each branch. +BUNDLE_FAIL=0 +ASSERT_MACHO="$(dirname "$0")/assert-macho.sh" +BUNDLED_BINS=(tty7-app tty7) +if [[ "$PACKAGE_UPDATE_ZIP" != "0" ]]; then + BUNDLED_BINS+=(tty7-updater) +fi +# First the binaries we staged ourselves, held to the full standard the server +# asset is: the right arch, links nothing macOS does not ship, carries a +# signature. This also leaves every shipped binary's load commands in the +# release log, which is where the next report like #687 gets answered from. +for bin in "${BUNDLED_BINS[@]}"; do + bash "$ASSERT_MACHO" "$APP/Contents/MacOS/$bin" "$ARCH" || BUNDLE_FAIL=1 +done + +# Then the whole bundle, for whatever that list did not know to look at: walk +# every file, let `file` say which are Mach-O of any kind — executable, dylib, +# bundle — and have `lipo` name the slices in each. The answer has to be +# exactly "$ARCH". Any other name is the wrong build; two names is a universal +# binary, which is what the report described and what nothing in this pipeline +# should ever produce. +# +# `file` detects and `lipo -archs` judges, rather than reading the arch out of +# `file`'s prose: Apple's build says "64-bit executable arm64" where upstream +# libmagic says "64-bit arm64 executable, flags:<...>", and a parser written +# against one misreads the other. lipo's slice names are the same on every +# macOS, and it is the tool that would have made a fat binary in the first +# place. Captured into variables, never piped into `grep -q` — see +# assert-macho.sh for the pipefail race. Process substitution rather than +# `find | while`, so the counters survive the loop. +echo "--- Mach-O sweep of $APP, expecting ${ARCH} ---" +SWEEP_SEEN=0 +while IFS= read -r -d '' f; do + KIND="$(file -b "$f")" + [[ "$KIND" == *"Mach-O"* ]] || continue + SWEEP_SEEN=$((SWEEP_SEEN + 1)) + # Multi-line for a universal file (one line per slice); the first line is + # the verdict. + KIND="${KIND%%$'\n'*}" + if ! ARCHS="$(lipo -archs "$f" 2>&1)"; then + echo "::error::lipo could not read $f ($KIND): $ARCHS" + BUNDLE_FAIL=1 + continue + fi + case "$ARCHS" in + "$ARCH") + echo "${ARCHS} $f ($KIND)" ;; + *" "*) + echo "::error::$f is a universal binary carrying [${ARCHS}]; this bundle ships ${ARCH} only" + BUNDLE_FAIL=1 ;; + *) + echo "::error::$f is ${ARCHS}, not ${ARCH} ($KIND)" + BUNDLE_FAIL=1 ;; + esac +done < <(find "$APP" -type f -print0) +# A sweep that sees fewer Mach-Os than the binaries copied in above is not +# looking at the bundle — a changed `file` wording, an empty find — and must not +# pass as "nothing wrong found". +if (( SWEEP_SEEN < ${#BUNDLED_BINS[@]} )); then + echo "::error::the sweep found ${SWEEP_SEEN} Mach-O file(s) in $APP, fewer than the ${#BUNDLED_BINS[@]} staged above — it is not seeing the bundle" + BUNDLE_FAIL=1 +fi +if [[ "$BUNDLE_FAIL" -ne 0 ]]; then + exit 1 +fi +echo "✅ every Mach-O in $APP is a thin ${ARCH} binary (${SWEEP_SEEN} checked)" + # The in-app updater needs the signed, notarized .app itself rather than a disk # image that requires Finder interaction. The helper re-reads the full embedded # version out of the staged bundle and refuses anything that is not the release From e82a460794c8b622928848a1f5c51103acf0adad Mon Sep 17 00:00:00 2001 From: Wh1te Date: Thu, 20 Aug 2026 09:47:26 +0800 Subject: [PATCH 22/33] fix(windows): normalize path separators before reveal and copy (#680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(windows): normalize path separators before reveal and copy Open-folder (reveal_path) and copy-to-clipboard hand raw paths to gpui. On Windows, mixed-separator paths (a forward-slash prefix joined with backslash entries) reach reveal_path through two routes: - the shell's PWD — OSC 7 from Git Bash / MSYS bash reports `/`, and that string survives `Path::ancestors()` when the file tree walks up to find `.git`, so the file-tree root keeps the forward slashes while `read_dir` entries underneath it come back native (backslash); - `git rev-parse --show-toplevel` from Git for Windows (MSYS2), which always prints `/` regardless of the calling shell. The SCM panel's `scm_repo_root` and the worktree creation in tty7-core both use it, so the root they hand downstream is `/`-prefixed and joins against backslash-joined entries to form `D:/code/tty7\skills`. Windows' IShellFolder::ParseDisplayName rejects that with E_INVALIDARG (0x80070057); reveal_path swallows the error (it only logs), so "open folder" silently does nothing. The same mixed-separator paths also make copy-to-clipboard produce strings the user has to retype before a shell will accept them. Add a native_separators helper in path_display and apply it to every reveal_path call (file tree, scm panel, right-panel info cwd, sftp downloads) and to every path copied to the clipboard. * fix(windows): rewrite separators losslessly, and only for local paths Review follow-ups on the reveal/copy separator fix. `native_separators` went through `to_string_lossy`, so any path holding an unpaired surrogate — legal in an NTFS name, not representable in a Rust `str` — came back with `U+FFFD` in place of it, naming a different file. Since `reveal_path` only logs its failures, that reads to the user as the same silent no-op the fix is here to remove. It now maps over the path's own UTF-16 code units and rebuilds with `OsString::from_wide`; `/` and `\` are ASCII, so a unit equal to either is that character and never half of a surrogate pair. Still `Cow::Borrowed` when there is no `/` to rewrite. The three clipboard sites re-spelled remote paths too. File-tree "Copy path" and the SCM panel's sat outside the locality guard their Reveal neighbours sit behind, and the Info panel's cwd copied `effective_cwd` while its Reveal checked `local_cwd` — so a Windows window onto a remote Linux host copied `/home/u/src` as `\home\u\src`, which names nothing on either machine. Each now shares one locality check with its Reveal. Both "Copy working directory" entry points were missed entirely: the app-menu action and the tab context menu each spelled the path their own way. They now share `tab_cwd_text`, which applies the same rule. Adds a Windows test that a lone surrogate survives the rewrite. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- src/ui/app.rs | 33 ++++++---- src/ui/file_tree.rs | 15 ++++- src/ui/path_display.rs | 141 +++++++++++++++++++++++++++++++++++++++++ src/ui/right_panel.rs | 21 ++++-- src/ui/scm/panel.rs | 18 ++++-- src/ui/sftp.rs | 3 +- src/ui/tab_strip.rs | 8 +-- 7 files changed, 207 insertions(+), 32 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 3f7c4fe2..c9695197 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -4294,22 +4294,29 @@ impl Tty7App { cx.notify(); } - pub(crate) fn tab_cwd( - &self, - index: usize, - window: &Window, - cx: &App, - ) -> Option { - self.tabs - .get(index)? - .pane - .focused_or_first(window, cx) - .and_then(|leaf| leaf.read(cx).cwd()) + /// A tab's working directory, spelled for the clipboard. + /// + /// A cwd on this machine is re-spelled with this OS's separators, because + /// what goes on the clipboard is meant to be pasted into a shell and a + /// mixed-separator path is not one Windows will take. A remote pane's cwd + /// keeps the spelling its own machine uses — it is already native over + /// there. Both "Copy working directory" entry points (the app-menu action + /// and the tab context menu) come through here so the two cannot drift. + pub(crate) fn tab_cwd_text(&self, index: usize, window: &Window, cx: &App) -> Option { + let leaf = self.tabs.get(index)?.pane.focused_or_first(window, cx)?; + let view = leaf.read(cx); + let cwd = view.cwd()?; + Some(match view.local_cwd().is_some() { + true => crate::ui::path_display::native_separators(&cwd) + .display() + .to_string(), + false => cwd.display().to_string(), + }) } pub(crate) fn copy_active_cwd(&mut self, window: &Window, cx: &mut Context) { - if let Some(cwd) = self.tab_cwd(self.active, window, cx) { - cx.write_to_clipboard(gpui::ClipboardItem::new_string(cwd.display().to_string())); + if let Some(text) = self.tab_cwd_text(self.active, window, cx) { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text)); } } diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 6a00248c..823fae2f 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -2050,9 +2050,18 @@ impl Tty7App { menu = menu.separator().item( PopupMenuItem::new(t(L10nKey::FileTreeContextCopyPath)).on_click({ - let p = p.clone(); + // Only a path on this machine gets re-spelled: a remote + // host's paths are already native over there, and giving + // them this OS's separators would copy something that names + // nothing on either machine. + let text = match paths_are_local { + true => crate::ui::path_display::native_separators(&p) + .display() + .to_string(), + false => p.display().to_string(), + }; move |_, _window, cx| { - cx.write_to_clipboard(gpui::ClipboardItem::new_string(p.display().to_string())); + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.clone())); } }), ); @@ -2061,7 +2070,7 @@ impl Tty7App { PopupMenuItem::new(crate::ui::right_panel::reveal_label()).on_click({ let p = p.clone(); move |_, _window, cx| { - cx.reveal_path(&p); + cx.reveal_path(&crate::ui::path_display::native_separators(&p)); } }), ); diff --git a/src/ui/path_display.rs b/src/ui/path_display.rs index 6581822e..51a319c8 100644 --- a/src/ui/path_display.rs +++ b/src/ui/path_display.rs @@ -69,6 +69,58 @@ fn normalized(s: &str) -> String { .to_ascii_lowercase() } +/// Re-spells a path on **this** machine with the separators this OS expects. +/// +/// On Windows the shell's `IShellFolder::ParseDisplayName` bails out with +/// `E_INVALIDARG` on a mixed-separator path — a forward-slash prefix joined +/// with backslash entries. The forward slashes get in from two routes: the +/// shell's PWD (OSC 7 from Git Bash / MSYS bash reports `/`, and that string +/// survives `Path::ancestors()` when the file tree walks up to find `.git`), +/// and `git rev-parse --show-toplevel` from Git for Windows (MSYS2), which +/// always prints `/` regardless of the calling shell. `reveal_path` swallows +/// that failure (it only logs), so handing it native separators is what makes +/// "open folder" actually open. +/// +/// **Only for paths on the machine this window runs on.** A remote host's +/// `/home/u/src` is already native over there; re-spelling it would put a +/// path on the clipboard that names nothing on either machine. Every caller +/// sits behind a locality check for that reason. +/// +/// The rewrite runs on the path's own UTF-16 code units, not on a +/// `to_string_lossy` copy of them. A Windows filename may hold unpaired +/// surrogates, which `to_string_lossy` turns into `U+FFFD` — the returned +/// path would then silently name a *different* file, and reveal would open +/// nothing without reporting why. `/` and `\` are ASCII, so a code unit +/// equal to one of them is that character and never half of a surrogate +/// pair, which is what makes the swap safe to do one unit at a time. +#[cfg(windows)] +pub(crate) fn native_separators(path: &Path) -> Cow<'_, Path> { + use std::ffi::OsString; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + + const SLASH: u16 = b'/' as u16; + const BACKSLASH: u16 = b'\\' as u16; + + let os = path.as_os_str(); + // Nothing to fix — including every UNC (`\\wsl$\…`, `\\?\…`) and + // already-native path — hands the caller's own path straight back. + if !os.encode_wide().any(|unit| unit == SLASH) { + return Cow::Borrowed(path); + } + let wide: Vec = os + .encode_wide() + .map(|unit| if unit == SLASH { BACKSLASH } else { unit }) + .collect(); + Cow::Owned(PathBuf::from(OsString::from_wide(&wide))) +} + +/// Off Windows the OS separator is already `/`, and a backslash in a path is +/// an ordinary filename character — there is nothing to re-spell. +#[cfg(not(windows))] +pub(crate) fn native_separators(path: &Path) -> Cow<'_, Path> { + Cow::Borrowed(path) +} + /// Shortens `path` to start from `~` when it is (inside) `home` — the home /// directory of the machine `path` is on, not of this one. /// @@ -181,4 +233,93 @@ mod tests { assert_eq!(abbreviate_under("/home/日本/work", "/home/日本"), "~/work"); assert_eq!(abbreviate_under("/home/xa/日本語", "/home/xa"), "~/日本語"); } + + #[test] + fn native_separators_passes_through_a_path_with_nothing_to_fix() { + // No forward slashes → nothing to rewrite, and no allocation: the + // borrowed path is the caller's, handed back untouched. + assert!(matches!( + native_separators(Path::new("README.md")), + Cow::Borrowed(_) + )); + } + + #[cfg(windows)] + #[test] + fn native_separators_rewrites_forward_slashes_to_backslashes_on_windows() { + // The bug: a repo root from `git rev-parse` (forward slashes) joined + // with backslash-joined entries yields a mixed path, which + // `ParseDisplayName` rejects. Every `/` must become `\`. + assert_eq!( + native_separators(Path::new("D:/code/tty7\\skills")), + Path::new("D:\\code\\tty7\\skills") + ); + assert_eq!( + native_separators(Path::new("D:/code/tty7")), + Path::new("D:\\code\\tty7") + ); + } + + #[cfg(windows)] + #[test] + fn native_separators_leaves_unc_and_backslash_paths_alone() { + // A UNC path (`\\wsl$\…`, `\\?\…`) or an already-native path has no + // `/`, so it passes through borrowed — the replace is a no-op and + // must not allocate, nor touch the leading `\\`. + for p in [ + "\\\\wsl$\\Ubuntu\\home", + "\\\\?\\C:\\code", + "C:\\code\\tty7", + ] { + let got = native_separators(Path::new(p)); + assert_eq!(got.as_ref(), Path::new(p), "{p:?}"); + assert!(matches!(got, Cow::Borrowed(_)), "{p:?} should not allocate"); + } + } + + #[cfg(windows)] + #[test] + fn native_separators_keeps_a_name_a_string_cannot_hold() { + use std::ffi::OsString; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + + // `0xD800` is a lone high surrogate — legal in an NTFS name, and not + // representable in a Rust `str`. Rewriting through + // `to_string_lossy` would swap it for `U+FFFD` and hand back a path + // naming a *different* file; because `reveal_path` only logs its + // failures, that reads to the user as the same silent no-op this fix + // is here to remove. Working on the UTF-16 units keeps the name. + let raw: Vec = "C:/a" + .encode_utf16() + .chain([0xD800]) + .chain("/b".encode_utf16()) + .collect(); + let path = PathBuf::from(OsString::from_wide(&raw)); + let want: Vec = "C:\\a" + .encode_utf16() + .chain([0xD800]) + .chain("\\b".encode_utf16()) + .collect(); + assert_eq!( + native_separators(&path) + .as_os_str() + .encode_wide() + .collect::>(), + want + ); + // The round-trip this replaced really did destroy it. + assert!(path.to_string_lossy().contains('\u{FFFD}')); + } + + #[cfg(not(windows))] + #[test] + fn native_separators_is_a_no_op_off_windows() { + // On Unix the OS separator is `/`; a path that happens to contain + // backslashes is legitimate and must be left alone. + for p in ["/home/u/tty7", "C:\\Users\\dev", "mixed/path\\here"] { + let got = native_separators(Path::new(p)); + assert_eq!(got.as_ref(), Path::new(p), "{p:?}"); + assert!(matches!(got, Cow::Borrowed(_))); + } + } } diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index ddb0a0b6..01a8aaba 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -681,15 +681,24 @@ impl Tty7App { ); if let Some(cwd) = view.effective_cwd() { let home = view.display_home(cx); + // Whether this pane's paths are this machine's decides + // both tiles: reveal only means anything on the machine + // the file manager can see, and only a local path may be + // re-spelled with this OS's separators — a remote one is + // already native where it lives. + let local = view.local_cwd().is_some(); rows.push(InfoRow { label: t(L10nKey::PanelCwd), value: InfoValue::Path(compact_path(&cwd, home.as_deref())), // The compacted `~/…` spelling is for reading; what // goes on the clipboard is the path a shell can use. - copy: Some(cwd.display().to_string()), - // Reveal only means anything when the path is on the - // machine the file manager can see. - reveal: view.local_cwd().is_some().then(|| cwd.clone()), + copy: Some(match local { + true => crate::ui::path_display::native_separators(&cwd) + .display() + .to_string(), + false => cwd.display().to_string(), + }), + reveal: local.then(|| cwd.clone()), }); } let shell = match view.shell_spec().map(|s| s.program.clone()) { @@ -970,7 +979,9 @@ impl Tty7App { reveal_label(), cx, ) - .on_click(move |_, _window, cx| cx.reveal_path(&cwd)), + .on_click(move |_, _window, cx| { + cx.reveal_path(&crate::ui::path_display::native_separators(&cwd)) + }), ); } if let Some(text) = row.copy { diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index 900a0438..fe13fd5f 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -1665,11 +1665,17 @@ impl Tty7App { .separator() .item( PopupMenuItem::new(t(L10nKey::FileTreeContextCopyPath)).on_click({ - let absolute = absolute.clone(); + // Same locality rule as Reveal below: a remote + // repository's paths belong to its own filesystem and + // are left spelled the way that machine spells them. + let text = match repo.host == HostId::LOCAL { + true => crate::ui::path_display::native_separators(&absolute) + .display() + .to_string(), + false => absolute.display().to_string(), + }; move |_, _window, cx| { - cx.write_to_clipboard(gpui::ClipboardItem::new_string( - absolute.display().to_string(), - )); + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.clone())); } }), ); @@ -1680,7 +1686,9 @@ impl Tty7App { menu = menu.item( PopupMenuItem::new(crate::ui::right_panel::reveal_label()).on_click({ let absolute = absolute.clone(); - move |_, _window, cx| cx.reveal_path(&absolute) + move |_, _window, cx| { + cx.reveal_path(&crate::ui::path_display::native_separators(&absolute)) + } }), ); } diff --git a/src/ui/sftp.rs b/src/ui/sftp.rs index 7f013199..d91351fc 100644 --- a/src/ui/sftp.rs +++ b/src/ui/sftp.rs @@ -1047,7 +1047,8 @@ impl Tty7App { } pub(crate) fn sftp_reveal_download(&self, local: String, cx: &mut Context) { - cx.reveal_path(Path::new(&local)); + let local = Path::new(&local); + cx.reveal_path(&crate::ui::path_display::native_separators(local)); } fn sftp_poll_jobs(&mut self, cx: &mut Context) { diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 3ce16a34..eb4f5198 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -1342,7 +1342,7 @@ impl Tty7App { }; let this = entity.read(cx); let tab_count = this.tabs.len(); - let cwd = this.tab_cwd(index, window, cx); + let cwd = this.tab_cwd_text(index, window, cx); let has_cwd = cwd.is_some(); let mut menu = menu.min_w(px(200.)); @@ -1460,10 +1460,8 @@ impl Tty7App { .action(Box::new(CopyWorkingDirectory)) .disabled(!has_cwd) .on_click(move |_, _window, cx| { - if let Some(cwd) = cwd.as_ref() { - cx.write_to_clipboard(gpui::ClipboardItem::new_string( - cwd.display().to_string(), - )); + if let Some(text) = cwd.as_ref() { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.clone())); } }), ); From 2cdc26f3572fd4cc73e2a2918ca5d3ca25a869d6 Mon Sep 17 00:00:00 2001 From: Austin Spraggins Date: Wed, 19 Aug 2026 18:51:05 -0700 Subject: [PATCH 23/33] Wire hooks, resume and detection for Kimi Code CLI (#694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agents): wire hooks, resume and detection for Kimi Code Kimi Code CLI takes its hooks as [[hooks]] entries in the same config.toml that holds the user's providers and models, so this adds a third install strategy — a format-preserving TOML merge on toml_edit — beside the JSON map merge and the owned files. Like Qwen it reports permission requests first-class, so it gets no Notification hook. Resume rides `kimi --session `; fork stays unwired, Kimi documents none. Closes #693 Signed-off-by: Austin Spraggins * fix(agents): harden the Kimi Code TOML hook merge and its resume flags The TOML merge strategy the Kimi wiring introduces round-trips a shared config.toml cleanly, but three gaps sat behind it. `hooks_state` counted only the marked entries that still named an event, so a hand-edit that dropped the key off one of nine entries left the remaining eight matching the roster exactly and the file reported Installed with a broken entry in it. Every marked entry now counts, which is what the JSON merge already did and what `refresh_hooks` needs to see. A `hooks = []` spelled as an empty inline array made install fail outright -- toml_edit keeps an empty array and an array of tables apart, but the two say the same thing and neither carries any configuration. It is now promoted rather than refused. Every other wrong-shaped `hooks` key -- a string, a table, a non-empty inline array -- still refuses with the file left byte-for-byte alone. `Stop` is not the only way a Kimi turn ends: its own event reference says `Stop` does not fire on interrupts and `Interrupt` fires instead, and a turn that dies on an error reports `StopFailure`. Without those two an Esc or a failed turn left the pane on "working" for good and `tty7 wait` could only ever time out. Both are observation-only events and report the same end of turn `Stop` does. On resume, `--agent` and `--agent-file` join the stale flags: Kimi rejects either next to `--session` at startup, and resuming rebinds the session agent by itself, so replaying them turned a working resume into a launch error. Tests cover the wrong-shaped `hooks` keys, a config.toml that does not parse on both install and uninstall, a file that does not exist yet, a second install being byte-for-byte the first, mangled and surplus marked entries, an uninstall threading between the user's own entries and the tables after them, and the `--session=`, bare `--session`, `--continue` and `--agent` spellings on the resume path. --------- Signed-off-by: Austin Spraggins Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- Cargo.lock | 2 + README.md | 2 +- README.zh-CN.md | 2 +- assets/icons/agents/kimi.svg | 6 + crates/tty7-core/Cargo.toml | 8 + crates/tty7-core/src/core/agent_hooks.rs | 502 ++++++++++++++++++++++- crates/tty7-core/src/core/cli_agent.rs | 83 +++- docs/agents/overview.mdx | 7 +- docs/agents/sessions.mdx | 4 +- docs/agents/status.mdx | 4 +- docs/getting-started/first-launch.mdx | 2 +- docs/index.mdx | 2 +- src/ui/assets.rs | 1 + src/ui/i18n/en.rs | 4 + src/ui/i18n/ja.rs | 4 + src/ui/i18n/mod.rs | 3 + src/ui/i18n/zh.rs | 4 + src/ui/settings.rs | 5 + 18 files changed, 630 insertions(+), 15 deletions(-) create mode 100644 assets/icons/agents/kimi.svg diff --git a/Cargo.lock b/Cargo.lock index 5d280d30..f03e4c82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9250,6 +9250,7 @@ dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.4", ] @@ -9852,6 +9853,7 @@ dependencies = [ "system-configuration-sys", "tempfile", "tokio", + "toml_edit 0.25.13+spec-1.1.0", "ureq", "uuid", "windows-sys 0.59.0", diff --git a/README.md b/README.md index 0ea6a2a3..0ed27f98 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com |---|---| | **Editor-grade input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · ⌃ R fuzzy history | | **Window** | tabs & splits · ⌘ P palette · ⌘ F scrollback search · nine themes · IME | -| **Agent-aware** | per-pane detection (18 CLIs): status dot · notifications · branch + diff · resume after reboot · tray icon when input is needed | +| **Agent-aware** | per-pane detection (19 CLIs): status dot · notifications · branch + diff · resume after reboot · tray icon when input is needed | | **Remote workspaces** | remote files, repos, changes, diffs, worktrees, tabs, and panes · reconnect from any client and continue where you left off | | **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · pane/workspace control · real PTY commands · output, process, port, and agent status | | **SSH** | native russh stack: profiles with keychain secrets · SFTP panel · port forwarding · jump hosts · one-time, unprivileged `tty7-server` install | diff --git a/README.zh-CN.md b/README.zh-CN.md index bb075519..46fb6354 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -50,7 +50,7 @@ |---|---| | **编辑器级输入** | 历史影子建议 · 带说明的 Tab 补全 · 语法高亮 · 多行编辑 · 点击定位光标 · ⌃ R 模糊历史搜索 | | **窗口** | 标签页与分屏 · ⌘ P 命令面板 · ⌘ F 回滚搜索 · 9 套主题 · 输入法 | -| **Agent-aware** | 按 pane 识别 18 个 CLI agent:状态点 · 通知 · 分支 + diff · 重启后续上会话 · 托盘图标提醒需要输入 | +| **Agent-aware** | 按 pane 识别 19 个 CLI agent:状态点 · 通知 · 分支 + diff · 重启后续上会话 · 托盘图标提醒需要输入 | | **远程工作区** | 远端文件、仓库、Changes、diff、worktree、标签页和 pane · 任意客户端重连后原地继续 | | **CLI + Skills** | 安装包自带 `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · pane/工作区控制 · 真实 PTY 命令 · 输出、进程、端口和 agent 状态 | | **SSH** | 原生 russh 栈:profile 凭据进 keychain · SFTP 面板 · 端口转发 · 跳板机 · 一次无 sudo 安装 `tty7-server` | diff --git a/assets/icons/agents/kimi.svg b/assets/icons/agents/kimi.svg new file mode 100644 index 00000000..577d2f56 --- /dev/null +++ b/assets/icons/agents/kimi.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/crates/tty7-core/Cargo.toml b/crates/tty7-core/Cargo.toml index d58d6dc7..9c12b7d5 100644 --- a/crates/tty7-core/Cargo.toml +++ b/crates/tty7-core/Cargo.toml @@ -48,6 +48,14 @@ sha2 = "0.11" # implementation. ignore = "0.4" +# Format-preserving TOML editing for `core::agent_hooks`: Kimi Code takes its +# hooks as `[[hooks]]` entries in the same `config.toml` that holds the user's +# providers, models and comments, so installing must edit that file in place +# without reformatting it — which rules out the plain `toml` crate's +# parse-and-reserialize round trip. Already in the tree transitively, so this +# pins no new code. +toml_edit = "0.25" + # Lane assignment for the commit graph (`core::git::log`) keeps a couple of # parents and a handful of edges per row; a SmallVec keeps those off the heap # for the shapes that make up almost all of a real history. Already in the tree diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 545fe1c2..6ceeea62 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -223,10 +223,11 @@ pub enum HookAgent { Droid, Qwen, Goose, + Kimi, } impl HookAgent { - pub const ALL: [HookAgent; 11] = [ + pub const ALL: [HookAgent; 12] = [ HookAgent::Claude, HookAgent::Codex, HookAgent::Copilot, @@ -238,6 +239,7 @@ impl HookAgent { HookAgent::Droid, HookAgent::Qwen, HookAgent::Goose, + HookAgent::Kimi, ]; /// The hooks behind a detected agent process, if it has any. @@ -258,6 +260,7 @@ impl HookAgent { CLIAgent::Droid => Some(HookAgent::Droid), CLIAgent::Qwen => Some(HookAgent::Qwen), CLIAgent::Goose => Some(HookAgent::Goose), + CLIAgent::Kimi => Some(HookAgent::Kimi), CLIAgent::Aider | CLIAgent::Amp | CLIAgent::Cursor @@ -283,7 +286,18 @@ impl HookAgent { | HookAgent::Pi | HookAgent::Grok | HookAgent::OhMyPi - | HookAgent::Goose => None, + | HookAgent::Goose + | HookAgent::Kimi => None, + } + } + + /// The events this agent's hooks merge into a shared TOML config, if that + /// is how it takes them — the third strategy, for the agents whose hooks + /// live as `[[hooks]]` entries in a config file the user also hand-edits. + fn toml_hook_events(self) -> Option<&'static [(&'static str, &'static str)]> { + match self { + HookAgent::Kimi => Some(KIMI_HOOK_EVENTS), + _ => None, } } @@ -300,6 +314,7 @@ impl HookAgent { HookAgent::Droid => "droid", HookAgent::Qwen => "qwen", HookAgent::Goose => "goose", + HookAgent::Kimi => "kimi", } } @@ -316,6 +331,7 @@ impl HookAgent { HookAgent::Droid => "Droid", HookAgent::Qwen => "Qwen Code", HookAgent::Goose => "Goose", + HookAgent::Kimi => "Kimi Code", } } @@ -346,6 +362,7 @@ impl HookAgent { HookAgent::Goose => { target.under_home(&[".agents", "plugins", "tty7", "hooks", "hooks.json"]) } + HookAgent::Kimi => target.kimi_config_path(), } } @@ -429,6 +446,15 @@ impl<'a> HookTarget<'a> { self.under_home(&[".config"]) } + fn kimi_config_path(&self) -> PathBuf { + if self.is_local() + && let Some(dir) = std::env::var_os("KIMI_CODE_HOME").filter(|d| !d.is_empty()) + { + return PathBuf::from(dir).join("config.toml"); + } + self.under_home(&[".kimi-code", "config.toml"]) + } + fn hook_command(&self, agent: HookAgent, event: &str) -> String { if let Some(exe) = self.hook_command_exe() { return format!("{exe} agent-hook {} {event}", agent.slug()); @@ -498,6 +524,9 @@ pub enum HooksState { pub fn hooks_state(target: &HookTarget, agent: HookAgent) -> HooksState { let path = agent.target_path(target); + if let Some(events) = agent.toml_hook_events() { + return toml_hooks_state(target, &path, agent, events); + } if let Some(events) = agent.hook_map_events() { return hook_map_state(target, &path, agent, events); } @@ -531,6 +560,10 @@ pub enum HookOutcome { pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result { let path = agent.target_path(target); + if let Some(events) = agent.toml_hook_events() { + toml_hooks_install(target, &path, agent, events)?; + return Ok(HookOutcome::Installed); + } if let Some(events) = agent.hook_map_events() { hook_map_install(target, &path, agent, events)?; if agent != HookAgent::Codex { @@ -552,6 +585,9 @@ pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result anyhow::Result { let path = agent.target_path(target); + if agent.toml_hook_events().is_some() { + return toml_hooks_uninstall(target, &path, agent); + } match agent.hook_map_events() { Some(_) => hook_map_uninstall(target, &path, agent), None => owned_file_uninstall(target, &path, &agent.marker()), @@ -663,6 +699,30 @@ const QWEN_HOOK_EVENTS: &[(&str, &str)] = &[ ("SessionEnd", "session-end"), ]; +/// Kimi Code's hooks live as `[[hooks]]` entries in its main `config.toml` — +/// the same file that holds the user's providers and models — so they go +/// through the TOML merge strategy rather than a JSON map or an owned file. +/// Like Qwen it has a first-class permission event, so it needs no +/// `Notification` hook and none of the sniffing in [`effective_event`]. +/// +/// `Stop` alone does not cover every way a turn ends here: Kimi's own event +/// reference says `Stop` "does not fire on interrupts, so this event fires +/// instead" of `Interrupt`, and a turn that dies on an error reports +/// `StopFailure`. Without those two an Esc or a failed turn would +/// leave the pane on "working" forever and `tty7 wait` would only ever time +/// out, so both report the same end-of-turn as `Stop` does. All three are +/// observation-only events, and a doubled `stop` is idempotent. +const KIMI_HOOK_EVENTS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt-submit"), + ("PermissionRequest", "permission-request"), + ("PostToolUse", "tool-complete"), + ("Stop", "stop"), + ("Interrupt", "stop"), + ("StopFailure", "stop"), + ("SessionEnd", "session-end"), +]; + const GROK_HOOK_TIMEOUT_SECS: u32 = 10; const GROK_HOOK_EVENTS: &[(&str, &str, Option<&str>)] = &[ @@ -816,6 +876,145 @@ fn marker_command<'a>(matcher: &'a serde_json::Value, marker: &str) -> Option<&' }) } +fn toml_hooks_state( + target: &HookTarget, + path: &Path, + agent: HookAgent, + events: &[(&str, &str)], +) -> HooksState { + let Ok(text) = target.read(path) else { + return HooksState::NotInstalled; + }; + let Ok(doc) = text.parse::() else { + return HooksState::NotInstalled; + }; + let marker = agent.marker(); + let marked: Vec<&toml_edit::Table> = doc + .get("hooks") + .and_then(|h| h.as_array_of_tables()) + .into_iter() + .flatten() + .filter(|entry| toml_command_is_marked(entry, &marker)) + .collect(); + if marked.is_empty() { + return HooksState::NotInstalled; + } + // Every marked entry counts towards the total, `event` or no `event`: a + // hand-edit that drops the key leaves an entry that is ours and is broken, + // which is exactly what Outdated means. Reporting NotInstalled instead + // would hide it from `refresh_hooks`, which only ever revisits Outdated. + let complete = marked.len() == events.len() + && events.iter().all(|(hook_event, tty7_event)| { + let command = target.hook_command(agent, tty7_event); + marked.iter().any(|entry| { + entry.get("event").and_then(|e| e.as_str()) == Some(*hook_event) + && entry.get("command").and_then(|c| c.as_str()) == Some(command.as_str()) + }) + }); + if complete { + HooksState::Installed + } else { + HooksState::Outdated + } +} + +fn toml_hooks_install( + target: &HookTarget, + path: &Path, + agent: HookAgent, + events: &[(&str, &str)], +) -> anyhow::Result<()> { + let mut doc: toml_edit::DocumentMut = match target.read(path) { + Ok(text) => text.parse().map_err(|e| { + anyhow::anyhow!( + "{} is not valid TOML ({e}); not touching it", + path.display() + ) + })?, + Err(e) if e.kind() == io::ErrorKind::NotFound => toml_edit::DocumentMut::new(), + Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())), + }; + + // `hooks = []` and no `hooks` key at all say the same thing, but toml_edit + // keeps an empty inline array and an array of tables apart. Promote the + // one to the other rather than refusing over a difference that carries no + // configuration and that the user cannot see. + if doc + .get("hooks") + .and_then(|h| h.as_array()) + .is_some_and(|a| a.is_empty()) + { + doc.remove("hooks"); + } + + let hooks = doc.entry("hooks").or_insert(toml_edit::Item::ArrayOfTables( + toml_edit::ArrayOfTables::new(), + )); + let Some(list) = hooks.as_array_of_tables_mut() else { + return Err(anyhow::anyhow!( + "\"hooks\" in {} is not an array of tables; not touching it", + path.display() + )); + }; + + let marker = agent.marker(); + list.retain(|entry| !toml_command_is_marked(entry, &marker)); + for (hook_event, tty7_event) in events { + let mut entry = toml_edit::Table::new(); + entry["event"] = toml_edit::value(*hook_event); + entry["command"] = toml_edit::value(target.hook_command(agent, tty7_event)); + list.push(entry); + } + + target.write(path, doc.to_string().as_bytes()) +} + +fn toml_hooks_uninstall( + target: &HookTarget, + path: &Path, + agent: HookAgent, +) -> anyhow::Result { + let text = match target.read(path) { + Ok(text) => text, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + return Ok(HookOutcome::NothingInstalled); + } + Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())), + }; + let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e| { + anyhow::anyhow!( + "{} is not valid TOML ({e}); not touching it", + path.display() + ) + })?; + + let marker = agent.marker(); + let mut removed = 0; + if let Some(list) = doc + .get_mut("hooks") + .and_then(|h| h.as_array_of_tables_mut()) + { + let before = list.len(); + list.retain(|entry| !toml_command_is_marked(entry, &marker)); + removed = before - list.len(); + if list.is_empty() { + doc.remove("hooks"); + } + } + if removed == 0 { + return Ok(HookOutcome::NoTty7Hooks); + } + target.write(path, doc.to_string().as_bytes())?; + Ok(HookOutcome::Removed) +} + +fn toml_command_is_marked(entry: &toml_edit::Table, marker: &str) -> bool { + entry + .get("command") + .and_then(|c| c.as_str()) + .is_some_and(|c| c.contains(marker)) +} + /// Returns the bare file name of `exe` when resolving that name from PATH /// yields the same binary. Returns `None` when the name does not resolve, or /// when an earlier PATH entry contains a different file with the same name @@ -877,7 +1076,8 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { | HookAgent::Codex | HookAgent::Gemini | HookAgent::Droid - | HookAgent::Qwen => None, + | HookAgent::Qwen + | HookAgent::Kimi => None, } } @@ -1236,6 +1436,7 @@ mod tests { .chain(DROID_HOOK_EVENTS) .chain(QWEN_HOOK_EVENTS) .chain(GOOSE_HOOK_EVENTS) + .chain(KIMI_HOOK_EVENTS) .map(|(_, e)| *e) .chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e)) .collect(); @@ -1268,6 +1469,7 @@ mod tests { HookAgent::Goose, "/home/me/.agents/plugins/tty7/hooks/hooks.json", ), + (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), ] { assert_eq!( agent.target_path(&t), @@ -1287,6 +1489,7 @@ mod tests { HookAgent::Droid, HookAgent::Qwen, HookAgent::Goose, + HookAgent::Kimi, ] { assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled); install_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug())); @@ -1564,6 +1767,7 @@ mod tests { HookAgent::OhMyPi, "/home/me/.omp/agent/extensions/tty7/index.ts", ), + (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), ] { assert_eq!( agent.target_path(&target), @@ -1878,4 +2082,296 @@ mod tests { unsafe { std::env::remove_var("CLAUDE_CONFIG_DIR") }; let _ = std::fs::remove_dir_all(&dir); } + + /// Kimi's hooks share `config.toml` with the user's providers and models, + /// so the merge must leave everything that is not ours — including + /// comments and formatting — byte-for-byte alone. + #[test] + fn kimi_install_preserves_the_user_s_config_toml() { + let dir = std::env::temp_dir().join(format!("tty7-kimi-hooks-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let config = dir.join("config.toml"); + let user_half = concat!( + "# my providers\n", + "default_model = \"kimi-k2\"\n", + "\n", + "[[hooks]]\n", + "event = \"Stop\"\n", + "command = \"afplay ding.aiff\"\n", + ); + std::fs::write(&config, user_half).unwrap(); + unsafe { std::env::set_var("KIMI_CODE_HOME", &dir) }; + + let host = local_host(); + let t = HookTarget::local(&*host).expect("home resolves in tests"); + assert_eq!(HookAgent::Kimi.target_path(&t), config); + + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::NotInstalled); + install_hooks(&t, HookAgent::Kimi).expect("install succeeds"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed); + install_hooks(&t, HookAgent::Kimi).expect("re-install succeeds"); + + let written = std::fs::read_to_string(&config).unwrap(); + assert!( + written.starts_with(user_half), + "the user's half of config.toml — comment included — survives untouched" + ); + let doc: toml_edit::DocumentMut = written.parse().expect("still valid TOML"); + let hooks = doc["hooks"].as_array_of_tables().unwrap(); + assert_eq!( + hooks + .iter() + .filter(|e| toml_command_is_marked(e, "agent-hook kimi")) + .count(), + KIMI_HOOK_EVENTS.len(), + "exactly one tty7 entry per event after two installs" + ); + for (event, _) in KIMI_HOOK_EVENTS { + assert!( + hooks.iter().any(|e| { + toml_command_is_marked(e, "agent-hook kimi") + && e.get("event").and_then(|v| v.as_str()) == Some(*event) + }), + "{event} carries the tty7 hook" + ); + } + + let healthy = std::fs::read_to_string(&config).unwrap(); + std::fs::write( + &config, + healthy.replace("agent-hook kimi stop", "agent-hook kimi stop --stale"), + ) + .unwrap(); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Outdated); + install_hooks(&t, HookAgent::Kimi).expect("reinstall over an outdated entry succeeds"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed); + + uninstall_hooks(&t, HookAgent::Kimi).expect("uninstall succeeds"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::NotInstalled); + let after = std::fs::read_to_string(&config).unwrap(); + assert!( + after.contains("afplay ding.aiff"), + "the user's own Stop hook survives uninstall" + ); + assert!(!after.contains("agent-hook kimi")); + uninstall_hooks(&t, HookAgent::Kimi).expect("uninstall is idempotent"); + + std::fs::write(&config, "not = valid = toml").unwrap(); + assert!( + install_hooks(&t, HookAgent::Kimi).is_err(), + "a config.toml that does not parse is left alone" + ); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + "not = valid = toml", + "and is not rewritten on the way out" + ); + assert!( + uninstall_hooks(&t, HookAgent::Kimi).is_err(), + "uninstall refuses the same file" + ); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + "not = valid = toml" + ); + + unsafe { std::env::remove_var("KIMI_CODE_HOME") }; + let _ = std::fs::remove_dir_all(&dir); + } + + /// A `config.toml` that parses but spells `hooks` as something other than + /// an array of tables is a file we do not understand. Every one of these + /// must come back as a refusal with the file untouched — the one thing + /// that must never happen to the file holding the user's API keys is a + /// silent rewrite. + #[test] + fn kimi_refuses_a_hooks_key_of_the_wrong_toml_type() { + let host = FakeRemote::shared(); + let base = std::env::temp_dir().join(format!("tty7-kimi-shapes-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + + for (name, text) in [ + ("a_string", "hooks = \"nope\"\n"), + ("a_table", "[hooks]\nfoo = 1\n"), + ( + "an_inline_array", + "hooks = [{ event = \"Stop\", command = \"afplay a.aiff\" }]\n", + ), + ] { + let home = base.join(name); + let t = HookTarget::remote(&*host, home.clone()); + let config = HookAgent::Kimi.target_path(&t); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + std::fs::write(&config, text).unwrap(); + + assert_eq!( + hooks_state(&t, HookAgent::Kimi), + HooksState::NotInstalled, + "{name}: nothing of ours is in there" + ); + assert!( + install_hooks(&t, HookAgent::Kimi).is_err(), + "{name}: install refuses" + ); + assert_eq!( + uninstall_hooks(&t, HookAgent::Kimi).unwrap(), + HookOutcome::NoTty7Hooks, + "{name}: uninstall finds nothing of ours" + ); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + text, + "{name}: the file is byte-for-byte what it was" + ); + } + + // `hooks = []` is the one shape that carries no configuration at all, + // so it is promoted instead of refused. + let home = base.join("an_empty_array"); + let t = HookTarget::remote(&*host, home.clone()); + let config = HookAgent::Kimi.target_path(&t); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + std::fs::write(&config, "model = \"k2\"\nhooks = []\n").unwrap(); + install_hooks(&t, HookAgent::Kimi).expect("an empty inline array is promoted, not refused"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed); + let written = std::fs::read_to_string(&config).unwrap(); + assert!(written.starts_with("model = \"k2\"\n"), "{written}"); + assert!(!written.contains("hooks = []"), "{written}"); + assert_eq!( + uninstall_hooks(&t, HookAgent::Kimi).unwrap(), + HookOutcome::Removed + ); + assert!( + !std::fs::read_to_string(&config).unwrap().contains("hooks"), + "the key goes when the last entry in it does" + ); + + let _ = std::fs::remove_dir_all(&base); + } + + /// The rest of the TOML merge contract: a file that does not exist yet, a + /// second install that changes nothing, entries a hand-edit has mangled, + /// and an uninstall that has to thread its removals between the user's own + /// entries and the tables that follow them. + #[test] + fn kimi_toml_merge_holds_up_across_the_awkward_shapes() { + let host = FakeRemote::shared(); + let base = std::env::temp_dir().join(format!("tty7-kimi-merge-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let marker = "agent-hook kimi"; + + // Nothing there at all: install creates the directory and the file. + let t = HookTarget::remote(&*host, base.join("fresh")); + let config = HookAgent::Kimi.target_path(&t); + assert!(!config.exists()); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::NotInstalled); + assert_eq!( + uninstall_hooks(&t, HookAgent::Kimi).unwrap(), + HookOutcome::NothingInstalled, + "there is no file to take anything out of" + ); + install_hooks(&t, HookAgent::Kimi).expect("install writes a fresh config.toml"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed); + let once = std::fs::read_to_string(&config).unwrap(); + install_hooks(&t, HookAgent::Kimi).expect("re-install succeeds"); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + once, + "a second install is byte-for-byte the first — no churn, no growth" + ); + + // A hand-edit that drops `event` leaves an entry that is ours and is + // broken. That is Outdated, not NotInstalled: `refresh_hooks` only + // ever revisits Outdated, so anything else hides the damage. + let mangled = once.replacen("event = \"SessionStart\"\n", "", 1); + assert_ne!(mangled, once); + std::fs::write(&config, &mangled).unwrap(); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Outdated); + install_hooks(&t, HookAgent::Kimi).expect("install repairs it"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed); + assert_eq!(std::fs::read_to_string(&config).unwrap(), once); + + // One marked entry too many is Outdated too, and install prunes it. + // This one has no `event` at all, so counting only the entries that + // still name one would find the full roster and call it Installed + // while a broken ninth entry sat there. + let mut extra = std::fs::read_to_string(&config).unwrap(); + extra.push_str("\n[[hooks]]\ncommand = \"tty7 agent-hook kimi stop\"\n"); + std::fs::write(&config, &extra).unwrap(); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Outdated); + install_hooks(&t, HookAgent::Kimi).expect("install prunes the stray"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed); + let doc: toml_edit::DocumentMut = + std::fs::read_to_string(&config).unwrap().parse().unwrap(); + assert_eq!( + doc["hooks"] + .as_array_of_tables() + .unwrap() + .iter() + .filter(|e| toml_command_is_marked(e, marker)) + .count(), + KIMI_HOOK_EVENTS.len() + ); + + // The user's own entry comes first and another table follows ours: + // uninstall has to take out the middle and leave both ends alone. + let t = HookTarget::remote(&*host, base.join("sandwich")); + let config = HookAgent::Kimi.target_path(&t); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + let user_half = concat!( + "# mine\n", + "[[hooks]]\n", + "event = \"Stop\"\n", + "command = \"afplay a.aiff\" # ding\n", + "\n", + "[providers.moonshot]\n", + "api_key = \"secret\"\n", + ); + std::fs::write(&config, user_half).unwrap(); + install_hooks(&t, HookAgent::Kimi).expect("install"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed); + let merged = std::fs::read_to_string(&config).unwrap(); + assert!( + merged.starts_with( + "# mine\n[[hooks]]\nevent = \"Stop\"\ncommand = \"afplay a.aiff\" # ding\n" + ), + "the user's entry and its trailing comment come through verbatim:\n{merged}" + ); + assert!(merged.contains("[providers.moonshot]\napi_key = \"secret\"\n")); + assert_eq!( + uninstall_hooks(&t, HookAgent::Kimi).unwrap(), + HookOutcome::Removed + ); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + user_half, + "uninstall puts the file back exactly as the user left it" + ); + + let _ = std::fs::remove_dir_all(&base); + } + + /// Kimi's `Stop` does not fire when the user interrupts a turn or when one + /// dies on an error, so the pane would sit on "working" forever without + /// the two events that do. + #[test] + fn kimi_reports_every_way_a_turn_ends() { + for event in ["Stop", "Interrupt", "StopFailure"] { + assert_eq!( + KIMI_HOOK_EVENTS + .iter() + .find(|(hook_event, _)| *hook_event == event) + .map(|(_, tty7_event)| *tty7_event), + Some("stop"), + "{event} has to end the turn like Stop does" + ); + } + assert!( + !KIMI_HOOK_EVENTS + .iter() + .any(|(hook_event, _)| *hook_event == "Notification"), + "Notification fires for background-task chatter and would strand the pane on waiting" + ); + } } diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 577dbf28..37a8ba6a 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -22,10 +22,11 @@ pub enum CLIAgent { Grok, Qwen, OhMyPi, + Kimi, } impl CLIAgent { - pub const ALL: [CLIAgent; 18] = [ + pub const ALL: [CLIAgent; 19] = [ CLIAgent::Claude, CLIAgent::Codex, CLIAgent::Gemini, @@ -44,6 +45,7 @@ impl CLIAgent { CLIAgent::Grok, CLIAgent::Qwen, CLIAgent::OhMyPi, + CLIAgent::Kimi, ]; fn aliases(self) -> &'static [&'static str] { @@ -72,6 +74,10 @@ impl CLIAgent { // Oh My Pi is a fork of Pi, but it ships one binary of its own and // never installs a `pi`, so the two names stay disjoint. CLIAgent::OhMyPi => &["omp"], + // Both the standalone Kimi Code CLI and the legacy open-source + // kimi-cli install a `kimi` — same vendor, same brand, so one + // detection covers them. Only the standalone one has hooks. + CLIAgent::Kimi => &["kimi", "kimi-code"], } } @@ -95,6 +101,7 @@ impl CLIAgent { CLIAgent::Grok => "grok", CLIAgent::Qwen => "qwen", CLIAgent::OhMyPi => "omp", + CLIAgent::Kimi => "kimi", } } @@ -123,6 +130,7 @@ impl CLIAgent { CLIAgent::Grok => "Grok", CLIAgent::Qwen => "Qwen Code", CLIAgent::OhMyPi => "Oh My Pi", + CLIAgent::Kimi => "Kimi Code", } } @@ -155,6 +163,7 @@ impl CLIAgent { CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id}")), CLIAgent::Pi => Some(format!("pi{flags} --session {session_id}")), CLIAgent::OhMyPi => Some(format!("omp{flags} --resume {session_id}")), + CLIAgent::Kimi => Some(format!("kimi{flags} --session {session_id}")), _ => None, } } @@ -342,6 +351,21 @@ impl CLIAgent { // `--resume`, `-r` and `--session` are three spellings of one flag // in Oh My Pi; `--session-dir` is a different one and survives. CLIAgent::OhMyPi => &["--resume", "-r", "--session", "--fork", "--continue", "-c"], + // `--resume`/`-r` is Kimi's hidden alias for `--session`/`-S`. + // `--agent`/`--agent-file` bind the main agent at session creation + // and Kimi rejects either next to `--session` outright; resuming + // restores the bound agent by itself, so replaying them would only + // turn a working resume into a startup error. + CLIAgent::Kimi => &[ + "--session", + "-S", + "--resume", + "-r", + "--continue", + "-c", + "--agent", + "--agent-file", + ], CLIAgent::Grok => &[ "--resume", "-r", @@ -414,6 +438,9 @@ impl CLIAgent { CLIAgent::Grok => 0x000000, CLIAgent::Qwen => 0x6D44E8, CLIAgent::OhMyPi => 0xF97316, + // The blue of the flame in Kimi's brand mark; the glyph itself is + // black, which Codex and Grok already have covered. + CLIAgent::Kimi => 0x027AFF, } } @@ -432,6 +459,7 @@ impl CLIAgent { CLIAgent::Pi => "icons/agents/pi.svg", CLIAgent::OhMyPi => "icons/agents/omp.svg", CLIAgent::Qwen => "icons/agents/qwen.svg", + CLIAgent::Kimi => "icons/agents/kimi.svg", CLIAgent::Aider | CLIAgent::Auggie | CLIAgent::Hermes @@ -865,6 +893,8 @@ mod tests { ("hermes", CLIAgent::Hermes), ("omp", CLIAgent::OhMyPi), ("/opt/homebrew/bin/omp", CLIAgent::OhMyPi), + ("kimi", CLIAgent::Kimi), + ("/usr/local/bin/kimi", CLIAgent::Kimi), ] { assert_eq!(CLIAgent::detect_from_argv(&argv(&[cmd])), Some(agent)); } @@ -1155,6 +1185,10 @@ mod tests { .as_deref(), Some("pi --session 0199c3f2-1b0e-7c3a-9f21-6d4b8e2a5c17") ); + assert_eq!( + CLIAgent::Kimi.resume_command("abc-123", None).as_deref(), + Some("kimi --session abc-123") + ); assert_eq!(CLIAgent::Aider.resume_command("abc", None), None); assert_eq!(CLIAgent::Claude.resume_command("abc; rm -rf /", None), None); assert_eq!(CLIAgent::Claude.resume_command("$(boom)", None), None); @@ -1180,6 +1214,53 @@ mod tests { .as_deref(), Some("claude --model opus --resume abc") ); + assert_eq!( + CLIAgent::Kimi + .resume_command( + "abc-123", + Some(&argv(&["kimi", "--session", "old", "--yolo"])) + ) + .as_deref(), + Some("kimi --yolo --session abc-123"), + "a stale --session flag comes off before the new one goes on" + ); + assert_eq!( + CLIAgent::Kimi + .resume_command("abc-123", Some(&argv(&["kimi", "--session=old", "--yolo"]))) + .as_deref(), + Some("kimi --yolo --session abc-123"), + "and so does the one-token spelling of it" + ); + assert_eq!( + CLIAgent::Kimi + .resume_command( + "abc-123", + Some(&argv(&["kimi", "--resume", "--model", "kimi-k2"])) + ) + .as_deref(), + Some("kimi --model kimi-k2 --session abc-123"), + "`--session` takes an optional id, so a bare one must not eat the flag after it" + ); + assert_eq!( + CLIAgent::Kimi + .resume_command( + "abc-123", + Some(&argv(&["kimi", "--continue", "--model", "kimi-k2"])) + ) + .as_deref(), + Some("kimi --model kimi-k2 --session abc-123"), + "`--continue` is mutually exclusive with `--session` and takes no value" + ); + assert_eq!( + CLIAgent::Kimi + .resume_command( + "abc-123", + Some(&argv(&["kimi", "--agent", "reviewer", "--yolo"])) + ) + .as_deref(), + Some("kimi --yolo --session abc-123"), + "Kimi rejects `--agent` next to `--session`, and resume rebinds the agent itself" + ); assert_eq!( CLIAgent::Claude .resume_command( diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx index 903c94a6..5888288c 100644 --- a/docs/agents/overview.mdx +++ b/docs/agents/overview.mdx @@ -1,6 +1,6 @@ --- title: "Coding agents" -description: "What tty7 does around Claude Code, Codex, and 16 others — without ever wrapping them." +description: "What tty7 does around Claude Code, Codex, and 17 others — without ever wrapping them." --- tty7 recognises coding agents running in a pane and builds around them. It does @@ -15,7 +15,7 @@ need, and what changed. ## Which agents -Eighteen CLIs are recognised on sight, by the command running in the pane: +Nineteen CLIs are recognised on sight, by the command running in the pane: | Agent | Command | |---|---| @@ -31,6 +31,7 @@ Eighteen CLIs are recognised on sight, by the command running in the pane: | Droid | `droid` | | Grok | `grok` | | Qwen Code | `qwen`, `qwen-code` | +| Kimi Code | `kimi`, `kimi-code` | | Auggie | `auggie` | | Hermes | `hermes` | | Vibe | `vibe`, `vibe-acp` | @@ -59,7 +60,7 @@ If you launch agents through a wrapper script, map its name to an agent in The key is your command's name; the value is one of the slugs above (`claude`, `codex`, `gemini`, `aider`, `amp`, `opencode`, `copilot`, `cursor`, `goose`, `droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`, -`omp`). +`omp`, `kimi`). ## What you get for free diff --git a/docs/agents/sessions.mdx b/docs/agents/sessions.mdx index bc8f685b..61d4fba8 100644 --- a/docs/agents/sessions.mdx +++ b/docs/agents/sessions.mdx @@ -19,8 +19,8 @@ claude --dangerously-skip-permissions --resume 8f3c… The original launch flags are replayed, so the pane comes back the way you started it, not the way the defaults would. -Supported for Claude Code, Codex, Gemini, OpenCode, Amp, Cursor, Copilot, Grok, -Pi, and Oh My Pi. Turn it off with `restore_agent_sessions: false`. +Supported for every recognised agent except Aider. Turn it off with +`restore_agent_sessions: false`. Resume needs the agent's hooks installed, since the session id comes from diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx index 70eab33c..f4fb2a87 100644 --- a/docs/agents/status.mdx +++ b/docs/agents/status.mdx @@ -14,8 +14,8 @@ the agent say which one it is. | Agent | | |---|---| -| Claude Code · Codex · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi | Hooks available | -| Gemini · Aider · Amp · Cursor · Goose · Droid · Auggie · Hermes · Vibe · Antigravity · Qwen Code | Detected and labelled, but no status channel yet | +| Claude Code · Codex · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code | Hooks available | +| Aider · Amp · Cursor · Auggie · Hermes · Vibe · Antigravity | Detected and labelled, but no status channel yet | Installing writes into that agent's own configuration directory. Once installed the row grows a second **Uninstall** button beside the first, which itself diff --git a/docs/getting-started/first-launch.mdx b/docs/getting-started/first-launch.mdx index 89cab4db..71f88c9d 100644 --- a/docs/getting-started/first-launch.mdx +++ b/docs/getting-started/first-launch.mdx @@ -54,7 +54,7 @@ leave it off if you type accented characters. ## 4. If you use coding agents, install the hooks -**Settings → Agents.** tty7 detects 18 coding CLIs by process name on its own — +**Settings → Agents.** tty7 detects 19 coding CLIs by process name on its own — you get brand avatars and tab labels for free. The *status dots*, the "needs your permission" notifications, and `tty7 wait` all need one more thing: a small hook the agent calls to report what it is doing. diff --git a/docs/index.mdx b/docs/index.mdx index d70cc602..d1f0d721 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -31,7 +31,7 @@ something floods the screen. syntax highlighting, click-to-place-caret, real multi-line editing. - 18 coding CLIs are recognised on sight. Per-pane status dots, notifications + 19 coding CLIs are recognised on sight. Per-pane status dots, notifications when one needs you, git context, and session resume after a reboot. diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 5f89789f..94a7bb1f 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -60,6 +60,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/agents/pi.svg" => include_bytes!("../../assets/icons/agents/pi.svg"), "icons/agents/omp.svg" => include_bytes!("../../assets/icons/agents/omp.svg"), "icons/agents/qwen.svg" => include_bytes!("../../assets/icons/agents/qwen.svg"), + "icons/agents/kimi.svg" => include_bytes!("../../assets/icons/agents/kimi.svg"), _ => return None, }; Some(bytes) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 30371ff3..ae2c1cfb 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -718,6 +718,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAgentDroid => "Droid", L10nKey::SettingsAgentQwenCode => "Qwen Code", L10nKey::SettingsAgentGoose => "Goose", + L10nKey::SettingsAgentKimiCode => "Kimi Code", L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github", L10nKey::SettingsSearchAppHttpProxyKeywords => { "proxy http https socks socks5 clash v2ray network download update" @@ -804,6 +805,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { "agent integration hooks install qwen code qwen-code" } L10nKey::SettingsSearchGooseKeywords => "agent integration hooks plugin install goose", + L10nKey::SettingsSearchKimiCodeKeywords => { + "agent integration hooks install kimi code kimi-code moonshot" + } L10nKey::SettingsSearchPiKeywords => "agent integration extension install pi", L10nKey::SettingsSearchPortForwardingKeywords => { "ssh tunnel local remote dynamic socks forward rule" diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 2bc12f6c..56f6f416 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -727,6 +727,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentDroid => "Droid", L10nKey::SettingsAgentQwenCode => "Qwen Code", L10nKey::SettingsAgentGoose => "Goose", + L10nKey::SettingsAgentKimiCode => "Kimi Code", L10nKey::SettingsSearchAboutKeywords => { "バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check" } @@ -855,6 +856,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchGooseKeywords => { "エージェント 統合 フック プラグイン インストール goose agent integration hooks plugin install" } + L10nKey::SettingsSearchKimiCodeKeywords => { + "エージェント 統合 フック インストール kimi code moonshot agent integration hooks install" + } L10nKey::SettingsSearchPiKeywords => { "エージェント 統合 拡張 インストール pi agent integration extension install" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index e24e1f56..4e012720 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -573,6 +573,7 @@ l10n_keys! { SettingsAgentDroid, SettingsAgentQwenCode, SettingsAgentGoose, + SettingsAgentKimiCode, SettingsSearchAppHttpProxyKeywords, SettingsSearchAboutKeywords, SettingsSearchAutoDownloadKeywords, @@ -611,6 +612,7 @@ l10n_keys! { SettingsSearchItalicFontKeywords, SettingsSearchKeybindingsKeywords, SettingsSearchKeybindingsTitle, + SettingsSearchKimiCodeKeywords, SettingsSearchLineHeightKeywords, SettingsSearchNewTabPositionKeywords, SettingsSearchNotifyOnCommandFinishKeywords, @@ -1521,6 +1523,7 @@ mod tests { L10nKey::SettingsAgentGemini, L10nKey::SettingsAgentGoose, L10nKey::SettingsAgentGrokBuild, + L10nKey::SettingsAgentKimiCode, L10nKey::SettingsAgentOhMyPi, L10nKey::SettingsAgentOpencode, L10nKey::SettingsAgentPi, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 6948668d..65463832 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -634,6 +634,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentDroid => "Droid", L10nKey::SettingsAgentQwenCode => "Qwen Code", L10nKey::SettingsAgentGoose => "Goose", + L10nKey::SettingsAgentKimiCode => "Kimi Code", L10nKey::SettingsSearchAboutKeywords => { "关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update" } @@ -760,6 +761,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchGooseKeywords => { "Goose agent 集成 钩子 插件 安装 goose agent integration hooks plugin install" } + L10nKey::SettingsSearchKimiCodeKeywords => { + "Kimi Code 月之暗面 agent 集成 钩子 安装 kimi code moonshot agent integration hooks install" + } L10nKey::SettingsSearchPiKeywords => { "Pi agent 集成 扩展 安装 pi agent integration extension install" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 81335a02..70938108 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -628,6 +628,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsAgentGoose, keywords: SettingsSearchGooseKeywords, }, + SearchEntry { + section: Agents, + title: SettingsAgentKimiCode, + keywords: SettingsSearchKimiCodeKeywords, + }, SearchEntry { section: WindowTabs, title: SettingsStartupWindow, From 51b0fe64b95e57e1be4c18c9e5e86481d931553f Mon Sep 17 00:00:00 2001 From: webdev <86946125+biztex@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:14:49 +0700 Subject: [PATCH 24/33] fix(linux): stop the compositor framing a window that draws its own title bar (#679) (#683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(linux): stop the compositor framing a window that draws its own title bar (#679) tty7 paints its own title bar through gpui-component's TitleBar, and the WindowOptions it opens with say as much (appears_transparent) — but say nothing about decorations. gpui reads a missing window_decorations as WindowDecorations::Server and, on Wayland, sends zxdg_toplevel_decoration_v1.set_mode(server_side) for the toplevel, so a compositor that honours it draws a second title bar and border around the one the app already has. window_options() now asks for WindowDecorations::Client, which is what Zed defaults to (its window_decorations setting, overridable by ZED_WINDOW_DECORATIONS). Nothing new is painted for it. gpui-component's Root already wraps the window in window_border() — bordered defaults to true and tty7's root never turns it off — which under Decorations::Client draws the 1px frame, the 12px shadow, the resize hit bands and the right-click window menu, and tells gpui its inset through set_client_inset; under Decorations::Server it degrades to a plain div. The request was the only piece missing. The field is set without a cfg, unlike the icon beside it: the icon is gated because the PNG behind it is only decoded on Linux, while this is a plain enum that costs nothing elsewhere. request_decorations is an empty default on the PlatformWindow trait that neither the macOS nor the Windows backend overrides, so both keep answering Decorations::Server and the window there is unchanged. X11 turns the request into _MOTIF_WM_HINTS and falls back to server-side on its own when no compositor is running, so a bare X session still gets a window-manager frame — and a reparenting WM under a compositor, which today is told in the same hints to decorate, gets the same fix as Wayland. Client-side decorations bring one follow-on that Zed hit too (ca9cee85e1, "linux: Fix non-maximized Zed windows growing larger across sessions", #22301), and the two Linux backends want opposite answers to it. The bounds tty7 remembers go back in through WindowOptions::window_bounds, which every backend reads as the outer rectangle. On Wayland under client decorations the outer rectangle is the surface, shadow included, and the compositor's first sized configure adds the inset back onto whatever was asked for (compute_outer_size) — so saving outer and reopening at it grew the window by twice the shadow per launch, and saving inner pre-deflates by exactly what the configure re-inflates. X11 never re-inflates: it creates the window at the requested rectangle verbatim, and its inner_window_bounds also shifts the origin by the inset, so saving inner there would shrink the window and walk it down-right by the shadow on every launch wherever the request is honoured (a compositor plus _GTK_FRAME_EXTENTS — GNOME on Xorg, Plasma X11). A window_bounds_to_remember helper therefore saves inner on Wayland and outer everywhere else, told apart by cx.compositor_name(); macOS and Windows report no inset, so the two are the same there. WindowState round-trips unchanged. The one place that hardcoded the window's corner follows the frame: the pane-to-tab-strip drop band was a rectangle from (0, 0) to the title bar's height, which under client decorations is the shadow strip plus the top of the bar, missing its lower third. It now starts at window_paddings(window), which is zero under server decorations, so nothing moves off CSD. A test pins the request: window_options() must answer Some(Client), and its title bar must be the transparent one the request stands in for. Not verified here, with no Linux session to run in: that the reporter's compositor honours the mode switch (the protocol lets it refuse), how the 12px shadow reads against the shipped themes, the edges of a maximized or tiled window, where gpui-component drops the padding on the tiled sides, and one quit-and-relaunch on X11 under a compositor to see the remembered size hold. The app.rs change is untestable in principle: gpui's TestWindow overrides neither inner_window_bounds nor the decorations, so inner and outer are one rectangle in every test. FreeBSD runs the same backends with gpui-component's shadow at zero; unexercised. * fix(linux): remember the inner window bounds on X11 too, not just Wayland The bounds tty7 remembers were saved as the *outer* rectangle everywhere but Wayland, on the reading that X11 creates its window at the requested rectangle verbatim and never puts the shadow back on. That reading is wrong, and it reintroduces on X11 exactly the bug the split was written to avoid on Wayland. gpui only turns client-side decorations on for X11 when a compositor is present *and* the window manager advertises _GTK_FRAME_EXTENTS (client_side_decorations_supported in x11/client.rs). A window manager that advertises that atom is one that honours it — it keeps the visible frame put and treats the extents as shadow outside it — so a window reopened at its outer rectangle comes back one shadow larger on each side, every launch. That is what Zed measured: ca9cee85e1 ("linux: Fix non-maximized Zed windows growing larger across sessions", #22301), the commit this code cites, took all of its before/after numbers on X11 (+20px per session) and fixed both backends with a single unconditional inner_window_bounds(). Zed still reads it unconditionally today, at the rev pinned here. So drop the compositor_name() branch and save the inner rectangle on every platform, as Zed does. It is a no-op wherever there is no inset to strip: inner_window_bounds defaults to window_bounds on the PlatformWindow trait and neither the macOS nor the Windows backend nor gpui's TestWindow overrides it, and on X11 without a compositor window_decorations() answers Server, so the window border never calls set_client_inset and last_insets stays [0, 0, 0, 0]. Also lift the tab strip's drop band out of the render path into strip_band(), so the padding arithmetic can be tested without a window: the viewport measures the whole surface, shadow included, so the band loses one padding at each end rather than one twice over or none at all. It clamps at zero now — a surface narrower than its own shadow is only reachable mid-resize, but a negative width would hand Bounds::contains a rectangle that is inside out. Three tests: the band is unmoved when the frame reports no padding (macOS, Windows, a bare X session), it reaches the far edge of the frame rather than of the surface when it does, and it collapses instead of inverting. window_bounds_to_remember stays untested on purpose — TestWindow makes inner and outer the same rectangle, so any assertion about it would only restate the call. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- src/ui/app.rs | 108 ++++++++++++++++++++++++++++++++++++++++------ src/ui/windows.rs | 28 +++++++++++- 2 files changed, 123 insertions(+), 13 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index c9695197..f7bf6f5d 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Axis, Bounds, Context, Entity, Focusable, Pixels, PromptLevel, Subscription, Window, div, - img, point, prelude::*, px, size, + App, Axis, Bounds, Context, Edges, Entity, Focusable, Pixels, PromptLevel, Size, Subscription, + Window, div, img, point, prelude::*, px, size, }; use gpui_component::color_picker::{ColorPickerEvent, ColorPickerState}; use gpui_component::input::{InputEvent, InputState}; @@ -293,6 +293,50 @@ pub(crate) fn title_bar_hug_offset() -> f32 { } } +/// The bounds to remember for reopening this window at the same place. +/// +/// Under client-side decorations the window's *outer* rectangle is the whole +/// surface, shadow included, and both Linux backends put the shadow back on +/// their own once the window is up: Wayland's first sized configure adds the +/// inset to whatever geometry was asked for (`compute_outer_size`), and on X11 +/// the inset travels as `_GTK_FRAME_EXTENTS`, which a window manager only +/// advertises — and gpui only turns CSD on for — when it honours those extents +/// by keeping the *visible* frame put. Save the outer rectangle either way and +/// it comes back twice the shadow larger every launch; that is the bug Zed +/// fixed in ca9cee85e1 ("linux: Fix non-maximized Zed windows growing larger +/// across sessions", #22301), whose own measurements were taken on X11. +/// +/// So save the inner rectangle, exactly as Zed does, on every platform. It is +/// the visible frame, which is what the backends re-inflate back to, and it +/// costs nothing elsewhere: `inner_window_bounds` defaults to `window_bounds` +/// on the `PlatformWindow` trait, and neither the macOS nor the Windows backend +/// nor gpui's `TestWindow` overrides it. On X11 without a compositor the same +/// holds for a different reason — `window_decorations()` answers `Server`, the +/// window border never calls `set_client_inset`, and the insets stay zero. +fn window_bounds_to_remember(window: &Window) -> Bounds { + window.inner_window_bounds().get_bounds() +} + +/// The band the tab strip claims for a drop, in the window's outer coordinates. +/// +/// The strip is the top of the *content*, and under client-side decorations the +/// content sits inside the frame padding while `viewport` still measures the +/// whole surface, shadow included — so the band starts at the padding and stops +/// at the far edge of the frame, one padding in from each side. Anything else +/// leaves the outermost chips outside the zone that is supposed to contain +/// them. `window_paddings` answers `Edges::all(0)` under server-side +/// decorations, which is every platform but Linux CSD, so off Linux this stays +/// the full-width rectangle from the window's corner it has always been. +fn strip_band(viewport: Size, pad: Edges) -> Bounds { + Bounds { + origin: point(pad.left, pad.top), + size: size( + (viewport.width - pad.left - pad.right).max(px(0.)), + px(TITLE_BAR_HEIGHT), + ), + } +} + pub(crate) const WINDOW_MARK_SIZE: f32 = 20.; pub(crate) fn title_bar_drag( @@ -1298,7 +1342,7 @@ impl Tty7App { settings: None, ssh_prompt: crate::ui::ssh_prompt::SshPromptState::new(cx), close_prompt_open: false, - window_bounds: window.window_bounds().get_bounds(), + window_bounds: window_bounds_to_remember(window), workspace, workspace_rename: None, window_title: std::cell::RefCell::new(String::new()), @@ -1323,7 +1367,7 @@ impl Tty7App { .detach(); cx.observe_window_bounds(window, |this, window, _cx| { - this.window_bounds = window.window_bounds().get_bounds(); + this.window_bounds = window_bounds_to_remember(window); }) .detach(); @@ -3806,6 +3850,8 @@ impl Tty7App { /// Which gap between tabs the pointer is in, as the tab a newcomer would /// be inserted before and the caret marking it — on the strip, or on the /// sidebar, whichever the pointer is over. + /// + /// `viewport` and the pointer are both in the window's outer coordinates. fn detach_slot(&self, window: &Window, cx: &App) -> Option<(usize, Bounds)> { /// How far above its first row the sidebar's band starts, so the gap /// over that row is inside it. @@ -3817,6 +3863,7 @@ impl Tty7App { let vertical = matches!(cx.global::().tab_bar_position, TabBarPosition::Left) && !self.tabs.is_empty(); let viewport = window.viewport_size(); + let pad = gpui_component::window_paddings(window); // The band each surface claims, read off the tabs it drew rather than // measured as an element of its own: the chips and the rows are the // only part of either surface a drop has anything to say about, and a @@ -3832,10 +3879,7 @@ impl Tty7App { .map(|(_, b)| b.origin.x + b.size.width) .reduce(Pixels::max)?; Some(match axis { - Axis::Horizontal => Bounds { - origin: point(px(0.), px(0.)), - size: size(viewport.width, px(TITLE_BAR_HEIGHT)), - }, + Axis::Horizontal => strip_band(viewport, pad), Axis::Vertical => Bounds { origin: point(left, top - px(BAND_REACH)), size: size( @@ -8619,15 +8663,55 @@ mod window_drag_tests { #[cfg(test)] mod tests { use super::{ - CloseReason, DOCUMENT_MIN_W, TERMINAL_MIN_W, TabAgentSession, clear_window_override_values, - close_prompt, document_column_px, join_shell_args, leaf_shares_the_window_daemon, - mru_order, pane_free_for, parse_ssh_connect_input, parse_ssh_option_words, side_panel_max, - split_shell_args, wd_path_saveable, + CloseReason, DOCUMENT_MIN_W, TERMINAL_MIN_W, TITLE_BAR_HEIGHT, TabAgentSession, + clear_window_override_values, close_prompt, document_column_px, join_shell_args, + leaf_shares_the_window_daemon, mru_order, pane_free_for, parse_ssh_connect_input, + parse_ssh_option_words, side_panel_max, split_shell_args, strip_band, wd_path_saveable, }; + use gpui::{Edges, point, px, size}; const SIDEBAR_MIN: f32 = crate::ui::tab_sidebar::MIN_SIDEBAR_WIDTH; const PANEL_MIN: f32 = crate::ui::right_panel::MIN_WIDTH; + /// #679: the band now starts at the frame padding rather than at the + /// window's corner, and off Linux CSD there is no padding to start at — + /// `window_paddings` answers `Edges::all(0)` under server-side decorations, + /// which is what macOS, Windows and a bare X session all report. + #[test] + fn an_undecorated_frame_leaves_the_strips_drop_band_where_it_was() { + let band = strip_band(size(px(1200.), px(800.)), Edges::all(px(0.))); + assert_eq!(band.origin, point(px(0.), px(0.))); + assert_eq!(band.size, size(px(1200.), px(TITLE_BAR_HEIGHT))); + } + + /// The viewport measures the whole surface, shadow included, so the band + /// has to lose one padding at each end — not one twice over, and not none. + /// Getting it wrong puts the outermost chips outside the band drawn to hold + /// them, and a drop on them reads as a drop on nothing. + #[test] + fn a_client_side_frame_pulls_the_drop_band_in_by_the_shadow_on_both_sides() { + let viewport = size(px(1200.), px(800.)); + let pad = Edges::all(px(12.)); + let band = strip_band(viewport, pad); + + assert_eq!(band.origin, point(pad.left, pad.top)); + assert_eq!( + band.origin.x + band.size.width, + viewport.width - pad.right, + "the band must reach the far edge of the frame, not of the surface" + ); + assert_eq!(band.size.height, px(TITLE_BAR_HEIGHT)); + } + + /// A surface narrower than its own shadow is only reachable mid-resize, but + /// a negative width would make `Bounds::contains` answer for a rectangle + /// that is inside out. + #[test] + fn a_drop_band_narrower_than_its_frame_collapses_instead_of_inverting() { + let band = strip_band(size(px(10.), px(800.)), Edges::all(px(12.))); + assert_eq!(band.size.width, px(0.)); + } + /// Two panels that each cap themselves at half the window leave the /// terminal nothing when both are open, so the cap is what is left after /// the terminal's floor and the *other* panel's floor — or half the window, diff --git a/src/ui/windows.rs b/src/ui/windows.rs index f1fc2f93..617853d4 100644 --- a/src/ui/windows.rs +++ b/src/ui/windows.rs @@ -1,6 +1,7 @@ use gpui::{ AnyWindowHandle, App, AppContext as _, BorrowAppContext as _, Bounds, Global, Styled as _, - TitlebarOptions, WeakEntity, Window, WindowBounds, WindowOptions, point, px, size, + TitlebarOptions, WeakEntity, Window, WindowBounds, WindowDecorations, WindowOptions, point, px, + size, }; use gpui_component::{Root, TitleBar}; @@ -708,6 +709,12 @@ fn window_options(cx: &mut App, workspace: Option) -> WindowOptions traffic_light_position: Some(crate::ui::theme::traffic_light_position()), ..TitleBar::title_bar_options() }), + // The title bar above is tty7's own, so the compositor must not add a + // second one: left unset, gpui asks Wayland for server-side + // decorations (#679). Only the Linux backends read this — macOS and + // Windows ignore the request — and X11 falls back to the window + // manager's frame on its own when no compositor is running. + window_decorations: Some(WindowDecorations::Client), window_background: crate::ui::theme::background_appearance(cx), window_min_size: Some(size(px(MIN_SIZE.0), px(MIN_SIZE.1))), ..Default::default() @@ -813,6 +820,25 @@ mod tests { assert_eq!(cascade(b, 6).origin, cascade(b, 1).origin); } + #[gpui::test] + fn a_window_asks_the_compositor_for_client_side_decorations(cx: &mut gpui::TestAppContext) { + // #679: tty7 draws its own title bar, so the request must say so — + // gpui's default asks Wayland for a server-side frame on top of it. + cx.update(|cx| { + WindowRegistry::init(cx); + let mut config = Config::default(); + config.remember_window_size = false; + cx.set_global(config); + + let options = window_options(cx, None); + assert_eq!(options.window_decorations, Some(WindowDecorations::Client)); + assert!( + options.titlebar.is_some_and(|t| t.appears_transparent), + "the in-app title bar is what the client-side request stands in for" + ); + }); + } + #[gpui::test] fn a_pathless_cli_request_restores_a_workspace_when_no_window_is_open( cx: &mut gpui::TestAppContext, From 47e25ef8542921e8cf176bac2a981bf52ae3632a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:00:40 +0800 Subject: [PATCH 25/33] fix(ci): judge a Mach-O's signature by codesign's exit status (#696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codesign -dv` spells its signature line differently per posture: `Signature=adhoc` for an ad-hoc or linker signature, `Signature size=8968` for a Developer ID one with a timestamp. The check matched the literal `Signature=`, which the second spelling does not contain. While the script only pointed at the standalone tty7-server, which is ad-hoc signed, that was invisible. #692 pointed it at the bundle's tty7-app, tty7 and tty7-updater as well, and those are Developer ID signed whenever the signing secrets are present. Pull requests do not see the secrets, so every PR run took the ad-hoc branch and passed; the first build that signed for real — the nightly — failed on all three binaries, printing `CodeDirectory`, `Signature size=8968` and a Developer ID `TeamIdentifier` as its proof they carried no signature. The binaries were signed, notarized and stapled; only the assertion was wrong. Exit status has no such split: 0 for anything signed, 1 with `code object is not signed at all` for anything not, verified against all three postures. The output is still captured so the failure message carries it. --- .github/scripts/assert-macho.sh | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/scripts/assert-macho.sh b/.github/scripts/assert-macho.sh index 9254fd20..54278023 100755 --- a/.github/scripts/assert-macho.sh +++ b/.github/scripts/assert-macho.sh @@ -66,8 +66,22 @@ fi # Rosetta shell (`uname -sm` = "Darwin x86_64"), and that is not a machine to # hand an unsigned binary to on a guess. The workflow signs it; this catches the # day it stops. -SIGNING=$(codesign -dv "$BIN" 2>&1 || true) -if [[ "$SIGNING" != *"Signature="* ]]; then +# The verdict is `codesign -dv`'s exit status, not a word in its output. It +# spells the signature line differently per posture — `Signature=adhoc` for an +# ad-hoc or linker signature, `Signature size=8968` for a Developer ID one with +# a timestamp — so the `*"Signature="*` this used to match held only for ad-hoc. +# While the script pointed at the standalone tty7-server, which is ad-hoc +# signed, that was invisible; #692 pointed it at the bundle's binaries as well, +# and those are Developer ID signed whenever the signing secrets are present. +# Pull requests do not see the secrets, so every PR run took the ad-hoc branch +# and passed, and the first build that signed for real — the nightly — failed +# on all three binaries with `Signature size=` in the very output it printed as +# proof they were unsigned. +# +# Exit status has no such split: 0 for anything signed, 1 with `code object is +# not signed at all` for anything not. The output is still captured so the +# failure message can carry it. +if ! SIGNING=$(codesign -dv "$BIN" 2>&1); then echo "::error::$BIN carries no code signature — arm64 macOS will refuse to run it" echo "$SIGNING" fail=1 From 8131ac2f9307a8ff612329dfb95ae04273eedaa6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:28:48 +0800 Subject: [PATCH 26/33] Address a tab by the bare id --json prints, and stop the skill sending workers in headless (#699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): address a tab by the bare id --json hands back `parse_tab` required the `@` sigil, so the tab id from `tty7 tab new --json` — the one id a caller is certain of — was the one shape the CLI refused. `parse_pane` already made `%` optional for exactly this reason (#538); this aligns tabs with it, keeping the digits-only guard so a leading `+` cannot read as an ordinal now that the sigil is gone. * docs(skill): hand a pane worker its interactive mode The worked example passed the task with `-p`, which draws nothing: the pane stays blank until the turn ends, `capture --plain` reads back empty, and the user watching their tty7 window sees a worker that looks hung. Putting a piped worker in a pane discards the only reason it is in one. Also documents three things that cost real debugging time: a fresh pane can swallow the Enter while its shell is still running startup files, `tty7 procs` reports nothing running for a pane with a live agent in it, and the OSC 777 event stream in a raw `capture` is what actually answers "is it moving". --- crates/tty7-cli/src/address.rs | 51 ++++++++++++++++++++++---- skills/tty7/SKILL.md | 67 +++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/crates/tty7-cli/src/address.rs b/crates/tty7-cli/src/address.rs index 66747f96..7bb4bbaa 100644 --- a/crates/tty7-cli/src/address.rs +++ b/crates/tty7-cli/src/address.rs @@ -60,19 +60,28 @@ pub fn parse_pane(s: &str) -> Result { } pub fn parse_tab(s: &str) -> Result { - let body = s - .strip_prefix('@') - .ok_or_else(|| anyhow!("'{s}' is not a tab address — tabs look like @7"))?; + // The `@` is optional, for the reason it is optional on a pane (#538): every + // `--json` payload spells tabs bare, so the id `tty7 tab new --json` just + // handed back has to address the tab it created. Demanding the sigil made + // the one id you are certain of the one shape the CLI refused. + let body = s.strip_prefix('@').unwrap_or(s); + let not_an_address = + || anyhow!("'{s}' is not a tab address — @7 as numbered by `tty7 ls`, or a full tab id"); if body.is_empty() { - bail!("'{s}' is not a tab address — tabs look like @7"); + return Err(not_an_address()); } - if let Ok(n) = body.parse::() { - return Ok(TabAddress::Ordinal(n)); + // Digits and nothing else. `u64::from_str` also takes a leading `+`, and + // with the `@` gone that would read `+5` as tab 5 rather than as a typo. + if body.bytes().all(|b| b.is_ascii_digit()) { + return body + .parse() + .map(TabAddress::Ordinal) + .map_err(|_| not_an_address()); } if looks_like_uuid(body) { return Ok(TabAddress::Id(body.to_string())); } - bail!("'{s}' is not a tab address — @7 as numbered by `tty7 ls`, or @"); + Err(not_an_address()) } fn looks_like_uuid(s: &str) -> bool { @@ -163,6 +172,34 @@ mod tests { assert_eq!(parse_pane("%42").unwrap(), 42); } + #[test] + fn a_bare_tab_id_addresses_the_same_tab_as_the_marked_one() { + // Every `--json` payload spells tabs bare, and the id `tty7 tab new` + // hands back is the one tab you are certain of — refusing it there made + // naming a tab you just created impossible without counting `@N` again. + let id = "0d4e1a54-0000-4000-8000-000000000003"; + assert_eq!(parse_tab(id).unwrap(), TabAddress::Id(id.into())); + assert_eq!( + parse_tab(&format!("@{id}")).unwrap(), + TabAddress::Id(id.into()) + ); + assert_eq!(parse_tab("7").unwrap(), TabAddress::Ordinal(7)); + assert_eq!(parse_tab("@7").unwrap(), TabAddress::Ordinal(7)); + } + + #[test] + fn a_tab_ordinal_is_digits_and_nothing_else() { + // Same guard as the pane one, and it matters for the same reason now + // that the sigil is optional on both. + for not_a_tab in ["+5", "@+5", "-5", " 5", "5 ", "", "@", "5.0", "build"] { + assert!( + parse_tab(not_a_tab).is_err(), + "'{not_a_tab}' must not read as a tab address" + ); + } + assert!(parse_tab("99999999999999999999999").is_err()); + } + #[test] fn an_address_is_digits_and_nothing_else() { // `u64::from_str` takes a leading `+`; an address must not, or a bare diff --git a/skills/tty7/SKILL.md b/skills/tty7/SKILL.md index 354a2a86..36c23f67 100644 --- a/skills/tty7/SKILL.md +++ b/skills/tty7/SKILL.md @@ -62,10 +62,17 @@ Reach for tty7 when one of these is true: |---|---|---| | `%42` | a pane | yes — a pane keeps its id for its whole life | | `@7` | a tab, numbered across the **whole machine** in tree order | **no** — it shifts whenever a workspace or tab appears or disappears | +| `@` | that same tab, by id | yes | | `api` / `76698a44` / a full UUID | a workspace, by name, by unique id prefix, or by id | yes | Re-resolve `@N` right before you use it; never cache one across a step that -creates or removes a tab. Pane ids and workspace ids are safe to remember. +creates or removes a tab. Pane ids, tab ids and workspace ids are safe to +remember — so when you create a tab and mean to address it again later, keep the +id `tty7 tab new --json` hands back rather than counting `@N` a second time. + +The sigils are optional wherever an address is expected: `%42` and `42` are the +same pane, `@7` and `7` the same tab. Ids copied out of `--json` paste straight +back in. Omitting the address inside a tty7 shell means "this pane" / "this workspace". An explicit address always wins over the environment. @@ -127,6 +134,22 @@ For keystrokes rather than characters — Ctrl-C, Escape, the arrow keys — use `--key` (see [Answering a prompt](#answering-a-prompt)). Typing `^C` as text does nothing; it arrives as two characters. +**A brand-new pane can swallow the Enter.** A shell still working through its +startup files — a prompt framework, `fastfetch`, anything that paints on login — +takes the text you send but loses the carriage return that follows it, and the +command just sits on the prompt line unexecuted. Nothing reports this: the +`send` succeeded, and the pane looks like a worker that has not got going yet. +So after sending the first command into a pane you just created, read the screen +back and check it actually left the prompt: + +```bash +tty7 capture "$PANE" --plain | tail -3 # command still sitting on the prompt? +tty7 send "$PANE" --enter # then give it the Enter it lost +``` + +Cheaper than diagnosing it later, and only the first `send` into a fresh pane +needs the check. + ## Reading a pane ### If you want the screen, use `--plain` @@ -197,6 +220,11 @@ If you want the process tree itself — "what is running in there", "which port this pane serving" — that is `tty7 procs %83`: indented by depth, `*` on the foreground process, then the ports those processes are listening on. +It is not the way to check on a coding agent, though. `procs` reports +`nothing running in this pane` for a pane with a busy agent in it, so reading it +as "the worker died" is wrong. Ask `tty7 agents` about those, or see +[When a worker never moves](#when-a-worker-never-moves). + ## Handing work to another agent Everything above also works when the thing in the pane is a coding agent, and @@ -206,7 +234,7 @@ process tree: ```bash PANE=$(tty7 split --v) -tty7 send "$PANE" 'claude -p "add tests for the parser"' --enter +tty7 send "$PANE" 'claude --dangerously-skip-permissions "add tests for the parser"' --enter tty7 wait "$PANE" --until waiting,done --changed --timeout 900 tty7 capture "$PANE" --plain | tail -40 tty7 pane close "$PANE" @@ -216,6 +244,27 @@ Five steps: give it a pane, hand it the task, sleep until it needs you or finishes, read what happened, clean up. The third is the one worth understanding. +### Give the worker its interactive mode + +Hand the task as an argument, **not** with `-p`. Both run one turn and stop, so +the difference is not what the worker does — it is what anybody can see while it +does it. + +Interactive is the mode that draws a TUI, so the pane fills with the worker's +reasoning and tool calls as they happen. That is visible to the user in their +tty7 window, and it is what `capture --plain` reads back. `-p` is the piped +mode: it draws nothing, streams its answer to stdout when the turn ends, and +until then the pane's screen stays **empty** — `capture --plain` on it returns +nothing at all, which reads exactly like a worker that hung. Putting a `-p` +worker in a pane throws away the only reason it is in a pane. + +Interactive also leaves the session alive at the prompt, so you can `send` a +follow-up into the same context. A `-p` worker is gone after its one turn. + +Reach for `-p` only when you want the answer as a string and nobody needs to +watch — and then prefer `tty7 run` or the Bash tool, which is what that shape +is for. + ### What the states mean | State | The pane is | @@ -293,6 +342,20 @@ it can see the gap, and `tty7 doctor` reports where every agent's hooks stand. Hooks are installed from the GUI's **Settings → Agents**; tell the user rather than trying to install them yourself. +Before concluding anything, check whether it is moving. Those same hooks emit an +OSC 777 line on every tool call, and `capture` **without** `--plain` shows them — +one of the few times the raw bytes beat the rendered screen: + +```bash +tty7 capture "$PANE" | grep -c 'tool-complete' # rising = alive and working +``` + +That is also the answer when a worker's screen looks empty: a `-p` worker paints +nothing until its turn ends, so `capture --plain` is blank the whole way through +while the event stream underneath is busy. Two things that do *not* answer this +question: `tty7 procs`, which reports nothing running for a pane with a live +agent in it, and the absence of output on a `--plain` capture. + ## Looking around ```bash From 8a950a343b713f80b8a5090187264b173830f6a7 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:51:36 +0800 Subject: [PATCH 27/33] Say what this platform does, not what macOS does (#700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(i18n): say what this platform does, not what macOS does Four pieces of user-facing wording described macOS as if it were the only platform they were read on, in all three languages at once — each translation had faithfully carried the English text's assumption across. - Copy on select claimed "no ⌘C needed" everywhere. Off macOS the binding is Ctrl+Shift+C, so the sentence named a key that copies nothing. - The blur switch was labelled "(macOS)" on a row Linux also renders and also honors. Windows gets the backdrop picker instead, so the label was wrong for every reader it had. It now says which compositors deliver it, because gpui's X11 backend does no blur at all and Wayland only does when the compositor offers a blur manager. - X11 forwarding named XQuartz as the only prerequisite anyone could have; Windows needs an X server of its own and Linux needs nothing. - The Explorer verbs were string literals, so a Chinese or Japanese install got English context-menu entries for the life of the install. The Explorer labels are the one string in the product that outlives the process that wrote it: Explorer reads them from the registry, not from tty7. Registration now sets the locale before building the entries (that process returns before the GUI path's set_locale ever runs), and a language change in Settings restates them. Only keys that already exist are rewritten — offering the menu is the installer's checkbox and declining it is the user's, and changing a language must never be what puts the verbs back. * docs(settings): the blur description no longer says what this comment quotes * fix(config): restate the Explorer verbs when a hand-edited language changes --- src/core/explorer_context_menu.rs | 114 ++++++++++++++++++++++++++++-- src/main.rs | 14 ++++ src/ui/app.rs | 4 ++ src/ui/i18n/en.rs | 26 ++++++- src/ui/i18n/ja.rs | 26 ++++++- src/ui/i18n/mod.rs | 35 +++++++++ src/ui/i18n/zh.rs | 28 +++++++- src/ui/settings.rs | 7 +- 8 files changed, 238 insertions(+), 16 deletions(-) diff --git a/src/core/explorer_context_menu.rs b/src/core/explorer_context_menu.rs index 4b5384e3..7c375507 100644 --- a/src/core/explorer_context_menu.rs +++ b/src/core/explorer_context_menu.rs @@ -17,6 +17,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result}; +use crate::ui::i18n::{L10nKey, t}; + const DIRECTORY_KEY: &str = r"Software\Classes\Directory\shell\tty7"; const BACKGROUND_KEY: &str = r"Software\Classes\Directory\Background\shell\tty7"; @@ -34,11 +36,19 @@ impl Location { } } + /// The wording Explorer shows, in the language the app is set to. + /// + /// Explorer reads this string from the registry, not from tty7, so what is + /// written here is what a user sees until something writes it again — an + /// install-time snapshot of the locale. Two things keep that snapshot + /// honest: `register` sets the locale from the config before building the + /// entries, and [`refresh_labels`] restates them whenever the language + /// changes — from the picker in Settings or from a hand-edited config. fn label(self) -> &'static str { - match self { - Self::Directory => "Open in tty7", - Self::Background => "Open tty7 here", - } + t(match self { + Self::Directory => L10nKey::ExplorerMenuOpenIn, + Self::Background => L10nKey::ExplorerMenuOpenHere, + }) } fn placeholder(self) -> &'static str { @@ -90,6 +100,25 @@ pub fn unregister() -> Result<()> { platform_unregister() } +/// Restate the verb labels in the language the UI now runs in. +/// +/// The registry holds whatever wording was current when the installer ran, so +/// without this a user who switches tty7 to Chinese keeps English entries in +/// Explorer for the life of the install — the one place in the product where +/// the language setting would not reach. +/// +/// Only keys that already exist are rewritten. Offering the menu is the +/// installer's checkbox and declining it is the user's decision; changing a +/// language must never be what puts the verbs back. +/// +/// Best-effort by design: a failure here costs a log line, never a language +/// change the user asked for. +pub fn refresh_labels() { + if let Err(error) = platform_refresh_labels() { + log::warn!("could not restate the Explorer context-menu labels: {error}"); + } +} + /// Build a command line without converting the executable path through UTF-8. /// /// Quotes are unconditional: both the executable and the Explorer-substituted @@ -251,6 +280,48 @@ mod windows { notify_explorer(); Ok(()) } + + /// The verb key, or `None` when tty7's menu is not installed. + /// + /// Deliberately open rather than create: this is the call that makes + /// [`refresh_labels`] unable to resurrect a menu the user removed. + fn open_existing(path: &str) -> Result> { + let path = wide(OsStr::new(path)); + let mut key: HKEY = std::ptr::null_mut(); + // SAFETY: `path` is a live, NUL-terminated UTF-16 string and `key` is a + // live local the API fills in only on success. + let code = unsafe { + RegOpenKeyExW( + HKEY_CURRENT_USER, + path.as_ptr(), + 0, + KEY_READ | KEY_WRITE, + &mut key, + ) + }; + match code { + ERROR_SUCCESS => Ok(Some(RegistryKey(key))), + ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND => Ok(None), + other => Err(io_error("opening the tty7 Explorer registry key", other)), + } + } + + pub(super) fn refresh_labels() -> Result<()> { + let mut restated = false; + for location in [Location::Directory, Location::Background] { + let Some(key) = open_existing(location.key())? else { + continue; + }; + set_string(&key, None, OsStr::new(location.label()))?; + restated = true; + } + // Only worth waking the shell when something actually moved; a user who + // never installed the menu changes languages for free. + if restated { + notify_explorer(); + } + Ok(()) + } } #[cfg(windows)] @@ -263,6 +334,11 @@ fn platform_unregister() -> Result<()> { windows::unregister() } +#[cfg(windows)] +fn platform_refresh_labels() -> Result<()> { + windows::refresh_labels() +} + #[cfg(not(windows))] fn platform_register() -> Result<()> { anyhow::bail!("Windows Explorer integration is only available on Windows") @@ -273,12 +349,23 @@ fn platform_unregister() -> Result<()> { anyhow::bail!("Windows Explorer integration is only available on Windows") } +/// Nothing to restate: the verbs exist only on Windows. +/// +/// Silent rather than an error like the two above, because this one is called +/// on every language change on every platform. Refusing here would put a +/// warning in the log of every macOS and Linux user who picks a language. +#[cfg(not(windows))] +fn platform_refresh_labels() -> Result<()> { + Ok(()) +} + #[cfg(test)] mod tests { use super::*; #[test] fn registration_targets_both_directory_surfaces() { + crate::ui::i18n::set_locale("en"); let app = Path::new(r"C:\Program Files\tty7\tty7-app.exe"); let [directory, background] = registrations(app); @@ -299,6 +386,25 @@ mod tests { ); } + /// The entries Explorer shows were English whatever language tty7 ran in, + /// because the labels were string literals. They are the only wording in + /// the product that outlives the process that wrote it, so the guard is on + /// the label rather than on the registry write it feeds. + #[test] + fn the_verb_labels_follow_the_ui_language() { + crate::ui::i18n::set_locale("zh-CN"); + assert_eq!(Location::Directory.label(), "在 tty7 中打开"); + assert_eq!(Location::Background.label(), "在此处打开 tty7"); + + crate::ui::i18n::set_locale("ja-JP"); + assert_eq!(Location::Directory.label(), "tty7 で開く"); + assert_eq!(Location::Background.label(), "ここで tty7 を開く"); + + crate::ui::i18n::set_locale("en"); + assert_eq!(Location::Directory.label(), "Open in tty7"); + assert_eq!(Location::Background.label(), "Open tty7 here"); + } + #[test] fn commands_quote_even_paths_without_spaces() { assert_eq!( diff --git a/src/main.rs b/src/main.rs index fd018576..a7fc9324 100644 --- a/src/main.rs +++ b/src/main.rs @@ -130,6 +130,12 @@ fn apply_reloaded_config( // alone, so there is nothing to compare and the user's keys stay in the // keymap the app is dispatching on. let keymap_before = crate::ui::keymap::keybinding_config(cx); + // Explorer's verbs live in the registry rather than in this process, so a + // hand-edited `gui_language` leaves them behind unless something restates + // them. Gated on the language actually moving, for the same reason the + // keymap below is: this watcher fires on every config write, and a sidebar + // drag has no business touching the registry. + let language_changed = cx.global::().gui_language != config.gui_language; crate::ui::i18n::set_locale(&config.gui_language); cx.set_global(config); reload_themes(cx); @@ -138,6 +144,9 @@ fn apply_reloaded_config( // gui_language by hand has to rebuild it the same way the in-app language // picker does. crate::ui::theme::set_menus(cx); + if language_changed { + crate::core::explorer_context_menu::refresh_labels(); + } crate::ui::windows::WindowRegistry::refresh_locale(cx, None); // `custom_shells` is only ever hand-edited, so this file is the one place // it can change from — and the inventory that carries it to the new-tab @@ -466,6 +475,11 @@ fn main() { // the log is the only place the reason can survive. if let Some(register) = explorer_menu_action_from(&args) { let result = if register { + // The verb labels are localized, and this process stops at the + // `return` below — it never reaches the `set_locale` on the GUI + // path. Without this read every install would write English + // entries, whatever language the user runs tty7 in. + crate::ui::i18n::set_locale(&Config::load().gui_language); crate::core::explorer_context_menu::register() } else { crate::core::explorer_context_menu::unregister() diff --git a/src/ui/app.rs b/src/ui/app.rs index f7bf6f5d..7c3d91d5 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -5434,6 +5434,10 @@ impl Tty7App { set_locale(code); cx.global::().save(); set_menus(cx); + // Explorer reads its menu wording from the registry, so it is the one + // surface a language change does not reach on its own. No-op unless + // the user installed the context menu, and off Windows entirely. + crate::core::explorer_context_menu::refresh_labels(); self.refresh_locale_state(window, cx); crate::ui::windows::WindowRegistry::refresh_locale(cx, Some(self.workspace)); } diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index ae2c1cfb..5df37e30 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -128,7 +128,13 @@ pub fn translate_en(key: L10nKey) -> &'static str { "How opaque the window background is, for every theme. Below 100% the desktop shows through." } L10nKey::SettingsBlur => "Blur", - L10nKey::SettingsBlurDesc => "Blur whatever is behind a translucent window (macOS).", + L10nKey::SettingsBlurDesc => { + if cfg!(target_os = "macos") { + "Blur whatever is behind a translucent window." + } else { + "Blur whatever is behind a translucent window. Needs a compositor that offers it — KDE Plasma does; GNOME and plain X11 leave the window merely transparent." + } + } L10nKey::SettingsBlurAutoDesc => { "Blur whatever is behind a translucent window. Only applies while Background material is Auto." } @@ -172,6 +178,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ThemeDuplicateFailed => "Could not duplicate the theme", L10nKey::ThemeSaveFailed => "Could not save the theme", L10nKey::OpenInFileManagerFailed => "Could not open {path}", + L10nKey::ExplorerMenuOpenIn => "Open in tty7", + L10nKey::ExplorerMenuOpenHere => "Open tty7 here", L10nKey::SettingsCustomThemesIntro => { "Duplicate a theme to edit its colors, or drop a tty7 YAML theme or iTerm2 .itermcolors file in the themes folder." } @@ -344,7 +352,15 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsConnectTimeout => "Connect timeout (s)", L10nKey::SettingsConnectTimeoutDesc => "Blank = library default.", L10nKey::SettingsX11Forwarding => "X11 forwarding", - L10nKey::SettingsX11ForwardingDesc => "Request X11 forwarding (needs XQuartz on macOS).", + L10nKey::SettingsX11ForwardingDesc => { + if cfg!(target_os = "macos") { + "Request X11 forwarding (needs XQuartz)." + } else if cfg!(target_os = "windows") { + "Request X11 forwarding (needs an X server running, such as VcXsrv or X410)." + } else { + "Request X11 forwarding." + } + } L10nKey::SettingsShellIntegration => "Shell integration", L10nKey::SettingsShellIntegrationDesc => { "Let the remote shell report prompts, exit codes, and the working directory." @@ -477,7 +493,11 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::SettingsCopyOnSelect => "Copy on select", L10nKey::SettingsCopyOnSelectDesc => { - "Selecting text with the mouse copies it to the clipboard right away, no ⌘C needed." + if cfg!(target_os = "macos") { + "Selecting text with the mouse copies it to the clipboard right away, no ⌘C needed." + } else { + "Selecting text with the mouse copies it to the clipboard right away, no Ctrl+Shift+C needed." + } } L10nKey::SettingsTrimTrailingSpaces => "Trim trailing spaces on copy", L10nKey::SettingsTrimTrailingSpacesDesc => { diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 56f6f416..507a8ac6 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -135,7 +135,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "すべてのテーマにおけるウィンドウ背景の不透明度。100% 未満ではデスクトップが透けて見えます" } L10nKey::SettingsBlur => "背景のぼかし", - L10nKey::SettingsBlurDesc => "半透明ウィンドウの背後にあるものをぼかす(macOS)", + L10nKey::SettingsBlurDesc => { + if cfg!(target_os = "macos") { + "半透明ウィンドウの背後にあるものをぼかす" + } else { + "半透明ウィンドウの背後にあるものをぼかす。対応するコンポジターが必要です(KDE Plasma は対応、GNOME と素の X11 ではウィンドウが透けるだけです)" + } + } L10nKey::SettingsBlurAutoDesc => { "半透明ウィンドウの背後にあるものをぼかす。背景マテリアルが「自動」のときのみ有効です" } @@ -175,6 +181,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ThemeDuplicateFailed => "テーマを複製できませんでした", L10nKey::ThemeSaveFailed => "テーマを保存できませんでした", L10nKey::OpenInFileManagerFailed => "{path} を開けませんでした", + L10nKey::ExplorerMenuOpenIn => "tty7 で開く", + L10nKey::ExplorerMenuOpenHere => "ここで tty7 を開く", L10nKey::SettingsCustomThemesIntro => { "テーマを複製して色を編集するか、tty7 の YAML テーマや iTerm2 の .itermcolors をテーマフォルダに置いてください" } @@ -349,7 +357,15 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsConnectTimeout => "接続タイムアウト(秒)", L10nKey::SettingsConnectTimeoutDesc => "空欄 = ライブラリのデフォルト", L10nKey::SettingsX11Forwarding => "X11 転送", - L10nKey::SettingsX11ForwardingDesc => "X11 転送を要求(macOS では XQuartz が必要)", + L10nKey::SettingsX11ForwardingDesc => { + if cfg!(target_os = "macos") { + "X11 転送を要求(XQuartz が必要)" + } else if cfg!(target_os = "windows") { + "X11 転送を要求(VcXsrv や X410 などの X サーバーの起動が必要)" + } else { + "X11 転送を要求" + } + } L10nKey::SettingsShellIntegration => "シェル統合", L10nKey::SettingsShellIntegrationDesc => { "リモートシェルにプロンプト・終了コード・作業ディレクトリを報告させる" @@ -488,7 +504,11 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::SettingsCopyOnSelect => "選択時に自動コピー", L10nKey::SettingsCopyOnSelectDesc => { - "マウスでテキストを選択するとすぐにクリップボードへコピーされます。⌘C は不要です" + if cfg!(target_os = "macos") { + "マウスでテキストを選択するとすぐにクリップボードへコピーされます。⌘C は不要です" + } else { + "マウスでテキストを選択するとすぐにクリップボードへコピーされます。Ctrl+Shift+C は不要です" + } } L10nKey::SettingsTrimTrailingSpaces => "コピー時に末尾の空白を除去", L10nKey::SettingsTrimTrailingSpacesDesc => "コピーした各行の末尾の空白を除去する", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 4e012720..f9d8e5af 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -207,6 +207,8 @@ l10n_keys! { ThemeDuplicateFailed, ThemeSaveFailed, OpenInFileManagerFailed, + ExplorerMenuOpenIn, + ExplorerMenuOpenHere, SettingsCustomThemesIntro, SettingsDuplicateToEdit, SettingsHosts, @@ -1581,6 +1583,39 @@ mod tests { } } + /// Three settings rows talked about macOS as if it were the only platform + /// they were ever shown on: "(macOS)" on a blur switch Linux honors too, + /// "⌘C" on a shortcut that is Ctrl+Shift+C everywhere else, and XQuartz as + /// the only X server anyone could need. All three were wrong in all three + /// languages at once — each translation had faithfully carried the English + /// text's assumption across — which is why this walks every locale rather + /// than trusting en to stand for them. + #[test] + fn wording_that_names_a_platform_names_this_one() { + for lang in SUPPORTED_LANGUAGES { + set_locale(lang.code); + let code = lang.code; + + let copy = t(L10nKey::SettingsCopyOnSelectDesc); + let x11 = t(L10nKey::SettingsX11ForwardingDesc); + + // This row is shown on macOS and Linux alike (Windows gets the + // backdrop picker instead), and both honor the switch, so naming + // either one of them is wrong wherever it is read. + let blur = t(L10nKey::SettingsBlurDesc); + assert!(!blur.contains("macOS"), "{code} blur desc: {blur:?}"); + + if cfg!(target_os = "macos") { + assert!(copy.contains('⌘'), "{code} copy-on-select: {copy:?}"); + assert!(x11.contains("XQuartz"), "{code} x11: {x11:?}"); + } else { + assert!(!copy.contains('⌘'), "{code} copy-on-select: {copy:?}"); + assert!(!x11.contains("XQuartz"), "{code} x11: {x11:?}"); + } + } + set_locale(default_language_code()); + } + #[test] fn explicit_languages_select_the_right_locale() { set_locale("zh-CN"); diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 65463832..2adc7f53 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -117,7 +117,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "窗口背景的不透明度,适用于所有主题。低于 100% 时可以看到桌面。" } L10nKey::SettingsBlur => "模糊", - L10nKey::SettingsBlurDesc => "模糊半透明窗口背后的内容(macOS)。", + L10nKey::SettingsBlurDesc => { + if cfg!(target_os = "macos") { + "模糊半透明窗口背后的内容。" + } else { + "模糊半透明窗口背后的内容。需要合成器支持——KDE Plasma 可以;GNOME 和裸 X11 下窗口只会变透明。" + } + } L10nKey::SettingsBlurAutoDesc => { "模糊半透明窗口背后的内容。仅在「背景材质」为「自动」时生效。" } @@ -155,6 +161,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ThemeDuplicateFailed => "无法复制主题", L10nKey::ThemeSaveFailed => "无法保存主题", L10nKey::OpenInFileManagerFailed => "无法打开 {path}", + L10nKey::ExplorerMenuOpenIn => "在 tty7 中打开", + L10nKey::ExplorerMenuOpenHere => "在此处打开 tty7", L10nKey::SettingsCustomThemesIntro => { "复制一个主题即可在此编辑颜色,或把 tty7 YAML 主题、iTerm2 .itermcolors 文件放进主题文件夹。" } @@ -307,7 +315,15 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsConnectTimeout => "连接超时(秒)", L10nKey::SettingsConnectTimeoutDesc => "留空 = 库默认值。", L10nKey::SettingsX11Forwarding => "X11 转发", - L10nKey::SettingsX11ForwardingDesc => "请求 X11 转发(macOS 上需要 XQuartz)。", + L10nKey::SettingsX11ForwardingDesc => { + if cfg!(target_os = "macos") { + "请求 X11 转发(需要 XQuartz)。" + } else if cfg!(target_os = "windows") { + "请求 X11 转发(需要运行 X 服务端,如 VcXsrv 或 X410)。" + } else { + "请求 X11 转发。" + } + } L10nKey::SettingsShellIntegration => "Shell 集成", L10nKey::SettingsShellIntegrationDesc => "让远程 shell 报告提示符、退出码和工作目录。", L10nKey::SettingsLoginScripts => "登录脚本", @@ -420,7 +436,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "双击选择光标下的完整 URL、文件路径、邮箱或成对的括号。" } L10nKey::SettingsCopyOnSelect => "选中即复制", - L10nKey::SettingsCopyOnSelectDesc => "用鼠标选中文本时立即复制到剪贴板,无需按 ⌘C。", + L10nKey::SettingsCopyOnSelectDesc => { + if cfg!(target_os = "macos") { + "用鼠标选中文本时立即复制到剪贴板,无需按 ⌘C。" + } else { + "用鼠标选中文本时立即复制到剪贴板,无需按 Ctrl+Shift+C。" + } + } L10nKey::SettingsTrimTrailingSpaces => "复制时去除末尾空格", L10nKey::SettingsTrimTrailingSpacesDesc => "去除每行复制文本末尾的空白。", L10nKey::SettingsKeyboard => "键盘", diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 70938108..c6817ba0 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -2523,9 +2523,10 @@ impl Tty7App { .into_any_element(); self.settings_row( t(L10nKey::SettingsBlur), - // Not `SettingsBlurDesc` — that one says "(macOS)", which is - // exactly wrong here. This row explains the flag's one - // remaining job on Windows: feeding the `Auto` material. + // Not `SettingsBlurDesc` — that one describes the switch's + // usual job, blurring whatever sits behind the window. This + // row explains its one remaining job on Windows: feeding the + // `Auto` material. t(L10nKey::SettingsBlurAutoDesc), control, cx, From 024d3689250e1f1fea96e842a84f7e011a2f9026 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:02:11 +0800 Subject: [PATCH 28/33] docs(skill): restructure the agent skill around a delegation playbook (#702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(skill): restructure around a delegation playbook SKILL.md becomes a slim routing layer: a what-are-you-here-to-do section up front, the pane/run/wait primitives, and four delegation rules that survive even when the reference is skipped. Everything specific to running another agent moves to references/delegation.md, which adds what the old text never had: per-worker git worktree isolation, a delivery contract collected through git instead of screen scraping, a launch-verification checklist, a babysit loop, and a fan-out harvest with short per-worker timeouts so one stuck worker cannot stall the round. Also replaces the last remaining 'claude -p' example (the fan-out one #699 missed) and keeps every snippet valid under both bash 3.2 and zsh. * docs(skill): un-deadlock the fan-out harvest loop Fresh read of SKILL.md and references/delegation.md. Every internal anchor resolves and the two files agree on the primitives; three things did not hold up: - The harvest loop passed `--changed`, which cannot work there. `wait` compares against the state standing when *that* wait began, so a worker that reached `done` while you were waiting on a different one is already in `done` when its own turn in the round comes up — refused, every round, forever. Each pane runs one turn, so a standing `done` is this turn's; drop the flag and note the one thing it was buying (a just-answered `waiting` worker needs to leave that state before it is requeued). - The same loop folded `wait`'s exit 1 into its 124 branch, so a pane that died got requeued instead of reported — and requeued at full speed, since a dead pane answers immediately. Split the three codes. - SKILL.md described `--plain` unwrapping "a line the shell wrapped at column 249" while two other passages state a pane is 120 columns. Say "at the pane's width", as references/commands.md already does. No typos or grammar slips found. Every bash block in both files parses under bash 3.2 and zsh. * docs(skill): two failure modes from the playbook's first live run Dogfooded the delegation playbook end to end (worker reviewing this very file). Two failures it hit that the text did not cover: - A turn aborted by an API error emits no turn boundary, so the status stands at 'working' forever and wait sleeps through it. Diagnose from the screen's error line; recover by telling the still-alive interactive session to continue. - A short capture tail cuts off the spinner line and shows only the TUI's always-present input box, which reads as idle. Tail 15+ lines and read for the spinner; 'bottom looks like a prompt' is only evidence on a shell pane. --- docs/cli/agent-skill.mdx | 12 +- skills/tty7/SKILL.md | 218 ++++++-------------- skills/tty7/references/delegation.md | 287 +++++++++++++++++++++++++++ 3 files changed, 356 insertions(+), 161 deletions(-) create mode 100644 skills/tty7/references/delegation.md diff --git a/docs/cli/agent-skill.mdx b/docs/cli/agent-skill.mdx index 4aa321b5..09fcaaa2 100644 --- a/docs/cli/agent-skill.mdx +++ b/docs/cli/agent-skill.mdx @@ -15,7 +15,8 @@ npx skills add l0ng-ai/tty7 The source lives at [`skills/tty7/`](https://github.com/l0ng-ai/tty7/tree/main/skills/tty7) in the -repository: a `SKILL.md` and a full command reference. +repository: a `SKILL.md` and two references — the full command table, and a +delegation playbook the agent reads before handing work to another agent. This is the only skill tty7 has — nothing in **Settings → Agents** installs @@ -89,6 +90,15 @@ tty7 send "$PANE" 'cargo test > /tmp/t.log 2>&1; echo $? > /tmp/t.rc' --enter tty7 wait %3 --until waiting,done --changed --timeout 600 ``` +### How to delegate + +`references/delegation.md` is the part the agent reads before spawning a +worker: give the worker its own git worktree and workspace, hand the task over +in interactive mode with the delivery contract in the prompt, prove the +command actually started, babysit with `tty7 wait`, collect the result out of +git rather than the screen, clean up — and, for a fan-out, harvest with short +per-worker timeouts so one stuck worker cannot stall the round. + ## For humans writing their own tooling The same material is worth reading even if you are not an agent — it is the diff --git a/skills/tty7/SKILL.md b/skills/tty7/SKILL.md index 36c23f67..40ead057 100644 --- a/skills/tty7/SKILL.md +++ b/skills/tty7/SKILL.md @@ -1,7 +1,7 @@ --- name: tty7 description: >- - Drive the tty7 terminal workbench from the shell with the `tty7` binary — list workspaces/tabs/panes, split a pane, send text or keystrokes into one, capture what is on a pane's screen, run a command in a real PTY and pass its exit code through, block until a pane finishes or needs input, see which coding agents are running and which ports a pane is listening on. Use this whenever tty7, panes, workspaces, or `%42`/`@7`/"the other pane"/"the other agent" come up; whenever you want to hand work to another agent and collect the result ("get Claude/Codex to do X", "派个活", "let another agent handle this", running several agents in parallel); whenever you need to start something long-running or interactive (dev server, REPL, ssh session, `tail -f`, a TUI) that should not sit blocking your Bash tool; whenever a program needs a real terminal to behave the way the user sees it; and whenever you need to look at or report on what is running in some *other* terminal on this machine. Cheap to check: if `$TTY7_PANE` is set you are already inside tty7 and every command here works with no setup. + Drive the tty7 terminal workbench from the shell with the `tty7` binary — list workspaces/tabs/panes, split a pane, send text or keystrokes into one, capture what is on a pane's screen, run a command in a real PTY and pass its exit code through, block until a pane finishes or needs input, see which coding agents are running and which ports a pane is listening on. Use this whenever tty7, panes, workspaces, or `%42`/`@7`/"the other pane"/"the other agent" come up; whenever you want to hand work to another agent and collect the result ("get Claude/Codex to do X", "派个活", "let another agent handle this", running several agents in parallel and merging what they produce); whenever you need to start something long-running or interactive (dev server, REPL, ssh session, `tail -f`, a TUI) that should not sit blocking your Bash tool; whenever a program needs a real terminal to behave the way the user sees it; and whenever you need to look at or report on what is running in some *other* terminal on this machine. Cheap to check: if `$TTY7_PANE` is set you are already inside tty7 and every command here works with no setup. --- # Driving tty7 from the command line @@ -34,27 +34,25 @@ If `tty7 doctor` says the server is unreachable, stop and tell the user — do not run `tty7 server start` on your own initiative. Starting a server they didn't ask for changes what their GUI attaches to. -## When to use this instead of the Bash tool +## What are you here to do? -The Bash tool is right for anything that starts, does its job, and exits. -Reach for tty7 when one of these is true: +Four jobs, four shapes: -- **It shouldn't block you.** A dev server, a watcher, `tail -f`, a long test - run you want to check on later. Put it in a pane, come back and read it. -- **It's interactive or stateful.** A REPL, `ssh`, a database shell, anything - where you send one thing, read the answer, then send the next. A pane keeps - the session alive between your turns; a Bash call cannot. -- **It needs a real TTY.** Programs that detect a pipe and change behaviour — - colour, progress bars, TUIs, `top`, anything using raw mode. `tty7 run` - gives a genuine PTY at 120×30. -- **The user should be able to watch it.** Anything in a pane shows up in their - tty7 window, live. That is often the whole point. -- **You're being asked about something you didn't start.** "What's running in - that pane?", "why is port 3000 taken?", "what are my agents doing?" — you can - answer those from here without touching anything. -- **Someone else should do the work.** Another coding agent can run in a pane, - and you can wait on it and read its answer. See [Handing work to another - agent](#handing-work-to-another-agent). +1. **Run something that shouldn't block you or needs a real TTY** — a dev + server, a long test run, a TUI. [Running a command](#running-a-command-two-shapes). +2. **Talk to something stateful over time** — a REPL, `ssh`, a debugger. + Same primitives: [send](#non-blocking-a-pane-you-talk-to-over-time), + [read](#reading-a-pane), repeat. +3. **Look at what this machine is doing** — other panes, other agents, ports. + [Looking around](#looking-around), strictly read-only. +4. **Hand work to another coding agent** — one worker or a fan-out of several. + Read `references/delegation.md` first; the short version is + [below](#handing-work-to-another-agent). + +The Bash tool remains right for anything that starts, does its job, and exits +without needing a terminal or an audience. A pane earns its keep when the +process outlives your turn, needs a real PTY, or should be visible to the user +in their tty7 window — that last one is often the whole point. ## Addresses @@ -131,8 +129,11 @@ wait and it does not tell you what happened — reading is a separate step, and waiting is `tty7 wait`. For keystrokes rather than characters — Ctrl-C, Escape, the arrow keys — use -`--key` (see [Answering a prompt](#answering-a-prompt)). Typing `^C` as text -does nothing; it arrives as two characters. +`--key`: it takes `enter escape tab backtab space backspace delete up down +right left home end pageup pagedown`, plus `C-` for Ctrl and `M-` +for Alt. Repeat it for a sequence; text and keys compose, text first. Typing +`^C` as text does nothing — it arrives as two characters; `--key C-c` is the +real interrupt. **A brand-new pane can swallow the Enter.** A shell still working through its startup files — a prompt framework, `fastfetch`, anything that paints on login — @@ -161,9 +162,10 @@ tty7 capture %83 --plain `capture` hands back what the daemon stored — the pane's bytes, escapes and all — and `--plain` replays them through a terminal grid and prints the resulting text instead. Not a stripper: colour and cursor escapes are gone, but -also a line the shell wrapped at column 249 comes back as one line, a progress -bar that rewrote itself with `\r` reads as its final value, and a TUI's screen -lands where it was drawn. Use it whenever a human would want to read the output. +also a line the shell wrapped at the pane's width comes back as one line, a +progress bar that rewrote itself with `\r` reads as its final value, and a +TUI's screen lands where it was drawn. Use it whenever a human would want to +read the output. Two details about what you get back either way: capture returns a *snapshot*, not a stream — call it again for a newer one. And by default it prints the @@ -218,143 +220,35 @@ For something that quick, `--interval 100`, or drop `--changed` and read the If you want the process tree itself — "what is running in there", "which port is this pane serving" — that is `tty7 procs %83`: indented by depth, `*` on the -foreground process, then the ports those processes are listening on. - -It is not the way to check on a coding agent, though. `procs` reports -`nothing running in this pane` for a pane with a busy agent in it, so reading it -as "the worker died" is wrong. Ask `tty7 agents` about those, or see -[When a worker never moves](#when-a-worker-never-moves). +foreground process, then the ports those processes are listening on. It is not +the way to check on a coding agent, though: `procs` reports `nothing running in +this pane` for a pane with a busy agent in it, so reading it as "the worker +died" is wrong. Ask `tty7 agents` about those. ## Handing work to another agent -Everything above also works when the thing in the pane is a coding agent, and -that is where this stops being a terminal wrapper and starts being useful. An -agent reports its own status, so you can wait on *it* rather than on its -process tree: +A pane can hold another coding agent, and every primitive above works on it — +plus one that only agents have: status hooks report `working` / `waiting` / +`done`, so `tty7 wait` can block on the *agent* rather than its process tree. -```bash -PANE=$(tty7 split --v) -tty7 send "$PANE" 'claude --dangerously-skip-permissions "add tests for the parser"' --enter -tty7 wait "$PANE" --until waiting,done --changed --timeout 900 -tty7 capture "$PANE" --plain | tail -40 -tty7 pane close "$PANE" -``` +**Delegation has a playbook — `references/delegation.md`. Read it before you +spawn a worker.** It covers the whole arc: giving the worker its own git +worktree and workspace, handing the task over with the delivery contract in the +prompt, proving the command actually started, babysitting the states, collecting +the result out of git, fanning out several workers, and cleaning up. -Five steps: give it a pane, hand it the task, sleep until it needs you or -finishes, read what happened, clean up. The third is the one worth -understanding. +Four rules from it survive even if you read nothing else: -### Give the worker its interactive mode - -Hand the task as an argument, **not** with `-p`. Both run one turn and stop, so -the difference is not what the worker does — it is what anybody can see while it -does it. - -Interactive is the mode that draws a TUI, so the pane fills with the worker's -reasoning and tool calls as they happen. That is visible to the user in their -tty7 window, and it is what `capture --plain` reads back. `-p` is the piped -mode: it draws nothing, streams its answer to stdout when the turn ends, and -until then the pane's screen stays **empty** — `capture --plain` on it returns -nothing at all, which reads exactly like a worker that hung. Putting a `-p` -worker in a pane throws away the only reason it is in a pane. - -Interactive also leaves the session alive at the prompt, so you can `send` a -follow-up into the same context. A `-p` worker is gone after its one turn. - -Reach for `-p` only when you want the answer as a string and nobody needs to -watch — and then prefer `tty7 run` or the Bash tool, which is what that shape -is for. - -### What the states mean - -| State | The pane is | -|---|---| -| `working` | mid-turn | -| `waiting` | **stopped, needing you** — a permission prompt, a question | -| `done` | finished its turn | -| `idle` | an agent that has not started a turn | -| `free` | no agent: the foreground command exited (see above) | -| `no-agent` | nothing reports status here — a plain shell, or hooks not installed | -| `exit` | the pane is gone; ends every wait whether you asked for it or not | - -`--until waiting,done,exit` is the default because those are the three that mean -"your turn again". Note that `idle` is something an agent says about *itself* — -a pane running a build is `no-agent`, never `idle`, so `--until idle` is never -the way to ask "is the command finished". That is `free`. - -Mixing the two is safe: `--until waiting,done,free` covers a pane whose kind you -don't know, because `free` is only consulted when none of the agent states you -named matched first. - -### `--changed` is not optional in a loop - -The status is a **level, not an event**: `done` stands until the next turn -begins. So a `wait` issued right after a `send` will happily answer with *last* -turn's `done` before the worker has even read the input, and you will read a -stale screen and think it failed. `--changed` refuses the state the pane was -already in. Every round after the first needs it; the JSON's `stale` flag tells -you when it mattered. - -### Answering a prompt - -A worker that stops at `waiting` is usually showing something that text cannot -answer — a permission prompt driven by arrow keys, a menu, a TUI. Look first, -then press keys: - -```bash -tty7 capture "$PANE" --plain | tail -20 # what is it asking? -tty7 send "$PANE" --key down --key enter # answer it -tty7 send "$PANE" --key C-c # or stop it -``` - -`--key` takes `enter escape tab backtab space backspace delete up down right -left home end pageup pagedown`, plus `C-` for Ctrl and `M-` for -Alt. Repeat it for a sequence; text and keys compose, text first. This is also -how you interrupt a runaway command in a pane you own — `--key C-c` — which -plain `send` cannot express. - -### Running several at once - -Panes are independent, so fan out and then collect: - -```bash -for task in parser lexer codegen; do - P=$(tty7 split --v) - tty7 send "$P" "claude -p 'add tests for the $task'" --enter - echo "$P" >> /tmp/workers -done -while read -r P; do - tty7 wait "$P" --until done,exit --changed --timeout 1800 || echo "$P did not finish" - tty7 capture "$P" --plain | tail -40 - tty7 pane close "$P" -done < /tmp/workers -``` - -Splitting repeatedly makes the user's window very busy; `tty7 new` gives each -worker its own workspace instead if you would rather not. - -### When a worker never moves - -A `wait` that times out while `tty7 agents` shows a status that never changes -almost always means the agent's status hooks are missing or out of date — the -worker is fine, it just has no way to say so. `tty7 agents` names the agent when -it can see the gap, and `tty7 doctor` reports where every agent's hooks stand. -Hooks are installed from the GUI's **Settings → Agents**; tell the user rather -than trying to install them yourself. - -Before concluding anything, check whether it is moving. Those same hooks emit an -OSC 777 line on every tool call, and `capture` **without** `--plain` shows them — -one of the few times the raw bytes beat the rendered screen: - -```bash -tty7 capture "$PANE" | grep -c 'tool-complete' # rising = alive and working -``` - -That is also the answer when a worker's screen looks empty: a `-p` worker paints -nothing until its turn ends, so `capture --plain` is blank the whole way through -while the event stream underneath is busy. Two things that do *not* answer this -question: `tty7 procs`, which reports nothing running for a pane with a live -agent in it, and the absence of output on a `--plain` capture. +- **Interactive mode, never `-p`.** `claude -p` draws no TUI: the pane stays + blank, `capture --plain` returns nothing, the user watches an empty + rectangle, and the session dies after one turn so you cannot follow up. Hand + the task as an argument to the interactive command instead. +- **A worker that writes files gets its own git worktree.** Two agents in one + checkout trample each other and the user's working tree. +- **Collect results from git, not from the screen.** Tell the worker to commit; + read the diff. A screen is a rectangle and the top of it is gone. +- **After the first send into a new pane, confirm the command left the + prompt** — the swallowed-Enter check above. ## Looking around @@ -420,8 +314,12 @@ from the GUI. `ws stop`, `machine connect` and `machine disconnect` exit with a message saying they're not implemented. Don't build a plan around them. -## Full command reference +## References -`references/commands.md` has every verb, subcommand and flag in one table, plus -the JSON shape each one emits. Read it when you need a verb that isn't above, -or when you're about to parse `--json` output and want to know the field names. +- `references/delegation.md` — the delegation playbook: worktrees, handover, + babysitting, collection, fan-out, cleanup. Read it whenever another agent is + about to do the work. +- `references/commands.md` — every verb, subcommand and flag in one table, plus + the JSON shape each one emits. Read it when you need a verb that isn't above, + or when you're about to parse `--json` output and want to know the field + names. diff --git a/skills/tty7/references/delegation.md b/skills/tty7/references/delegation.md new file mode 100644 index 00000000..1e9b13b8 --- /dev/null +++ b/skills/tty7/references/delegation.md @@ -0,0 +1,287 @@ +# Delegating work to another coding agent + +A worker in a pane is a real agent session the user can watch, interrupt, and +take over — that is what makes a pane better than an API call. This playbook is +the arc of one delegation, in order: + +1. [Give it a place to work](#1-a-place-to-work) — its own worktree and workspace +2. [Hand over the task](#2-handing-over-the-task) — interactive mode, delivery contract in the prompt +3. [Prove it started](#3-prove-it-started) — the three ways a launched worker silently isn't +4. [Wait, and babysit](#4-wait-and-babysit) — states, `--changed`, answering prompts +5. [Collect through git](#5-collect-through-git-not-the-screen) — the screen is a diagnostic, not a deliverable +6. [Clean up](#6-clean-up) + +[Fan-out](#running-several-workers) builds on the same six steps, one worker at +a time. + +## 1. A place to work + +**A worker that will write files gets its own git worktree.** Two agents in one +checkout — or one agent in the checkout the user is editing — trample each +other: half-written files show up in each other's diffs, builds race, and +`git status` stops meaning anything. The whole industry of parallel-agent +tooling converged on the same answer: one task, one worktree, one branch. + +```bash +REPO=/path/to/repo +WT=/tmp/agent-wt/parser-tests +git -C "$REPO" worktree add "$WT" -b agent/parser-tests +read -r WS PANE < <(tty7 new --json "$WT" \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["id"], "%%%d" % d["pane"])') +``` + +`tty7 new` rather than `split` for the same reason as the worktree, one level +up: a workspace of its own keeps the worker out of the user's window layout. +Splitting your own pane is fine for one short-lived worker the user wants to +watch; three splits deep the window is unusable. + +Skip the worktree only when the worker will not write: a research question, a +code-reading task, a second opinion. For those, `split` in the current checkout +is fine. And if the user explicitly wants the worker operating on their live +checkout, say what the risk is and do as asked. + +## 2. Handing over the task + +Hand the task as an argument to the **interactive** command — never `-p`: + +```bash +TASK="Add tests for the tokenizer edge cases in src/lexer.rs. +When you are done, commit to the current branch with a message +saying what you did and what you verified. Do not push." +tty7 send "$PANE" "claude --dangerously-skip-permissions \"$TASK\"" --enter +``` + +Why interactive: both modes run one turn and stop, so the difference is not +what the worker does — it is what anybody can see while it does it. +Interactive draws the TUI, so the pane fills with the worker's reasoning and +tool calls as they happen; that is what the user watches and what +`capture --plain` reads back. `-p` is the piped mode: it draws nothing, the +pane's screen stays **empty** until the turn ends — which reads exactly like a +hung worker — and the session is gone afterwards, so you cannot ask a +follow-up. Putting a `-p` worker in a pane throws away the only reason it is +in a pane. When you want an answer as a string and nobody needs to watch, +that's `tty7 run` or your own Bash tool, not a pane. + +Two things belong in every task prompt: + +- **The delivery contract.** "Commit to the current branch when done" turns + the result into something git can hand you complete — see + [step 5](#5-collect-through-git-not-the-screen). Without it you are left + reading a 40-line tail of a screen and guessing. +- **The boundaries.** "Do not push", "stay in this directory", "ask before + deleting" — a worker inherits none of your context, only its prompt. + +## 3. Prove it started + +Three independent ways a worker you just launched is silently not running, and +the checks that catch each: + +| Failure | What you see | The check | +|---|---|---| +| A fresh shell swallowed the Enter | command sits on the prompt, never runs | `capture --plain \| tail -3`; still on the prompt → `tty7 send "$PANE" --enter` | +| `tty7 agents` reports stale state | a pane stuck at the prompt can still show `working` | the screen's last lines are the authority, not the status | +| `tty7 procs` says `nothing running` | looks like the worker died | it hasn't — `procs` cannot see agents; ignore it here | + +The first check is mandatory after the first `send` into any pane you just +created. Thirty seconds here beats a 900-second `wait` that times out on a +worker that never began. + +## 4. Wait, and babysit + +```bash +tty7 wait "$PANE" --until waiting,done --changed --timeout 1800 +``` + +### What the states mean + +| State | The pane is | +|---|---| +| `working` | mid-turn | +| `waiting` | **stopped, needing you** — a permission prompt, a question | +| `done` | finished its turn | +| `idle` | an agent that has not started a turn | +| `free` | no agent: the foreground command exited | +| `no-agent` | nothing reports status here — a plain shell, or hooks not installed | +| `exit` | the pane is gone; ends every wait whether you asked for it or not | + +`--until waiting,done,exit` is the default because those are the three that +mean "your turn again". `idle` is something an agent says about *itself* — a +pane running a build is `no-agent`, never `idle`, so `--until idle` is never +the way to ask "is the command finished"; that is `free`. Mixing agent states +with `free` is safe: `free` is only consulted when no named agent state +matched first. + +### `--changed` on every wait that follows a send + +The status is a **level, not an event**: `done` stands until the next turn +begins. A `wait` issued right after a `send` will happily answer with *last* +turn's `done` before the worker has even read the input, and you will read a +stale screen and think it failed. `--changed` refuses the state the pane was +already in. Every wait that follows input into the pane needs it; the JSON's +`stale` flag tells you when it mattered. A wait that follows *nothing* — the +harvest round in [fan-out](#running-several-workers) — is the case that must +not use it. + +### The babysit loop + +`waiting` means the worker stopped for a human. Be that human when you can: + +```bash +tty7 capture "$PANE" --plain | tail -20 # what is it asking? +tty7 send "$PANE" --key down --key enter # answer a menu / permission prompt +tty7 send "$PANE" 'yes, use the existing fixture file' --enter # answer a question +``` + +Then go back to waiting. Answer what the task's boundaries already cover; +anything outside them — a destructive action, a scope change, credentials — +gets reported to the user instead, with the pane id so they can look +themselves. A worker you cannot safely answer is a worker the user takes over; +that handover is a feature, not a failure. + +To stop a runaway worker: `tty7 send "$PANE" --key C-c` — typing `^C` as text +arrives as two harmless characters. + +Exit codes from `wait`: `0` a state you asked for was reached, `124` timeout +(the `timeout(1)` convention — "not yet", distinguishable from broken), `1` +the pane died. + +## 5. Collect through git, not the screen + +The delivery contract from step 2 pays off here: + +```bash +git -C "$WT" log --oneline main..HEAD # what it says it shipped +git -C "$WT" diff main...HEAD # the changes themselves +git -C "$WT" status --short # anything it left uncommitted +``` + +Complete, unwrapped, with nothing scrolled away — and reviewable before a +single byte reaches the user's branch. You are the merge point: read the diff, +run the tests if the task warranted them, then merge or report. + +`capture --plain | tail` is for *diagnosis* — what is it stuck on, what did it +just print — not for collecting results. A screen is a 120-column rectangle +and the interesting part has usually scrolled off the top of it. + +If the worker finished but committed nothing, the screen is where you find out +why; that is the one time the tail is the deliverable. + +## 6. Clean up + +```bash +tty7 ws rm "$WS" # hangs up the workspace's panes +git -C "$REPO" worktree remove "$WT" # refuses if dirty — that's a feature +git -C "$REPO" branch -D agent/parser-tests # once merged, or rejected +``` + +`worktree remove` refusing means uncommitted work is sitting there — look at +it before deciding anything, and ask the user rather than `--force`-ing away +changes you have not read. A worker you opened with `split` instead is just +`tty7 pane close "$PANE"`. + +## Running several workers + +Fan-out is the six steps above per worker — **each with its own worktree and +branch** — plus a harvest loop that no single stuck worker can stall: + +```bash +: > /tmp/agent-workers +for task in parser lexer codegen; do + WT=/tmp/agent-wt/$task + git -C "$REPO" worktree add "$WT" -b "agent/$task" + read -r WS PANE < <(tty7 new --json "$WT" \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["id"], "%%%d" % d["pane"])') + tty7 send "$PANE" "claude --dangerously-skip-permissions \"Add tests for the $task. Commit to the current branch when done; do not push.\"" --enter + echo "$task $PANE $WT $WS" >> /tmp/agent-workers +done +# prove each one started (step 3) before settling in to wait + +cp /tmp/agent-workers /tmp/agent-pending +while [ -s /tmp/agent-pending ]; do + : > /tmp/agent-still + while read -r task PANE WT WS; do + tty7 wait "$PANE" --until waiting,done --timeout 120; rc=$? + if [ $rc -eq 0 ]; then + : # done → collect (step 5); waiting → babysit (step 4), then requeue + elif [ $rc -eq 124 ]; then + echo "$task $PANE $WT $WS" >> /tmp/agent-still # not yet — come back + else + echo "$task: pane $PANE is gone" >&2 # 1: died; nothing to requeue + fi + done < /tmp/agent-pending + mv /tmp/agent-still /tmp/agent-pending +done +``` + +The short per-worker timeout is the point: with one long `wait` per worker in +sequence, the first stuck worker blinds you to every worker behind it. Round +trips of 120 seconds keep you circulating — collecting the finished, answering +the stuck, and telling the user about the one that has moved nothing for three +rounds. + +No `--changed` here, unlike step 4, and that is deliberate: a worker that +reached `done` while you were waiting on a *different* one is already standing +in that state when its own `wait` finally starts, and `--changed` would refuse +it — every round, forever. Each pane runs one turn, so a standing `done` is +this turn's. What that costs you is on the other side: after answering a +`waiting` worker, give it a `tty7 wait "$PANE" --until working --changed +--timeout 30` before you requeue it, or the next round hands you the same +prompt again. + +What not to build: workers do not talk to each other, and their branches never +merge into each other. Keep the topology a star — you hand out tasks that do +not overlap, each worker delivers to its own branch, and every diff comes back +through you, serially. Cross-cutting conflicts between two workers' branches +are yours to resolve at merge time, which is exactly why the tasks should not +overlap in the first place. + +## When a worker never moves + +A `wait` that times out while `tty7 agents` shows a status that never changes +almost always means the agent's status hooks are missing or out of date — the +worker is fine, it just has no way to say so. `tty7 agents` names the agent +when it can see the gap, and `tty7 doctor` reports where every agent's hooks +stand. Hooks are installed from the GUI's **Settings → Agents**; tell the user +rather than trying to install them yourself. + +Before concluding anything, check whether it is moving. Those same hooks emit +an OSC 777 line on every tool call, and `capture` **without** `--plain` shows +them — one of the few times the raw bytes beat the rendered screen: + +```bash +tty7 capture "$PANE" | grep -c 'tool-complete' # rising = alive and working +``` + +Two things that do *not* answer this question: `tty7 procs`, which reports +nothing running for a pane with a live agent in it, and an empty +`capture --plain` — a worker mistakenly launched with `-p` paints nothing all +turn while the event stream underneath is busy. + +### A stuck `working` can be an aborted turn + +Hooks report turn boundaries, so a turn that dies without one — an API error, +a dropped connection — leaves the status standing at `working` forever, and +your `wait` sleeps through it. The screen is where the truth is: capture the +tail and look for an error line (`API Error: Connection closed mid-response` +and its relatives) sitting above the input box. + +The recovery is the reason workers run in interactive mode: the session is +still alive at its prompt, with all its context and any half-made edits +intact. Tell it to pick the work back up — + +```bash +tty7 send "$PANE" 'Your last turn was cut off by an API error. Continue and finish the task as instructed.' --enter +``` + +— and go back to waiting. + +### Tail enough lines to see the spinner + +A TUI keeps its input box at the **bottom** of the screen, so a short tail — +`tail -5` — shows an empty prompt box whether the worker is idle or three +files deep in an edit: the line that distinguishes them is the spinner/elapsed +line a dozen rows up, and a short tail cuts it off. `tail -15` or more +whenever the question is "is it doing anything", and read for the spinner, not +the prompt. "The bottom looks like a prompt" is evidence about a *shell* pane +(step 3's launch check); on a TUI pane it is what the screen always looks +like. From d8ac3ef4541ea92ae6682307a6afce38af6ec59f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:49:31 +0800 Subject: [PATCH 29/33] docs(readme): fix the fork column and restate the feature tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The support matrix left Fork blank for Droid, Qwen, and Goose, but all three have a fork command in CLIAgent::fork_label and hooks in HookAgent::ALL, so the menu entry is reachable. Amp stays blank: it has a fork command but no hooks, so no session id ever arrives and can_fork never turns true — which the intro paragraph now states. Also restores "click places the caret" and IME to the input and window rows, folds the prose that had crept into the Agent-aware, CLI, and Git cells back into scannable fragments, and drops the Why lede's repetition of the three bullets directly beneath it. --- README.md | 62 +++++++++++++++++++++++++++---- README.zh-CN.md | 99 ++++++++++++++++++++++++++++++++++--------------- 2 files changed, 124 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 0ed27f98..a4c6c7c8 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,16 @@ ## Why +A background server owns your shells and panes — not the window. Everything +below follows from that. + - **Performance** — ~2× the throughput of Alacritty, Ghostty, or Kitty ([benchmarks](#benchmarks)) - **Persistent sessions** — quit or reboot; your shells and supported agent sessions keep running, no tmux -- **Editor-grade input** — suggestions, completion, highlighting, history search -- **Remote development** — files, repos, panes, and git data stay on the remote machine -- **Native SSH** — profiles, SFTP, port forwarding, and jump hosts -- **Agent-aware** — Claude Code, Codex & co.: status, notifications, git context -- **CLI + Skills** — agents create panes, run commands, and inspect output +- **Agent-aware** — Claude Code, Codex & co.: status, notifications, and git context for every repo at once +- **Scriptable by agents** — one agent opens a pane for another, hands off a task, waits, and reads the result, with or without the GUI running +- **Editor-grade input** — suggestions, completion, highlighting, history search, with no plugin to install +- **Remote development** — files, repos, panes, and git data stay on the remote machine, over a native SSH stack +- **Git beside the terminal** — source control, diffs, and worktrees without leaving the window ## Install @@ -48,12 +51,55 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com | | | |---|---| +| **Agent-aware** | per-pane detection (19 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository | +| **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` streams a command and exits with its code · `split` · `send` · `wait --until free` · `capture` | | **Editor-grade input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · ⌃ R fuzzy history | -| **Window** | tabs & splits · ⌘ P palette · ⌘ F scrollback search · nine themes · IME | -| **Agent-aware** | per-pane detection (19 CLIs): status dot · notifications · branch + diff · resume after reboot · tray icon when input is needed | +| **Window** | tabs & splits · ⌘ P palette · ⌘ F scrollback search · ⌘ J panel with process tree and listening ports · 13 themes, your own YAML, iTerm2 import · IME | +| **Shell integration** | injected when a pane starts, nothing to install · prompt marks · working directory · exit codes · command-finished notifications · zsh, bash, fish, PowerShell, WSL, remote panes | | **Remote workspaces** | remote files, repos, changes, diffs, worktrees, tabs, and panes · reconnect from any client and continue where you left off | -| **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · pane/workspace control · real PTY commands · output, process, port, and agent status | | **SSH** | native russh stack: profiles with keychain secrets · SFTP panel · port forwarding · jump hosts · one-time, unprivileged `tty7-server` install | +| **Git** | panel follows the focused pane · stage, commit, amend, branch, push, stash · side-by-side or unified diffs · commit graph with cherry-pick, revert, and reset · a new worktree opens its own tab | + +## Supported agents + +**Detection** is free: brand avatar, branch + diff, tab title. +**Status** takes one click under Settings → Agents to install that agent's hook, +and brings the status dot, notifications, the tray icon, `tty7 wait`, and resume +after a reboot. **Fork** needs both — the agent's own fork command, and the hook +that tells tty7 which session to fork. + +
+The full support matrix, all nineteen + +| Agent | Detected | Status · resume | Fork | +|---|:-:|:-:|:-:| +| **Claude Code** | ✓ | ✓ | ✓ | +| **Codex** | ✓ | ✓ | ✓ | +| **Grok** | ✓ | ✓ | ✓ | +| **OpenCode** | ✓ | ✓ | ✓ | +| **Oh My Pi** | ✓ | ✓ | ✓ | +| **Droid** | ✓ | ✓ | ✓ | +| **Qwen Code** | ✓ | ✓ | ✓ | +| **Goose** | ✓ | ✓ | ✓ | +| **Gemini** | ✓ | ✓ | | +| **Copilot** | ✓ | ✓ | | +| **Kimi Code** | ✓ | ✓ | | +| **Pi** | ✓ | ✓ | | +| Aider | ✓ | | | +| Amp | ✓ | | | +| Cursor | ✓ | | | +| Auggie | ✓ | | | +| Hermes | ✓ | | | +| Vibe | ✓ | | | +| Antigravity | ✓ | | | + +
+ +None of them are wrapped or proxied — the agent you start is the agent you get, +in a normal PTY, with its own interface. An agent launched through a wrapper +script can be mapped to one by name with `agent_commands` in `config.json`. + +## Documentation Full documentation lives in [**`docs/`**](docs/) — [keyboard shortcuts](docs/reference/keyboard-shortcuts.mdx) · diff --git a/README.zh-CN.md b/README.zh-CN.md index 46fb6354..014ba97a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,7 +4,7 @@ ### tty7 -**终端工作台:常驻会话、远程工作、agent。** +**终端工作台:会话常驻、远程开发、原生支持 agent。** 纯 Rust · GPU 渲染基于 Zed 的 gpui · VT 内核来自 Alacritty @@ -14,7 +14,7 @@ [![Version](https://img.shields.io/github/v/release/l0ng-ai/tty7?label=version&color=3FDD8C)](https://github.com/l0ng-ai/tty7/releases) [![Platforms](https://img.shields.io/badge/platforms-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux-blue)](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) +[![Discord](https://img.shields.io/badge/Discord-%E5%8A%A0%E5%85%A5%E8%AE%A8%E8%AE%BA-5865F2?logo=discord&logoColor=white)](https://discord.gg/s3dethqz2V) [English](README.md) · 简体中文 @@ -26,69 +26,110 @@ ## 为什么 -- **性能** —— 吞吐约为 Alacritty、Ghostty、Kitty 的 2 倍([基准测试](#基准测试)) -- **持久会话** —— 退出应用、重启机器后,shell 和已支持的 agent 会话照样运行;无需 tmux -- **编辑器级输入** —— 建议、补全、语法高亮、历史搜索 -- **远程开发** —— 文件、仓库、pane 和 git 信息都留在远端机器上 -- **原生 SSH** —— profile、SFTP、端口转发和跳板机 -- **Agent-aware** —— Claude Code、Codex 等:状态、通知、git 上下文 -- **CLI + Skills** —— agent 创建 pane、运行命令、读取输出 +真正持有 shell 和 pane 的是后台常驻的 server,不是窗口。下面这些几乎都是这一个决定的结果。 + +- **性能**:吞吐是 Alacritty、Ghostty、Kitty 的两倍左右([基准测试](#基准测试)) +- **会话常驻**:退出应用或重启机器后,shell 和已支持的 agent 会话继续运行,不需要 tmux +- **Agent 感知**:Claude Code、Codex 等 agent 的状态、通知和 git 上下文,多个仓库一屏看完 +- **可被 agent 驱动**:一个 agent 能给另一个开 pane、派活、等它跑完、读走结果,GUI 开不开都行 +- **编辑器级输入**:建议、补全、高亮、历史搜索,不用装任何插件 +- **远程开发**:文件、仓库、pane 和 git 信息都留在远端机器上,走自带的 SSH 栈 +- **Git 就在终端旁边**:源代码管理、diff、worktree,不用切出窗口 ## 安装 -三平台原生构建都在 [**Releases**](https://github.com/l0ng-ai/tty7/releases): +macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/l0ng-ai/tty7/releases): | | | | |---|---|---| | **macOS** | `…-macos-arm64.dmg` · `…-x86_64.dmg` | 拖进「应用程序」 | -| **Windows** | `…-setup.exe` · 便携版 `….zip` | | -| **Linux** | `…-x86_64.AppImage` | `chmod +x` 直接运行,X11/Wayland 依赖已打包 | +| **Windows** | `…-setup.exe` · 免安装 `….zip` | | +| **Linux** | `…-x86_64.AppImage` | `chmod +x` 后直接运行,X11/Wayland 的库已打包在内 | ## 有什么 | | | |---|---| -| **编辑器级输入** | 历史影子建议 · 带说明的 Tab 补全 · 语法高亮 · 多行编辑 · 点击定位光标 · ⌃ R 模糊历史搜索 | -| **窗口** | 标签页与分屏 · ⌘ P 命令面板 · ⌘ F 回滚搜索 · 9 套主题 · 输入法 | -| **Agent-aware** | 按 pane 识别 19 个 CLI agent:状态点 · 通知 · 分支 + diff · 重启后续上会话 · 托盘图标提醒需要输入 | -| **远程工作区** | 远端文件、仓库、Changes、diff、worktree、标签页和 pane · 任意客户端重连后原地继续 | -| **CLI + Skills** | 安装包自带 `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · pane/工作区控制 · 真实 PTY 命令 · 输出、进程、端口和 agent 状态 | -| **SSH** | 原生 russh 栈:profile 凭据进 keychain · SFTP 面板 · 端口转发 · 跳板机 · 一次无 sudo 安装 `tty7-server` | +| **Agent 感知** | 逐 pane 识别 19 个 CLI agent · 状态点 · 通知 · 分支 + diff · 需要输入时托盘图标提醒 · 重启后续上会话 · 侧边栏按仓库分组 | +| **CLI + Skills** | 安装包自带 `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` 转发命令输出并原样返回退出码 · `split` · `send` · `wait --until free` · `capture` | +| **编辑器级输入** | 从历史推出影子建议 · Tab 补全附带说明 · 语法高亮 · 多行编辑 · 点击定位光标 · ⌃ R 模糊搜索历史 | +| **窗口** | 标签页与分屏 · ⌘ P 命令面板 · ⌘ F 回滚搜索 · ⌘ J 侧栏列出进程树和监听端口 · 13 套主题,也能写自己的 YAML 或导入 iTerm2 配色 · 输入法 | +| **Shell 集成** | pane 启动时自动注入,不用你装什么 · 提示符边界 · 工作目录 · 退出码 · 命令跑完发通知 · 覆盖 zsh、bash、fish、PowerShell、WSL 和远程 pane | +| **远程工作区** | 远端的文件、仓库、改动、diff、worktree、标签页和 pane · 从任意客户端重连,接着离开时的位置继续 | +| **SSH** | 自带 russh 实现,不依赖外部 ssh:profile 凭据存入 keychain · SFTP 面板 · 端口转发 · 跳板机 · `tty7-server` 只需安装一次,无需 root | +| **Git** | 源代码管理面板跟着焦点 pane 走 · 暂存、提交、amend、切分支、push、stash · 双栏或统一 diff · 提交图谱支持 cherry-pick、revert、reset · 新建 worktree 连同它的标签页 | -完整文档在 [**`docs/`**](docs/)(英文)—— +## 支持的 agent + +**识别**无需配置:品牌头像、分支与 diff、标签页标题。 +**状态**需要在设置 → Agents 中为该 agent 安装 hook,一次点击,之后才有状态点、通知、托盘提醒、`tty7 wait` 和重启后恢复会话。 +**Fork** 两个条件都要:agent 自己提供 fork 命令,且 hook 已装——tty7 得知道 fork 的是哪个会话。 + +
+19 个 agent 的完整支持矩阵 + +| Agent | 识别 | 状态 · 重启恢复 | Fork | +|---|:-:|:-:|:-:| +| **Claude Code** | ✓ | ✓ | ✓ | +| **Codex** | ✓ | ✓ | ✓ | +| **Grok** | ✓ | ✓ | ✓ | +| **OpenCode** | ✓ | ✓ | ✓ | +| **Oh My Pi** | ✓ | ✓ | ✓ | +| **Droid** | ✓ | ✓ | ✓ | +| **Qwen Code** | ✓ | ✓ | ✓ | +| **Goose** | ✓ | ✓ | ✓ | +| **Gemini** | ✓ | ✓ | | +| **Copilot** | ✓ | ✓ | | +| **Kimi Code** | ✓ | ✓ | | +| **Pi** | ✓ | ✓ | | +| Aider | ✓ | | | +| Amp | ✓ | | | +| Cursor | ✓ | | | +| Auggie | ✓ | | | +| Hermes | ✓ | | | +| Vibe | ✓ | | | +| Antigravity | ✓ | | | + +
+ +tty7 不包装、不代理其中任何一个 —— 你启动的就是那个 agent 本身,运行在普通 PTY 中,界面仍然是它自己的。 +如果你通过 wrapper 脚本启动 agent,在 `config.json` 的 `agent_commands` 里把脚本名映射到对应 agent 即可。 + +## 文档 + +完整文档在 [**`docs/`**](docs/),英文: [快捷键](docs/reference/keyboard-shortcuts.mdx) · [config.json](docs/reference/configuration.mdx) · -[CLI 参考](docs/cli/reference.mdx)。面向 agent 的 CLI 接口另见 -[skills/tty7/SKILL.md](skills/tty7/SKILL.md)。 +[CLI 参考](docs/cli/reference.mdx)。 +agent 如何调用这套 CLI,另见 [skills/tty7/SKILL.md](skills/tty7/SKILL.md)。 -通过以下命令安装 skill: +安装 skill: ```sh npx skills add l0ng-ai/tty7 # 安装 -npx skills update tty7 # 之后更新 +npx skills update tty7 # 后续更新 ``` ## 基准测试 -同一台机器、同一天、统一 155×40 网格 —— Apple M1 Pro,macOS 26.3.1, -取五次运行的平均值(2026-07-04): +同一台机器、同一天、同样的 155×40 网格:Apple M1 Pro,macOS 26.3.1,每项运行五次取平均(2026-07-04)。 | | **tty7** | Alacritty | Ghostty | Kitty | |---|---:|---:|---:|---:| -| 纯文本 I/O —— 11 MB `cat` (越低越好) | **95 ms** | 239 ms | 179 ms | 185 ms | +| 纯文本 I/O:`cat` 一个 11 MB 文件 (越低越好) | **95 ms** | 239 ms | 179 ms | 185 ms | | [DOOM-fire](https://github.com/const-void/DOOM-fire-zig) 帧率 (越高越好) | **888 fps** | 485 fps | 552 fps | 617 fps | | 冷启动内存 | 116 MB¹ | 105 MB | 128 MB | 130 MB | -¹ GUI 105 MB + 常驻 server 11 MB。 +¹ GUI 占 105 MB,常驻 server 占 11 MB。 -测试方法与一键复现脚本:[`scripts/bench/`](scripts/bench/README.md)。 +测试方法与一条命令复现:[`scripts/bench/`](scripts/bench/README.md)。 ---
-基于 [gpui](https://github.com/zed-industries/zed) 与 [`alacritty_terminal`](https://github.com/zed-industries/alacritty) 构建 · [Apache-2.0](LICENSE) · [Discord](https://discord.gg/s3dethqz2V) · [更新日志](CHANGELOG.md) +基于 [gpui](https://github.com/zed-industries/zed) 和 [`alacritty_terminal`](https://github.com/zed-industries/alacritty) 构建 · [Apache-2.0](LICENSE) · [Discord](https://discord.gg/s3dethqz2V) · [更新日志](CHANGELOG.md)
From 07e3b264348caac59415a8e5fb8e68ae567f138b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:56:34 +0800 Subject: [PATCH 30/33] feat(agent): outline a coding agent's conversation, and jump back to a turn (#703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent): outline a coding agent's conversation, and jump back to a turn The hooks tty7 installs into Claude Code already announce every turn over the pty as an OSC 777, and the daemon reads those for the pane's status dot. The same bytes reach the client, where they are worth something else: the byte offset a `prompt-submit` lands on is a *position in the stream*, so advancing the emulator to exactly there and reading the cursor gives the scrollback row that turn began on. That is an outline of the conversation, and a way back into it — which is the one thing a long agent session in a terminal has never had. The Info panel grows a CONVERSATION section: one row per turn, the prompt's first line as its label, a dot that says whether the turn is still running. Clicking a row scrolls the pane so that turn's prompt is the top line. Not a fourth right-panel tab. `RightPanelTab` says out loud why there is no room for one at 260px, and a fourth variant would drop anyone who rolled back to an older build onto Info. This is a fact about the pane, like its shell and its cwd, so it sits with them. The hook is a subprocess writing to the controlling tty while the agent's own renderer writes to it too. Claude Code repaints in place with ink, so the cursor when the hook's bytes land is wherever the last repaint left it — inside the live region, a few rows from where the prompt's echo comes to rest. And once the scrollback limit starts discarding lines, every anchor slides by the discard count at once. So the anchor is a hint, and the prompt's own text is the correction: at click time (by which point it has long been drawn) the row is looked for around the anchor, exact match first — the row that *is* `> hi`, marker stripped — and only then by containment, which keeps its length floor because `hi` appears inside half the rows of any answer. The row that is found is written back, so a second click does not search again and cannot land somewhere else. Claude Code keeps a JSONL transcript, and reading it would give the assistant's side too. It would also only work for Claude, only when the agent runs on this machine, and only for a path this process may read. An OSC comes back through the pty from wherever the agent actually runs — over ssh, in a container, in a remote workspace — with no file access and no per-agent format. What is lost is the assistant's text; what is kept is every host tty7 supports. - `OscTokenizer::feed_at` reports each payload's end offset. The client already tokenized OSC 777 on every batch to keep agent events out of desktop notifications, so the scan is free; only a real event now costs a cut, which is what #404 was right to object to about the old per-command mark scanner. - `Cut` is a two-variant enum again (cursor repair, agent turn). Two ascending runs concatenated are not one, so a batch carrying both kinds is sorted — and only such a batch pays for it. - A replayed ring is cut the same way, so reattaching to a pane rebuilds the outline from its own history rather than losing it with the old client. - Turn anchors are dropped where image placements are: `clear_scrollback`, and the grid reset in `adopt_relink`. - A turn that began on the alt screen is listed but not clickable — there is no scrollback behind it to return to. - A turn announced twice is one turn. Hooks are not guaranteed to fire once, and what makes it the same turn is that the one before it never ended: a real repeat can only come after an answer, and an answer brings a `stop`. - The hook forwards the prompt's first line, clamped to 200 characters. The tokenizer *abandons* a payload past 8 KiB rather than truncating it, so a pasted file would otherwise cost the whole event; and a needle spanning a line break matches no single row. No protocol change: the prompt rides in the OSC the hook already sent, and an older client ignores the field. * refactor(panel): drop the Info panel's agent row It said `Claude Code · working` behind a status dot — the same name and the same dot the tab chip and its sidebar row were already wearing, restated two panels away from either of them. The CONVERSATION section that now sits under it says what the agent is doing in a form the row never could: which turns there were, which one is still running, and a way back to each. `InfoValue::Agent` and `status_pip` went with it — the dot was the row's only caller — and `PanelAgent` / `PanelAgentIdle` with those. The remaining three status labels stay: the tray menu still names them. `Tab::agent_row` stays too. `agent_status` is that pair's status and the tab strip's badge reads it, which is the one-leaf rule #543 put there. --- CHANGELOG.md | 20 + crates/tty7-core/src/core/agent_hooks.rs | 106 ++++ crates/tty7-core/src/core/cli_agent.rs | 13 + crates/tty7-core/src/core/osc.rs | 42 +- src/terminal/agent_marks.rs | 709 +++++++++++++++++++++++ src/terminal/mod.rs | 1 + src/terminal/remote.rs | 141 ++++- src/terminal/view.rs | 135 +++++ src/ui/i18n/en.rs | 3 +- src/ui/i18n/ja.rs | 3 +- src/ui/i18n/mod.rs | 8 +- src/ui/i18n/zh.rs | 3 +- src/ui/right_panel.rs | 228 ++++---- 13 files changed, 1266 insertions(+), 146 deletions(-) create mode 100644 src/terminal/agent_marks.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 425504e9..16e2f1c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,8 +77,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 thing that cannot come along is an ad-hoc `-J` hop, and that is said out loud rather than saved broken (#438). +- **A coding agent's conversation is an outline, and a way back into it.** The + Info panel grows a **CONVERSATION** section: one row per turn, the prompt's + first line as its label, a dot that says whether the turn is still running. + Click a row and the pane scrolls so that turn's prompt is the top line — a + long agent session in a terminal has never had a way back to "what did I ask + an hour ago". It rides on the OSC 777 the hooks already send for the tab's + status dot, so it costs a cut only when an agent event actually arrives, and + it works wherever the agent runs — over ssh, in a container, in a remote + workspace — rather than only where a transcript file happens to be readable. + Reattaching to a pane rebuilds the outline from its own replayed history. A + turn that began on the alt screen is listed but not clickable, because there + is no scrollback behind it to return to. Agents whose hooks do not report + prompt text (Codex, Copilot, Grok) are left out rather than drawn as a column + of anonymous dots. + ### Changed +- **The Info panel's `agent` row is gone.** It said `Claude Code · working` + beside a status dot — the same name and the same dot the tab and its sidebar + row were already wearing, two panels away from neither of them. The + CONVERSATION section below now says what that agent is doing in a form the + row never could, and the dot stays where it was learned. - **A zsh or fish you gave your own arguments to is no longer injected into.** Custom arguments have always been the line where tty7 backs off — the bash, PowerShell and WSL setups checked for them — but the zsh and fish setups did diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 6ceeea62..6458c227 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -78,9 +78,41 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec { body[key] = serde_json::Value::String(v.to_string()); } } + if let Some(prompt) = ["prompt", "userPrompt", "user_prompt"] + .iter() + .find_map(|k| payload.get(*k)) + .and_then(|v| v.as_str()) + .and_then(prompt_label) + { + body["prompt"] = serde_json::Value::String(prompt); + } format!("\x1b]777;notify;{AGENT_EVENT_SENTINEL};{body}\x07").into_bytes() } +/// How much of a prompt rides back to the terminal. +/// +/// Two reasons it is short. The payload goes out as an OSC, and the tokenizer +/// reading it *abandons* anything past 8 KiB rather than truncating — a pasted +/// file would silently cost the whole event, not just its tail. And what the +/// client does with this is label one row of a list and look for that text in +/// the scrollback, neither of which can use more than a line. +const PROMPT_LABEL_MAX: usize = 200; + +/// The first line of what the user typed, which is both the label an outline +/// row shows and the needle that finds the turn again in the scrollback. +/// +/// A line rather than the whole prompt because the terminal wrapped it across +/// rows: a needle spanning a line break matches no single row, so the later +/// lines would only make the search fail. +fn prompt_label(text: &str) -> Option { + let line = text.lines().map(str::trim).find(|l| !l.is_empty())?; + let end = line + .char_indices() + .nth(PROMPT_LABEL_MAX) + .map_or(line.len(), |(i, _)| i); + Some(line[..end].to_string()) +} + #[cfg(unix)] fn write_to_controlling_tty(bytes: &[u8]) -> bool { if write_dev(std::path::Path::new("/dev/tty"), bytes) { @@ -1417,6 +1449,80 @@ mod tests { assert_eq!(ev.cwd.as_deref(), Some(std::path::Path::new("/w"))); } + /// Parses a built sequence back the way the terminal's scanner does. + fn round_trip( + agent: &str, + event: &str, + stdin_json: &str, + ) -> crate::core::cli_agent::AgentEvent { + let seq = build_hook_sequence(agent, event, stdin_json); + crate::core::cli_agent::parse_agent_event(&seq[2..seq.len() - 1]).expect("parses") + } + + #[test] + fn a_submitted_prompt_rides_back_as_the_turns_label() { + let ev = round_trip( + "claude", + "prompt-submit", + r#"{"prompt":"restore the outline","session_id":"s-1"}"#, + ); + assert_eq!(ev.prompt.as_deref(), Some("restore the outline")); + assert_eq!( + ev.message, None, + "a prompt is not a message; the turn starts with nothing said back" + ); + } + + #[test] + fn a_prompt_is_cut_to_its_first_line() { + let ev = round_trip( + "claude", + "prompt-submit", + r#"{"prompt":"\n\n what did we decide \nand then some more\nand more"}"#, + ); + assert_eq!( + ev.prompt.as_deref(), + Some("what did we decide"), + "later lines wrapped when they were drawn and would only fail the search" + ); + } + + #[test] + fn a_pasted_file_cannot_cost_the_whole_event() { + let prompt = "x".repeat(64 * 1024); + let ev = round_trip( + "claude", + "prompt-submit", + &serde_json::json!({ "prompt": prompt }).to_string(), + ); + assert_eq!( + ev.prompt.map(|p| p.chars().count()), + Some(PROMPT_LABEL_MAX), + "the tokenizer abandons an oversized payload rather than truncating it" + ); + } + + #[test] + fn a_prompt_of_wide_characters_is_cut_on_a_character_boundary() { + let prompt = "把大纲恢复一下".repeat(100); + let ev = round_trip( + "claude", + "prompt-submit", + &serde_json::json!({ "prompt": prompt }).to_string(), + ); + assert_eq!(ev.prompt.map(|p| p.chars().count()), Some(PROMPT_LABEL_MAX)); + } + + #[test] + fn an_agent_that_reports_no_prompt_carries_none() { + assert_eq!(round_trip("codex", "stop", "{}").prompt, None); + assert_eq!( + round_trip("claude", "prompt-submit", r#"{"prompt":" "}"#).prompt, + None, + "whitespace is not a label" + ); + } + #[test] fn grok_run_hooks_are_relabeled_to_grok() { assert_eq!(effective_agent("claude", true), "grok"); diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 37a8ba6a..c4c67ea0 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -708,6 +708,13 @@ pub struct AgentEvent { pub session_id: Option, pub message: Option, pub cwd: Option, + /// What the user typed, on a `PromptSubmit` — already clamped to a label's + /// worth of text by the hook that sent it, since this rides an OSC payload + /// the tokenizer abandons rather than truncates past 8 KiB. + /// + /// Separate from `message`, which carries what the *agent* said and is + /// deliberately cleared when a turn starts. + pub prompt: Option, } pub fn parse_agent_event(payload: &[u8]) -> Option { @@ -729,6 +736,8 @@ pub fn parse_agent_event(payload: &[u8]) -> Option { message: Option, #[serde(default)] cwd: Option, + #[serde(default)] + prompt: Option, } let w: Wire = serde_json::from_slice(json).ok()?; @@ -740,6 +749,7 @@ pub fn parse_agent_event(payload: &[u8]) -> Option { session_id: nonempty(w.session_id), message: nonempty(w.message), cwd: nonempty(w.cwd).map(std::path::PathBuf::from), + prompt: nonempty(w.prompt), }) } @@ -1054,6 +1064,7 @@ mod tests { session_id: id.map(String::from), message: msg.map(String::from), cwd: None, + prompt: None, }; s.apply_event(&ev(AgentEventKind::SessionStart, None, Some("sid-1"))); @@ -1109,6 +1120,7 @@ mod tests { session_id: None, message: None, cwd: None, + prompt: None, }; let mut s = AgentSessionState::default(); @@ -1144,6 +1156,7 @@ mod tests { session_id: None, message: None, cwd: cwd.map(PathBuf::from), + prompt: None, }; let mut s = AgentSessionState::default(); diff --git a/crates/tty7-core/src/core/osc.rs b/crates/tty7-core/src/core/osc.rs index f6431cd2..83504084 100644 --- a/crates/tty7-core/src/core/osc.rs +++ b/crates/tty7-core/src/core/osc.rs @@ -27,6 +27,15 @@ impl OscTokenizer { } pub fn feed(&mut self, bytes: &[u8], mut on_payload: impl FnMut(&[u8])) { + self.feed_at(bytes, |_, payload| on_payload(payload)); + } + + /// [`feed`](Self::feed), but also reporting where each payload ended: an + /// offset one past its terminator, in ascending order, so a reader can + /// advance an emulator to exactly there and read the state the sequence + /// left behind. A payload split across two feeds is reported against the + /// batch its terminator landed in. + pub fn feed_at(&mut self, bytes: &[u8], mut on_payload: impl FnMut(usize, &[u8])) { let mut i = 0; while i < bytes.len() { match self.state { @@ -64,7 +73,7 @@ impl OscTokenizer { _ => self.state = State::Ground, }, State::Osc => match b { - 0x07 => self.finish(&mut on_payload), + 0x07 => self.finish(i + 1, &mut on_payload), 0x1b => self.state = State::OscEsc, _ => { self.buf.push(b); @@ -75,7 +84,7 @@ impl OscTokenizer { } }, State::OscEsc => match b { - b'\\' => self.finish(&mut on_payload), + b'\\' => self.finish(i + 1, &mut on_payload), 0x1b => {} b']' => { self.buf.clear(); @@ -107,8 +116,8 @@ impl OscTokenizer { } } - fn finish(&mut self, on_payload: &mut impl FnMut(&[u8])) { - on_payload(&self.buf); + fn finish(&mut self, at: usize, on_payload: &mut impl FnMut(usize, &[u8])) { + on_payload(at, &self.buf); self.buf.clear(); self.state = State::Ground; } @@ -237,6 +246,31 @@ mod tests { ); } + #[test] + fn offsets_land_one_past_the_terminator() { + let mut tok = OscTokenizer::new(&[b"9"]); + let mut got = Vec::new(); + let stream = b"ab\x1b]9;bel\x07cd\x1b]9;st\x1b\\"; + tok.feed_at(stream, |at, payload| got.push((at, payload.to_vec()))); + assert_eq!( + got, + vec![(10, b"9;bel".to_vec()), (20, b"9;st".to_vec())], + "a cut must point just past its sequence" + ); + assert_eq!(&stream[10..12], b"cd"); + assert_eq!(stream.len(), 20, "the ST-terminated one ends the stream"); + } + + #[test] + fn an_offset_is_reported_against_the_batch_its_terminator_lands_in() { + let mut tok = OscTokenizer::new(&[b"777"]); + let mut got = Vec::new(); + tok.feed_at(b"out\x1b]777;no", |at, p| got.push((at, p.to_vec()))); + assert!(got.is_empty(), "unterminated, so nothing to report yet"); + tok.feed_at(b"tify;x\x07tail", |at, p| got.push((at, p.to_vec()))); + assert_eq!(got, vec![(7, b"777;notify;x".to_vec())]); + } + #[test] fn esc_runs_and_non_osc_escapes_do_not_confuse_the_scanner() { assert_eq!( diff --git a/src/terminal/agent_marks.rs b/src/terminal/agent_marks.rs new file mode 100644 index 00000000..d8ec2b49 --- /dev/null +++ b/src/terminal/agent_marks.rs @@ -0,0 +1,709 @@ +//! Where each turn of a coding agent's conversation sits in the scrollback. +//! +//! The hooks tty7 installs into Claude Code and friends already announce a +//! turn's start and end over the pty, as an OSC 777 the daemon reads for the +//! pane's status dot. Those same bytes arrive at the client, and *here* they +//! are worth something else: the byte offset a `prompt-submit` lands on is a +//! position in the stream, so advancing the emulator to exactly there and +//! reading the cursor gives the row that turn began on. That is an outline of +//! the conversation, and a place to scroll back to. +//! +//! # Why the anchor is not the whole answer +//! +//! The hook is a subprocess writing to the controlling tty while the agent's +//! own renderer writes to it too. Claude Code repaints in place with ink, so +//! the cursor at the moment the hook's bytes land sits wherever the last +//! repaint left it — inside the live region at the bottom, a few rows off from +//! where the prompt's echo finally comes to rest. The anchor is close, not +//! exact. +//! +//! So the anchor is a *hint*, and [`AgentTurn::text`] is the correction: the +//! first line of what the user typed, which the view looks for around the +//! anchor when the jump happens (by then it has long been drawn). The anchor +//! narrows the search and orders the list; the text lands the jump. +//! +//! # Why rows and not the transcript file +//! +//! Claude Code keeps a JSONL transcript, and reading it would give the +//! assistant's side of the conversation too. It would also only work for +//! Claude, only for a pane whose agent runs on this machine, and only for a +//! path this process is allowed to read. An OSC comes back through the pty +//! from wherever the agent actually runs — over ssh, inside a container, in a +//! remote workspace — with no file access and no per-agent format. What is +//! lost is the assistant's text; what is kept is every host tty7 supports. + +use std::sync::{Arc, Mutex}; + +use alacritty_terminal::event::EventListener; +use alacritty_terminal::grid::Dimensions as _; +use alacritty_terminal::term::{Term, TermMode}; + +use crate::core::cli_agent::{AgentEventKind, parse_agent_event}; +use crate::core::osc::OscTokenizer; + +/// Turns kept per pane. A long agent session is tens of turns, not hundreds; +/// this is the bound that keeps a runaway hook from growing the list without +/// end, not a number anyone should reach. +const MAX_TURNS: usize = 500; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentTurn { + /// Absolute scrollback row: `history_size + grid line`, counting from the + /// oldest line the emulator still holds. `None` when the turn began on the + /// alt screen, which has no scrollback to point into. + /// + /// The count it is relative to shrinks once the scrollback limit starts + /// discarding lines, which slides every anchor by the same amount. That is + /// what `text` is for. + pub row: Option, + /// The first line of the prompt, as the hook clamped it. Empty when the + /// agent's hook does not report prompts. + pub text: String, + /// The turn ended (a `stop` event arrived). + pub done: bool, + /// Identity for the UI, so a list that grows underneath a click still + /// points at the same turn. + pub id: u64, +} + +#[derive(Clone, Default)] +pub struct AgentTurns(Arc>); + +#[derive(Default)] +struct Inner { + turns: Vec, + next_id: u64, +} + +impl AgentTurns { + pub fn new() -> Self { + Self::default() + } + + pub fn list(&self) -> Vec { + let Ok(inner) = self.0.lock() else { + return Vec::new(); + }; + inner.turns.clone() + } + + /// Drop everything, for the same reason the image store is dropped: the + /// rows these point into are gone (`clear_scrollback`, a relink that resets + /// the grid) or belong to a different conversation (a fresh session). + pub fn clear(&self) { + if let Ok(mut inner) = self.0.lock() { + inner.turns.clear(); + } + } + + /// Move a turn onto the row the view found its text on. Jumping twice + /// should not search twice, and should not land in two different places. + pub fn recenter(&self, id: u64, row: i64) { + let Ok(mut inner) = self.0.lock() else { return }; + if let Some(turn) = inner.turns.iter_mut().find(|t| t.id == id) { + turn.row = Some(row); + } + } + + /// Read the row the emulator is on and record the turn there. Called with + /// the emulator advanced to exactly the byte after the event's terminator. + pub fn apply(&self, term: &Term, cut: TurnCut) { + match cut { + TurnCut::Begin { text } => { + // The alt screen is a scratch surface with no history behind + // it, so there is no row to come back to. The turn still + // belongs in the list — an agent that renders there (Codex + // does) has a conversation like any other — it just cannot be + // jumped to. + let row = (!term.mode().contains(TermMode::ALT_SCREEN)).then(|| { + let grid = term.grid(); + grid.history_size() as i64 + i64::from(grid.cursor.point.line.0) + }); + self.begin(row, text.unwrap_or_default()); + } + TurnCut::End => self.finish(), + TurnCut::Reset => self.clear(), + } + } + + fn begin(&self, row: Option, text: String) { + let Ok(mut inner) = self.0.lock() else { return }; + // One turn, announced twice. Hooks are not guaranteed to fire once — + // an agent reading two settings sources runs the same command twice, + // and a resumed session re-announces the turn it is resuming. What + // makes it the same turn is that the one before it never ended: a real + // repeat of a prompt can only come after the answer to the first, and + // an answer always brings a `stop`. + if let Some(last) = inner.turns.last_mut() + && !last.done + && last.text == text + { + // The earlier anchor is the better one — it was taken before the + // agent had drawn anything in reply — but take a row over nothing. + if last.row.is_none() { + last.row = row; + } + return; + } + let id = inner.next_id; + inner.next_id += 1; + inner.turns.push(AgentTurn { + row, + text, + done: false, + id, + }); + let overflow = inner.turns.len().saturating_sub(MAX_TURNS); + if overflow > 0 { + inner.turns.drain(..overflow); + } + } + + fn finish(&self) { + let Ok(mut inner) = self.0.lock() else { return }; + if let Some(last) = inner.turns.last_mut() { + last.done = true; + } + } +} + +/// Longest needle taken from a prompt. The point of a cap is that the text has +/// to sit on *one* grid row to be findable, and a prompt longer than the pane +/// is wide wrapped when it was drawn. +const NEEDLE_MAX: usize = 32; + +/// Shortest needle worth searching for *by containment*. Below this, `y` or +/// `go` occurs inside half the rows on screen and the closest match to the +/// anchor would be noise dressed up as precision. The exact match below has no +/// such floor: a row that *is* the prompt is the prompt however short it is. +const NEEDLE_MIN: usize = 3; + +/// What a TUI draws in front of what the user typed. Stripping one of these +/// turns the row Claude Code renders — `> restore the outline` — back into the +/// prompt the hook reported, so the two can be compared as equals. +const PROMPT_MARKERS: &[char] = &[ + '>', '❯', '›', '»', '〉', '⟩', '▶', '●', '•', '│', '|', '$', '#', '*', +]; + +/// Rows to look at either side of the anchor before widening to the whole +/// scrollback. Two screens covers the live region an agent repaints in, which +/// is the distance the anchor can be off by. +const NEAR: i64 = 120; + +/// The row a turn's prompt was actually drawn on, or `anchor` when there is +/// nothing to go on. +/// +/// The anchor says where the cursor was when the hook fired, which is a few +/// rows from where the prompt's echo settled (see the module docs), and drifts +/// further once the scrollback limit starts discarding lines out from under +/// every anchor at once. The prompt's own text does not drift, so it is the +/// better answer whenever it can be found — near the anchor first, since a +/// prompt asked twice should resolve to the turn that was clicked. +pub fn locate(term: &Term, anchor: i64, text: &str) -> i64 { + let Some(needle) = needle(text) else { + return anchor; + }; + let grid = term.grid(); + let last = grid.history_size() as i64 + grid.screen_lines() as i64 - 1; + let near = NEAR.max(2 * grid.screen_lines() as i64); + let (lo, hi) = ((anchor - near).max(0), (anchor + near).min(last)); + // Exact first, everywhere, before settling for containment: the row that + // *is* the prompt beats a row that merely mentions it, even when the + // mention is closer. `hi` appears inside a dozen rows of any answer; only + // one row is `> hi`. + Match::Exact + .nearest(term, anchor, needle, lo, hi) + .or_else(|| Match::Exact.nearest(term, anchor, needle, 0, last)) + .or_else(|| Match::Loose.nearest(term, anchor, needle, lo, hi)) + .or_else(|| Match::Loose.nearest(term, anchor, needle, 0, last)) + .unwrap_or(anchor) +} + +/// How hard a row has to try to count as the one the prompt was drawn on. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Match { + /// The row, minus the marker the TUI drew in front of it, starts with the + /// prompt. This is what an agent's echo of the user's line looks like. + Exact, + /// The row mentions the prompt somewhere. The fallback for a TUI that + /// frames its messages some other way — and the reason for [`NEEDLE_MIN`]. + Loose, +} + +fn needle(text: &str) -> Option<&str> { + let text = text.trim(); + let end = text + .char_indices() + .nth(NEEDLE_MAX) + .map_or(text.len(), |(i, _)| i); + Some(&text[..end]).filter(|n| !n.is_empty()) +} + +impl Match { + /// The row closest to `anchor` in `lo..=hi` that matches, found by walking + /// outwards so the first hit is the answer. + fn nearest( + self, + term: &Term, + anchor: i64, + needle: &str, + lo: i64, + hi: i64, + ) -> Option { + if lo > hi || (self == Match::Loose && needle.chars().count() < NEEDLE_MIN) { + return None; + } + let reach = (anchor - lo).max(hi - anchor).max(0); + for step in 0..=reach { + let probes = [anchor - step, anchor + step]; + for &row in &probes[..if step == 0 { 1 } else { 2 }] { + if (lo..=hi).contains(&row) && self.holds(term, row, needle) { + return Some(row); + } + } + } + None + } + + fn holds(self, term: &Term, row: i64, needle: &str) -> bool { + let Some(text) = row_text(term, row) else { + return false; + }; + match self { + Match::Loose => text.contains(needle), + Match::Exact => { + let line = text.trim(); + let line = match line.chars().next() { + Some(c) if PROMPT_MARKERS.contains(&c) => &line[c.len_utf8()..], + _ => line, + }; + line.trim_start().starts_with(needle) + } + } + } +} + +/// One scrollback row as text, with the spacer cells that follow double-width +/// glyphs left out — keeping them would break every CJK needle in half. +fn row_text(term: &Term, row: i64) -> Option { + use alacritty_terminal::index::{Column, Line}; + use alacritty_terminal::term::cell::Flags; + + let grid = term.grid(); + let line = row - grid.history_size() as i64; + if line < -(grid.history_size() as i64) || line >= grid.screen_lines() as i64 { + return None; + } + let line = i32::try_from(line).ok()?; + let row = &grid[Line(line)]; + let mut text = String::with_capacity(grid.columns()); + for col in 0..grid.columns() { + let cell = &row[Column(col)]; + if cell + .flags + .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) + { + continue; + } + text.push(cell.c); + if let Some(zerowidth) = cell.zerowidth() { + text.extend(zerowidth); + } + } + Some(text) +} + +/// What an agent event means for the outline, reported at the offset one past +/// the sequence that carried it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TurnCut { + Begin { + text: Option, + }, + End, + /// A session started: whatever is in the list belongs to a conversation + /// that is over. Claude Code sends this on `/clear` and on a resume, both + /// of which repaint the pane from scratch. + Reset, +} + +/// Byte scanner over the pty stream, reporting the agent events an outline +/// cares about, in ascending offset order — the same shape (and the same +/// contract) as [`ParkedCursorScanner`](crate::terminal::parked_cursor::ParkedCursorScanner). +pub struct AgentTurnScanner { + tok: OscTokenizer, +} + +impl Default for AgentTurnScanner { + fn default() -> Self { + Self { + tok: OscTokenizer::new(&[b"777"]), + } + } +} + +impl AgentTurnScanner { + pub fn new() -> Self { + Self::default() + } + + /// Forgets a sequence in progress. A replayed snapshot is a new stream, and + /// half an OSC from the old one must not join up with it. + pub fn reset(&mut self) { + *self = Self::default(); + } + + pub fn feed(&mut self, bytes: &[u8], mut on_cut: impl FnMut(usize, TurnCut)) { + self.tok.feed_at(bytes, |at, payload| { + let Some(ev) = parse_agent_event(payload) else { + return; + }; + match ev.kind { + AgentEventKind::PromptSubmit => on_cut(at, TurnCut::Begin { text: ev.prompt }), + AgentEventKind::Stop => on_cut(at, TurnCut::End), + AgentEventKind::SessionStart => on_cut(at, TurnCut::Reset), + // The rest move the status dot, not the conversation: a + // permission prompt or a tool completion happens *inside* a + // turn that is already in the list. + _ => {} + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alacritty_terminal::event::VoidListener; + use alacritty_terminal::vte::ansi::Processor; + + fn event(kind: &str, prompt: Option<&str>) -> Vec { + let mut body = serde_json::json!({ + "v": 1, + "agent": "claude", + "event": kind, + }); + if let Some(p) = prompt { + body["prompt"] = serde_json::Value::String(p.to_string()); + } + format!( + "\x1b]777;notify;{};{body}\x07", + crate::core::cli_agent::AGENT_EVENT_SENTINEL + ) + .into_bytes() + } + + fn cuts(chunks: &[&[u8]]) -> Vec<(usize, TurnCut)> { + let mut scanner = AgentTurnScanner::new(); + let mut got = Vec::new(); + for chunk in chunks { + scanner.feed(chunk, |at, cut| got.push((at, cut))); + } + got + } + + /// Drives a stream through the emulator the way the reader does — advance + /// to each cut, act on it, carry on — and reports the turns it recorded. + fn turns_after(stream: &[u8]) -> Vec { + let mut term = Term::new( + alacritty_terminal::term::Config { + scrolling_history: 1000, + ..Default::default() + }, + &crate::terminal::size::TermSize::new(80, 24), + VoidListener, + ); + let mut parser: Processor = Processor::new(); + let mut scanner = AgentTurnScanner::new(); + let turns = AgentTurns::new(); + + let mut cuts = Vec::new(); + scanner.feed(stream, |off, cut| cuts.push((off, cut))); + let mut at = 0; + for (off, cut) in cuts { + parser.advance(&mut term, &stream[at..off]); + at = off; + turns.apply(&term, cut); + } + parser.advance(&mut term, &stream[at..]); + turns.list() + } + + #[test] + fn a_prompt_and_its_stop_are_one_turn() { + let mut stream = event("prompt-submit", Some("restore the outline")); + stream.extend_from_slice(&event("stop", None)); + let got = turns_after(&stream); + assert_eq!(got.len(), 1); + assert_eq!(got[0].text, "restore the outline"); + assert!(got[0].done); + } + + #[test] + fn a_turn_anchors_to_the_row_the_prompt_arrived_on() { + let mut stream = b"one\r\ntwo\r\nthree\r\n".to_vec(); + stream.extend_from_slice(&event("prompt-submit", Some("go"))); + assert_eq!( + turns_after(&stream)[0].row, + Some(3), + "three lines written, so the cursor is on the fourth row" + ); + } + + #[test] + fn an_anchor_counts_from_the_oldest_line_still_held() { + // Fill past a screen so lines are in history, and the anchor has to + // count them rather than the visible rows. + let mut stream = "x\r\n".repeat(30).into_bytes(); + stream.extend_from_slice(&event("prompt-submit", Some("go"))); + assert_eq!( + turns_after(&stream)[0].row, + Some(30), + "30 rows written: 7 scrolled into history, cursor on screen line 23" + ); + } + + #[test] + fn a_turn_on_the_alt_screen_has_no_row_to_return_to() { + let mut stream = b"\x1b[?1049h".to_vec(); + stream.extend_from_slice(&event("prompt-submit", Some("go"))); + let got = turns_after(&stream); + assert_eq!(got.len(), 1, "still a turn, still listed"); + assert_eq!(got[0].row, None, "nothing behind the alt screen to jump to"); + } + + #[test] + fn a_session_start_drops_the_previous_conversation() { + let mut stream = event("prompt-submit", Some("first")); + stream.extend_from_slice(&event("stop", None)); + stream.extend_from_slice(&event("session-start", None)); + stream.extend_from_slice(&event("prompt-submit", Some("second"))); + let got = turns_after(&stream); + assert_eq!(got.len(), 1); + assert_eq!(got[0].text, "second"); + } + + #[test] + fn events_that_only_move_the_status_dot_are_not_turns() { + for kind in ["notification", "permission-request", "tool-complete"] { + assert!( + cuts(&[&event(kind, None)]).is_empty(), + "{kind} happens inside a turn, it does not start one" + ); + } + } + + #[test] + fn other_osc_777_notifications_are_left_alone() { + assert!( + cuts(&[b"\x1b]777;notify;Build;finished\x07"]).is_empty(), + "a plain desktop notification is not an agent event" + ); + } + + #[test] + fn a_cut_lands_one_past_its_sequence() { + let ev = event("stop", None); + let mut stream = b"before".to_vec(); + stream.extend_from_slice(&ev); + stream.extend_from_slice(b"after"); + let got = cuts(&[&stream]); + assert_eq!(got.len(), 1); + assert_eq!( + &stream[got[0].0..], + b"after", + "the emulator must be advanced to just past the event" + ); + } + + #[test] + fn an_event_split_across_reads_still_arrives() { + let ev = event("prompt-submit", Some("split me")); + let (head, tail) = ev.split_at(ev.len() / 2); + assert_eq!( + cuts(&[head, tail]), + vec![( + tail.len(), + TurnCut::Begin { + text: Some("split me".into()) + } + )], + "the cut is attributed to the batch its terminator lands in" + ); + } + + /// A pane holding `lines`, one per row, with the first of them at row 0. + fn painted(lines: &[&str]) -> Term { + let mut term = Term::new( + alacritty_terminal::term::Config { + scrolling_history: 1000, + ..Default::default() + }, + &crate::terminal::size::TermSize::new(80, 24), + VoidListener, + ); + let mut parser: Processor = Processor::new(); + parser.advance(&mut term, lines.join("\r\n").as_bytes()); + term + } + + #[test] + fn a_prompt_is_found_a_few_rows_off_its_anchor() { + let mut lines = vec!["boot"; 40]; + lines[30] = "> restore the outline please"; + let term = painted(&lines); + assert_eq!( + locate(&term, 33, "restore the outline please"), + 30, + "the anchor lands in the live region; the text says where the turn is" + ); + } + + #[test] + fn the_same_prompt_twice_resolves_to_the_one_that_was_clicked() { + let mut lines = vec!["boot"; 200]; + lines[20] = "> run the tests"; + lines[150] = "> run the tests"; + let term = painted(&lines); + assert_eq!(locate(&term, 22, "run the tests"), 20); + assert_eq!(locate(&term, 148, "run the tests"), 150); + } + + #[test] + fn a_prompt_far_from_its_anchor_is_still_found() { + // What a scrollback that has started discarding lines looks like: every + // anchor slid, and the near window no longer covers the distance. + let mut lines = vec!["boot"; 600]; + lines[80] = "> the anchor drifted away from me"; + let term = painted(&lines); + assert_eq!(locate(&term, 500, "the anchor drifted away from me"), 80); + } + + #[test] + fn a_wide_glyph_prompt_survives_the_spacer_cells() { + let mut lines = vec!["boot"; 40]; + lines[12] = "> 把大纲恢复一下"; + let term = painted(&lines); + assert_eq!( + locate(&term, 15, "把大纲恢复一下"), + 12, + "the cell after a double-width glyph is a spacer, not part of the text" + ); + } + + #[test] + fn a_two_letter_prompt_still_lands_on_its_own_row() { + // The row the agent echoed the prompt on is `> hi`; every row of the + // answer under it may well contain "hi" too. Exactness, not length, is + // what makes the short one safe. + let mut lines = vec!["boot"; 40]; + lines[12] = "> hi"; + lines[14] = "Hi! What can I help you with?"; + lines[16] = "I see you are on this branch"; + let term = painted(&lines); + assert_eq!(locate(&term, 15, "hi"), 12); + } + + #[test] + fn the_marker_a_tui_draws_in_front_does_not_hide_the_prompt() { + for marker in ["> ", "❯ ", "〉", "│ ", "▶ "] { + let mut lines = vec!["boot".to_string(); 40]; + lines[9] = format!("{marker}run the tests"); + let borrowed: Vec<&str> = lines.iter().map(String::as_str).collect(); + let term = painted(&borrowed); + assert_eq!(locate(&term, 13, "run the tests"), 9, "marker {marker:?}"); + } + } + + #[test] + fn a_row_that_is_the_prompt_beats_a_nearer_row_that_merely_mentions_it() { + let mut lines = vec!["boot"; 60]; + lines[20] = "> run the tests"; + lines[30] = "sure, I will run the tests now"; + let term = painted(&lines); + assert_eq!( + locate(&term, 31, "run the tests"), + 20, + "the echo is the turn; the sentence about it is the answer" + ); + } + + #[test] + fn text_too_short_to_be_distinctive_leaves_the_anchor_alone() { + let mut lines = vec!["y"; 40]; + lines[5] = "y"; + let term = painted(&lines); + assert_eq!( + locate(&term, 30, "y"), + 30, + "a needle that matches everywhere is worse than the anchor" + ); + } + + #[test] + fn a_prompt_that_was_never_drawn_leaves_the_anchor_alone() { + let term = painted(&vec!["boot"; 40]); + assert_eq!(locate(&term, 12, "nothing on screen says this"), 12); + } + + #[test] + fn a_turn_announced_twice_is_still_one_turn() { + // What a hook that fires from two settings sources produces: the same + // prompt twice, then the same stop twice. + let mut stream = event("prompt-submit", Some("hi")); + stream.extend_from_slice(&event("prompt-submit", Some("hi"))); + stream.extend_from_slice(&event("stop", None)); + stream.extend_from_slice(&event("stop", None)); + let got = turns_after(&stream); + assert_eq!(got.len(), 1, "one prompt, however many times announced"); + assert!(got[0].done); + } + + #[test] + fn the_first_anchor_of_a_repeated_announcement_is_the_one_kept() { + let mut stream = b"a\r\nb\r\n".to_vec(); + stream.extend_from_slice(&event("prompt-submit", Some("hi"))); + stream.extend_from_slice(b"c\r\nd\r\n"); + stream.extend_from_slice(&event("prompt-submit", Some("hi"))); + assert_eq!( + turns_after(&stream)[0].row, + Some(2), + "the earlier anchor was taken before the agent drew its reply" + ); + } + + #[test] + fn the_same_prompt_asked_again_after_an_answer_is_a_new_turn() { + let mut stream = event("prompt-submit", Some("hi")); + stream.extend_from_slice(&event("stop", None)); + stream.extend_from_slice(&event("prompt-submit", Some("hi"))); + let got = turns_after(&stream); + assert_eq!(got.len(), 2, "an answer came between them, so they differ"); + assert!(got[0].done && !got[1].done); + } + + #[test] + fn turns_are_capped_from_the_front() { + let turns = AgentTurns::new(); + for i in 0..(MAX_TURNS + 10) { + turns.begin(Some(i as i64), format!("turn {i}")); + } + let got = turns.list(); + assert_eq!(got.len(), MAX_TURNS); + assert_eq!( + got[0].text, "turn 10", + "the oldest aged out, not the newest" + ); + } + + #[test] + fn recentring_moves_the_anchor_by_id_not_by_position() { + let turns = AgentTurns::new(); + turns.begin(Some(10), "first".into()); + turns.begin(Some(20), "second".into()); + let id = turns.list()[0].id; + turns.recenter(id, 12); + assert_eq!(turns.list()[0].row, Some(12)); + assert_eq!(turns.list()[1].row, Some(20), "its neighbour is untouched"); + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 59979e26..a09e113a 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod agent_marks; mod boxdraw; mod cmd_editor; mod completion; diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 48aadb58..4064c0cf 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -12,6 +12,7 @@ use alacritty_terminal::sync::FairMutex; use alacritty_terminal::term::{Config, Term, TermMode}; use alacritty_terminal::vte::ansi::{self, CursorShape, CursorStyle}; +use crate::terminal::agent_marks::{AgentTurnScanner, AgentTurns, TurnCut}; use crate::terminal::parked_cursor::{CursorCut, ParkedCursorRepair, ParkedCursorScanner}; use std::collections::VecDeque; @@ -64,6 +65,16 @@ struct ShellState { cycle: u64, } +/// A point in a batch of pty output where the emulator has to stop, because +/// something wants to read the state the sequence there left behind — the cell +/// a repaint hid the cursor on, or the row an agent turn began at. Both are +/// positions, and a position is only knowable by parsing up to it and no +/// further. +enum Cut { + Cursor(CursorCut), + Turn(TurnCut), +} + struct ReaderSignals { cwd: Arc>>, shell: Arc>, @@ -81,6 +92,10 @@ struct ReaderSignals { /// anchored to the grid for the paint path to blit. Shared with the reader, /// which places/deletes them as `DaemonMsg::Image`/`DeleteImage` frames land. images: crate::terminal::images::ImageStore, + /// Where each agent turn started, anchored to the grid the same way — see + /// [`crate::terminal::agent_marks`]. The daemon reads the same events for + /// the status dot, but only the client holds the rows they point into. + turns: AgentTurns, } #[derive(Clone, Debug, PartialEq)] @@ -208,6 +223,9 @@ pub struct RemoteTerminal { /// frames, read by the paint path — only the client holds the grid the /// anchors are relative to, so the store lives here rather than in the daemon. images: crate::terminal::images::ImageStore, + /// The conversation's shape, for the outline in the Info panel: one entry + /// per agent turn, anchored to the scrollback row it began on. + turns: AgentTurns, route: PaneRoute, proxy: EventProxy, reader_thread: Option>, @@ -484,6 +502,10 @@ impl RemoteTerminal { // Drop them; the daemon does not replay out-of-band image frames, so a // browser redraws on its next transmit (see issue #213's reattach note). self.images.clear(); + // Turn anchors point into the same grid. The replay that follows + // carries the agent's events with it, so the outline rebuilds itself + // from the bytes rather than being kept across the reset. + self.turns.clear(); let quit = Arc::new(AtomicBool::new(false)); let reader = Self::spawn_reader( @@ -506,6 +528,7 @@ impl RemoteTerminal { auth: self.auth_prompts.clone(), phase: self.ssh_phase.clone(), images: self.images.clone(), + turns: self.turns.clone(), }, ); if let Ok(mut writer) = self.writer.lock() { @@ -557,6 +580,7 @@ impl RemoteTerminal { Arc::new(Mutex::new(VecDeque::new())); let ssh_phase: Arc>> = Arc::new(Mutex::new(None)); let images = crate::terminal::images::ImageStore::new(); + let turns = AgentTurns::new(); let reader_quit = Arc::new(AtomicBool::new(false)); let reader_thread = Self::spawn_reader( @@ -579,6 +603,7 @@ impl RemoteTerminal { auth: auth_prompts.clone(), phase: ssh_phase.clone(), images: images.clone(), + turns: turns.clone(), }, ); @@ -607,6 +632,7 @@ impl RemoteTerminal { agent, agent_session, images, + turns, route: PaneRoute::Local, proxy, reader_thread: Some(reader_thread), @@ -680,6 +706,7 @@ impl RemoteTerminal { auth, phase, images, + turns, } = signals; crate::core::threads::promote_to_user_interactive(); let mut stream = read_half; @@ -689,6 +716,7 @@ impl RemoteTerminal { let mut zle_tok = OscTokenizer::new(&[b"133"]); let mut cursor_scan = ParkedCursorScanner::new(); let mut parked_cursor = ParkedCursorRepair::default(); + let mut turn_scan = AgentTurnScanner::new(); let mut pending: Vec = buffered; // Kitty-graphics decode runs on its own thread with newest-frame // coalescing (issue #213): inflating a full-window browser frame @@ -733,14 +761,25 @@ impl RemoteTerminal { macro_rules! flush_batch { () => { if !out_batch.is_empty() { - // The scanner reports an offset one past the + // Each scanner reports an offset one past the // sequence it matched, in ascending order, so the // batch splits at each of them: advance the // emulator to the cut, act on the state that // sequence left behind, carry on. - let mut cuts: Vec<(usize, CursorCut)> = Vec::new(); + let mut cuts: Vec<(usize, Cut)> = Vec::new(); if Self::REPAIR_PARKED_CURSOR { - cursor_scan.feed(&out_batch, |off, c| cuts.push((off, c))); + cursor_scan + .feed(&out_batch, |off, c| cuts.push((off, Cut::Cursor(c)))); + } + // Two ascending runs concatenated are not one + // ascending run, and a cut out of order would + // advance the emulator backwards — but only a + // batch carrying both kinds pays for the sort, + // and agent events are a handful per turn. + let cursor_cuts = cuts.len(); + turn_scan.feed(&out_batch, |off, c| cuts.push((off, Cut::Turn(c)))); + if cursor_cuts > 0 && cuts.len() > cursor_cuts { + cuts.sort_by_key(|(off, _)| *off); } { let t0 = trace.then(std::time::Instant::now); @@ -756,7 +795,10 @@ impl RemoteTerminal { for (off, cut) in cuts { processor.advance(&mut *term, &out_batch[at..off]); at = off; - parked_cursor.apply(&mut term, cut); + match cut { + Cut::Cursor(c) => parked_cursor.apply(&mut term, c), + Cut::Turn(t) => turns.apply(&term, t), + } } processor.advance(&mut *term, &out_batch[at..]); } @@ -875,13 +917,27 @@ impl RemoteTerminal { flush_batch!(); cursor_scan.reset(); parked_cursor.reset(); + turn_scan.reset(); proxy.replaying.store(true, Ordering::Relaxed); + // A replayed ring is the pane's own history + // coming back, agent events and all, so cut it + // the same way live output is cut: the outline + // of a conversation is rebuilt by reattaching + // to the pane, not lost with the old client. + let mut turn_cuts: Vec<(usize, TurnCut)> = Vec::new(); + turn_scan.feed(&bytes, |off, c| turn_cuts.push((off, c))); { let mut term = term.lock(); if quit.load(Ordering::SeqCst) { return; } - processor.advance(&mut *term, &bytes); + let mut at = 0usize; + for (off, cut) in turn_cuts { + processor.advance(&mut *term, &bytes[at..off]); + at = off; + turns.apply(&term, cut); + } + processor.advance(&mut *term, &bytes[at..]); if processor.sync_timeout().sync_timeout().is_some() { processor.stop_sync(&mut *term); } @@ -1269,6 +1325,12 @@ impl RemoteTerminal { self.agent_session.lock().ok().and_then(|g| g.clone()) } + /// This pane's agent turns, anchored to the scrollback. Same cheap handle + /// clone as [`images`](Self::images), shared with the reader thread. + pub fn agent_turns(&self) -> AgentTurns { + self.turns.clone() + } + pub fn zle_reading(&self) -> bool { self.zle_reading.load(Ordering::Relaxed) } @@ -4130,6 +4192,75 @@ mod tests { assert!(poll(""), "an unnamed command start does not inherit a name"); } + /// A `prompt-submit` the hook wrote into the middle of a batch of output. + fn prompt_event(prompt: &str) -> Vec { + format!( + "\x1b]777;notify;{};{{\"v\":1,\"agent\":\"claude\",\ + \"event\":\"prompt-submit\",\"prompt\":\"{prompt}\"}}\x07", + crate::core::cli_agent::AGENT_EVENT_SENTINEL + ) + .into_bytes() + } + + fn poll_turns(term: &RemoteTerminal) -> Vec { + for _ in 0..200 { + let turns = term.agent_turns().list(); + if !turns.is_empty() { + return turns; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + Vec::new() + } + + #[test] + fn an_agent_turn_anchors_where_its_event_sits_in_the_batch() { + crate::core::config::pin_test_config_dir(); + let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); + let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); + + // One frame, so one batch: the event's row is only reachable by + // splitting the batch at it. Reading the cursor after the whole batch + // has been parsed would answer 5. + let mut out = b"a\r\nb\r\n".to_vec(); + out.extend_from_slice(&prompt_event("restore the outline")); + out.extend_from_slice(b"c\r\nd\r\ne\r\n"); + DaemonMsg::Output(out).encode(&mut daemon_side).unwrap(); + daemon_side.flush().unwrap(); + + let turns = poll_turns(&term); + assert_eq!(turns.len(), 1, "one prompt, one turn"); + assert_eq!( + turns[0].row, + Some(2), + "the anchor is the row the event arrived on, not the end of the batch" + ); + assert_eq!(turns[0].text, "restore the outline"); + assert!(!turns[0].done, "no stop yet"); + } + + #[test] + fn a_replayed_ring_brings_the_conversation_back_with_it() { + crate::core::config::pin_test_config_dir(); + let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); + let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); + + // What reattaching to a pane looks like: its history arrives as a + // snapshot, agent events and all. The outline has to be rebuilt from + // those bytes — nothing else carries it across a client restart. + let mut snapshot = b"older output\r\n".to_vec(); + snapshot.extend_from_slice(&prompt_event("what did we decide")); + DaemonMsg::Snapshot(snapshot) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + + let turns = poll_turns(&term); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].row, Some(1)); + assert_eq!(turns[0].text, "what did we decide"); + } + #[test] fn shell_vi_mode_follows_live_prompt_mode_marks_without_disarming_zle() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index e0f7e6a4..9125a4a9 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1550,6 +1550,50 @@ impl TerminalView { self.terminal.agent_session() } + /// One entry per turn of the agent's conversation, oldest first — see + /// [`crate::terminal::agent_marks`]. + pub fn agent_turns(&self) -> Vec { + self.terminal.agent_turns().list() + } + + /// Scroll back to where a turn began, putting that row at the top of the + /// viewport so the answer to it reads downwards from there. + /// + /// The stored anchor only says roughly where the agent was drawing when the + /// turn started, so the prompt's own text gets the final say; the row it is + /// found on is written back, and a second click on the same turn lands in + /// the same place without searching again. + pub fn scroll_to_agent_turn( + &mut self, + turn: &crate::terminal::agent_marks::AgentTurn, + cx: &mut Context, + ) -> bool { + let Some(anchor) = turn.row else { + return false; + }; + // Nothing behind the alt screen to scroll to, and `scroll_display` is a + // no-op there anyway — say so rather than pretending the click worked. + if self.on_alt_screen() { + return false; + } + self.cancel_scroll_anim(); + let row = { + let mut term = self.terminal.term.lock(); + use alacritty_terminal::grid::Dimensions as _; + let history = term.grid().history_size() as i64; + let row = crate::terminal::agent_marks::locate(&term, anchor, &turn.text); + let target = (history - row).clamp(0, history); + let delta = (target - term.grid().display_offset() as i64) + .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; + term.scroll_display(Scroll::Delta(delta)); + row + }; + self.terminal.agent_turns().recenter(turn.id, row); + self.scroll_frac = 0.; + cx.notify(); + true + } + /// What this pane is in the middle of, when it can say so. `None` means /// either nothing is running or the shell never told us — and a terminal /// that guessed would raise this question on every single close. @@ -3069,6 +3113,9 @@ impl TerminalView { // not replay out-of-band image frames, so a browser redraws on its next // transmit (same reasoning as the reattach path in `adopt_relink`). self.terminal.images().clear(); + // Agent turn anchors are rows in the same discarded history, and unlike + // images they cannot be redrawn back into place. + self.terminal.agent_turns().clear(); self.scroll_frac = 0.; self.terminal.write(vec![0x0c_u8]); cx.notify(); @@ -10783,6 +10830,94 @@ mod gpui_tests { .unwrap(); } + /// The agent's prompt drawn on row 20, with enough output after it that the + /// row can actually be scrolled to the top of the viewport. + const CONVERSATION_ROW: i64 = 20; + + fn painted_conversation(view: &TerminalView) { + let mut parser: alacritty_terminal::vte::ansi::Processor = Default::default(); + let mut term = view.terminal.term.lock(); + for i in 0..300 { + let line = match i { + 20 => "> restore the outline\r\n".to_string(), + _ => format!("line {i}\r\n"), + }; + parser.advance(&mut *term, line.as_bytes()); + } + } + + fn viewport_top(view: &TerminalView) -> String { + use alacritty_terminal::index::{Column, Line}; + let term = view.terminal.term.lock(); + let grid = term.grid(); + let line = -(grid.display_offset() as i32); + (0..grid.columns()) + .map(|c| grid[Line(line)][Column(c)].c) + .collect::() + .trim_end() + .to_string() + } + + #[gpui::test] + fn jumping_to_a_turn_puts_the_prompt_at_the_top_of_the_viewport(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _w, cx| { + painted_conversation(view); + // Off by three, the way a hook firing into a live repaint is. + let turn = crate::terminal::agent_marks::AgentTurn { + row: Some(CONVERSATION_ROW + 3), + text: "restore the outline".into(), + done: true, + id: 1, + }; + assert!(view.scroll_to_agent_turn(&turn, cx)); + assert_eq!( + viewport_top(view), + "> restore the outline", + "the text corrected the anchor's three-row error" + ); + let history = { + use alacritty_terminal::grid::Dimensions as _; + view.terminal.term.lock().grid().history_size() as i64 + }; + assert_eq!( + display_offset(view) as i64, + history - CONVERSATION_ROW, + "the turn's row is the first one shown, not merely on screen" + ); + }) + .unwrap(); + } + + #[gpui::test] + fn a_turn_with_nowhere_to_go_reports_that_it_did_not_move(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _w, cx| { + painted_conversation(view); + let mut turn = crate::terminal::agent_marks::AgentTurn { + row: None, + text: "restore the outline".into(), + done: true, + id: 1, + }; + assert!( + !view.scroll_to_agent_turn(&turn, cx), + "a turn that began on the alt screen has no row" + ); + + turn.row = Some(CONVERSATION_ROW); + let mut parser: alacritty_terminal::vte::ansi::Processor = Default::default(); + parser.advance(&mut *view.terminal.term.lock(), b"\x1b[?1049h"); + assert!( + !view.scroll_to_agent_turn(&turn, cx), + "and a pane sitting on the alt screen has no scrollback to show" + ); + }) + .unwrap(); + } + #[gpui::test] fn turning_smooth_scrolling_off_restores_the_direct_path(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 5df37e30..d13d5d50 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1032,6 +1032,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::PanelNoChanges => "No uncommitted changes.", L10nKey::PanelNoChangesHint => "The working tree is clean.", L10nKey::PanelSessionSubtitle => "Session", + L10nKey::PanelConversationSubtitle => "Conversation", L10nKey::PanelProcessesSubtitle => "Processes", L10nKey::PanelPortsSubtitle => "Ports", L10nKey::PanelCwd => "cwd", @@ -1039,8 +1040,6 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::PanelSsh => "ssh", L10nKey::PanelBranch => "branch", L10nKey::PanelChangesRow => "changes", - L10nKey::PanelAgent => "agent", - L10nKey::PanelAgentIdle => "idle", L10nKey::PanelAgentWorking => "working", L10nKey::PanelAgentWaiting => "waiting", L10nKey::PanelAgentDone => "done", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 507a8ac6..5feb9313 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1096,6 +1096,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::PanelNoChanges => "未コミットの変更はありません", L10nKey::PanelNoChangesHint => "ワーキングツリーはクリーンです", L10nKey::PanelSessionSubtitle => "セッション", + L10nKey::PanelConversationSubtitle => "会話", L10nKey::PanelProcessesSubtitle => "プロセス", L10nKey::PanelPortsSubtitle => "ポート", L10nKey::PanelCwd => "作業ディレクトリ", @@ -1103,8 +1104,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::PanelSsh => "ssh", L10nKey::PanelBranch => "ブランチ", L10nKey::PanelChangesRow => "変更", - L10nKey::PanelAgent => "エージェント", - L10nKey::PanelAgentIdle => "アイドル", L10nKey::PanelAgentWorking => "作業中", L10nKey::PanelAgentWaiting => "待機中", L10nKey::PanelAgentDone => "完了", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index f9d8e5af..1d8b6c5c 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -767,6 +767,7 @@ l10n_keys! { PanelNoChangesHint, PanelMoreChangedFiles, PanelSessionSubtitle, + PanelConversationSubtitle, PanelProcessesSubtitle, PanelPortsSubtitle, PanelCwd, @@ -774,8 +775,6 @@ l10n_keys! { PanelSsh, PanelBranch, PanelChangesRow, - PanelAgent, - PanelAgentIdle, PanelAgentWorking, PanelAgentWaiting, PanelAgentDone, @@ -1541,10 +1540,9 @@ mod tests { L10nKey::SettingsLanguageEnglish, L10nKey::SettingsLanguageChinese, L10nKey::SettingsLanguageJapanese, - // The pane-type labels are one set — shell / agent / ssh — and - // translating only the middle one would break the set. + // The pane-type labels: one names a program, the other a protocol, + // and no locale renames either. L10nKey::PanelShell, - L10nKey::PanelAgent, L10nKey::PanelSsh, // The zh copy calls the background process "server" throughout — // this heading is that word on its own. diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 2adc7f53..a1f451e0 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -989,6 +989,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::PanelNoChanges => "没有未提交的变更。", L10nKey::PanelNoChangesHint => "worktree 是干净的。", L10nKey::PanelSessionSubtitle => "会话", + L10nKey::PanelConversationSubtitle => "对话", L10nKey::PanelProcessesSubtitle => "进程", L10nKey::PanelPortsSubtitle => "端口", L10nKey::PanelCwd => "工作目录", @@ -996,8 +997,6 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::PanelSsh => "ssh", L10nKey::PanelBranch => "分支", L10nKey::PanelChangesRow => "变更", - L10nKey::PanelAgent => "agent", - L10nKey::PanelAgentIdle => "空闲", L10nKey::PanelAgentWorking => "进行中", L10nKey::PanelAgentWaiting => "等待中", L10nKey::PanelAgentDone => "已完成", diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 01a8aaba..ecfd467a 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -185,14 +185,6 @@ enum InfoValue { removed: u32, open: Option<(crate::ui::host_ops::HostId, PathBuf)>, }, - /// An agent and what it is doing, behind the status dot the sidebar draws - /// on the tab — `hollow` for Waiting, which is a different *shape* rather - /// than one more hue, for the same reason the tab's dot is. - Agent { - text: String, - dot: Option, - hollow: bool, - }, } /// One label/value line of the Session section. @@ -663,11 +655,9 @@ impl Tty7App { // off in both places rather than in one of them. let mut diff_target: Option<(crate::ui::host_ops::HostId, PathBuf)> = None; let mut git: Option = None; - // The agent row's name and status, read off one leaf (see below). - let mut agent_row: Option<( - crate::core::cli_agent::CLIAgent, - crate::core::cli_agent::AgentStatus, - )> = None; + // The leaf the CONVERSATION section reads its turns off — the same one + // every row above describes. + let mut detail_pane = None; if let Some(tab) = self.tabs.get(self.active) { if let Some(leaf) = tab.detail_pane(window, cx) { @@ -720,26 +710,7 @@ impl Tty7App { forwards_pane = Some(view.pane_id); } git = view.git_status(cx); - // Name and status come from the *same* leaf: the detail pane's - // own agent when it has one, and otherwise the tab's most - // urgent agent leaf — which still holds the row while focus - // sits on a plain shell, and still colours its dot the way the - // tab strip's badge does, but names the pane it took the - // status from. Pairing `tab.agent` with `tab.agent_status` - // would splice one pane's name onto another pane's status — a - // row no leaf ever had — because the two resolve - // independently (#543). Read here, where `view` is in scope; - // pushed beside the other rows below. - agent_row = match view.agent() { - Some(agent) => { - let status = view - .agent_session() - .map(|s| s.status) - .unwrap_or(crate::core::cli_agent::AgentStatus::Idle); - Some((agent, status)) - } - None => tab.agent_row(cx), - }; + detail_pane = Some(leaf); } // Read off the same pane the rows above describe, rather than off // `Tab::git_status`, which resolves a split tab to its *first* leaf @@ -764,20 +735,6 @@ impl Tty7App { reveal: None, }); } - // Name and status were read off one leaf above; push the row. - if let Some((agent, status)) = agent_row { - let name = agent.display_name(); - rows.push(InfoRow { - label: t(L10nKey::PanelAgent), - value: InfoValue::Agent { - text: format!("{name} · {}", agent_status_label(status)), - dot: status.dot_rgb(), - hollow: status == crate::core::cli_agent::AgentStatus::Waiting, - }, - copy: None, - reveal: None, - }); - } } if rows.is_empty() { @@ -806,6 +763,7 @@ impl Tty7App { let inner = v_flex() .child(self.panel_subtitle(t(L10nKey::PanelSessionSubtitle), false, None, cx)) .child(list) + .children(self.turns_section(detail_pane.as_ref(), cx)) .children(self.procs_section(pane_id, cx)) .children(self.ports_section(pane_id, local_pane, cx)) .children(self.forwards_section(forwards_pane, cx)) @@ -925,31 +883,6 @@ impl Tty7App { None => counts.into_any_element(), } } - // The dot hangs out of the flow rather than sitting in it. A - // childless box has no baseline of its own, so as a flex item it - // offers up its bottom edge instead — and the row, which aligns - // its label and its value on their shared baseline, then hoisted - // the whole value six pixels and left "agent" sitting under its - // own value. Out of flow it cannot be mistaken for the thing that - // sets the line. - InfoValue::Agent { text, dot, hollow } => div() - .flex_1() - .min_w_0() - .relative() - .child( - div() - .min_w_0() - .truncate() - .when(dot.is_some(), |d| d.pl(rems(PIP_SIZE + PIP_GAP))) - .text_size(rems(TEXT_MONO)) - .font_family(mono.clone()) - .text_color(cx.theme().foreground) - .child(text), - ) - .children(dot.map(|rgb| { - status_pip(rgb, hollow, crate::ui::theme::workspace_surface_color(cx)) - })) - .into_any_element(), }; // The strip is opaque and pinned to the row's right edge, so whatever @@ -1087,6 +1020,103 @@ impl Tty7App { .into_any_element() } + /// The agent's conversation, one row per turn, each a way back to where + /// that turn started in the scrollback. + /// + /// It sits under the session facts rather than in a tab of its own: this is + /// something *this pane* is, like its shell and its cwd, and the tab strip + /// has no room for a fourth tile at 260px. + fn turns_section( + &self, + leaf: Option<&gpui::Entity>, + cx: &mut Context, + ) -> Option { + let leaf = leaf?; + let turns = leaf.read(cx).agent_turns(); + // A turn the hook announced but could not name is a row with nothing on + // it. The status dot already says a turn is running. + let turns: Vec<_> = turns + .into_iter() + .filter(|t| !t.text.trim().is_empty()) + .collect(); + if turns.is_empty() { + return None; + } + let sf = cx.global::().sidebar; + let count = turns.len().to_string(); + let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(1.)).gap(px(1.)); + for turn in turns { + let id = turn.id; + // Only a turn that was drawn into the scrollback has somewhere to + // go: one that began on the alt screen is history the pane never + // kept, so its row reads as a label and not as a link. + let jumpable = turn.row.is_some(); + let dot = { + let d = div().flex_none().size(px(7.)).rounded_full(); + if turn.done { + d.border_1() + .border_color(cx.theme().muted_foreground.opacity(0.55)) + } else { + d.bg(cx.theme().muted_foreground) + } + }; + list = list.child( + h_flex() + .id(gpui::SharedString::from(format!("panel-turn-{id}"))) + .items_center() + .gap(px(8.)) + .px(px(4.)) + .py(px(3.)) + .rounded(px(5.)) + .when(jumpable, |this| { + let leaf = leaf.clone(); + let turn = turn.clone(); + this.cursor_pointer() + .hover(|s| s.bg(gpui::rgb(sf.hover))) + .on_click(cx.listener(move |_this, _, _window, cx| { + leaf.update(cx, |view, cx| { + view.scroll_to_agent_turn(&turn, cx); + }); + })) + }) + .child(dot) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(rems(TEXT)) + .text_color(if jumpable { + cx.theme().foreground + } else { + cx.theme().muted_foreground + }) + .child(turn.text), + ), + ); + } + Some( + v_flex() + .child( + self.panel_subtitle( + t(L10nKey::PanelConversationSubtitle), + true, + Some( + div() + .text_size(rems(META_MONO)) + .font_family(cx.theme().mono_font_family.clone()) + .text_color(cx.theme().muted_foreground.opacity(0.75)) + .child(count) + .into_any_element(), + ), + cx, + ), + ) + .child(list) + .into_any_element(), + ) + } + fn procs_section(&self, pane_id: Option, cx: &mut Context) -> Option { let procs = &self.procs(pane_id)?.procs; if procs.len() < 2 { @@ -1399,50 +1429,6 @@ pub(crate) fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedStri .into_any_element() } -/// Diameter of the agent dot in the Info panel, and the gap between it and the -/// word it qualifies. -/// -/// Seven sixteenths of a rem — seven pixels at the default interface size, -/// because a dot on a line of text has to survive being read at a glance -/// without becoming a bullet, and a rem rather than a pixel because the line it -/// sits in is sized in rems: pinned in pixels it slid towards the cap height of -/// its own row the moment the interface font scale moved off 100%. -const PIP_SIZE: f32 = 7. * STEP; -const PIP_GAP: f32 = 7. * STEP; - -/// How far down the value box the dot starts, again as a fraction of the text -/// it is centred in rather than a pixel count. -const PIP_TOP: f32 = 6. * STEP; - -/// The dot a tab wears for its agent's state, at the size a line of panel text -/// can carry it. -/// -/// Same colours and the same hollow-for-Waiting rule as the sidebar's, because -/// it is the same fact: a reader who has learned that amber-with-a-hole means -/// "it wants you" on a tab must not have to learn it a second time here. Same -/// *shape*, too — [`Tty7App::status_dot`] punches a small hole out of a filled -/// dot, so drawing this one as a thin ring would have been a second dialect of -/// the one rule the doc above promises is shared. `hole` is the colour behind -/// the dot, which is what a hole in it has to be painted in; the agent row is -/// never interactive, so that colour is the panel's own and does not move -/// under the pointer. -fn status_pip(rgb: u32, hollow: bool, hole: gpui::Hsla) -> AnyElement { - div() - .absolute() - .left_0() - .top(rems(PIP_TOP)) - .size(rems(PIP_SIZE)) - .rounded_full() - .bg(gpui::rgb(rgb)) - .when(hollow, |dot| { - dot.flex() - .items_center() - .justify_center() - .child(div().size(rems(PIP_SIZE * 0.36)).rounded_full().bg(hole)) - }) - .into_any_element() -} - /// A small filled pill around a mono token — a pid, a port number. /// /// The padding and the radius are derived from the text size: at @@ -1478,16 +1464,6 @@ pub fn reveal_label() -> &'static str { } } -fn agent_status_label(status: crate::core::cli_agent::AgentStatus) -> &'static str { - use crate::core::cli_agent::AgentStatus::*; - match status { - Idle => t(L10nKey::PanelAgentIdle), - Working => t(L10nKey::PanelAgentWorking), - Waiting => t(L10nKey::PanelAgentWaiting), - Done => t(L10nKey::PanelAgentDone), - } -} - /// Splits a path into everything-but-the-last-segment and the last segment, /// so a row can shrink the first and keep the second. fn split_path_leaf(s: &str) -> (String, String) { From 46759b8a019ea08887100d12a0930f9645d5077e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:35:32 +0800 Subject: [PATCH 31/33] fix(input-bar): read column widths from unicode-width, not a hand-rolled table (#704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(input-bar): read column widths from unicode-width, not a hand-rolled table The input bar scored every character against a hand-written list of code-point ranges. Anything the list missed counted as one plain column, so `🀄`, `⌚` and every combining mark pulled the rest of the row a column left, and clicks, wrapping and the caret all landed off by that much (#701). The grid gets its widths from `unicode-width` by way of `alacritty_terminal`, so read the same table. Zero-width characters then need a cell to ride in: group each base with the marks that follow it, so the shaper sees one run and composes `é` instead of setting `e` and its accent side by side. An emoji presentation sequence is re-scored as a string the way the grid re-scores it, so `❤️` is two columns in the bar as well. A ZWJ sequence stays two cells on purpose — that is what the grid makes of it, and composing it here would put the bar a column off from where the text lands. * fix(input-bar): derive click and wrap geometry from the cells the bar draws `input_cells` re-scores an emoji presentation sequence to two columns and hands a stranded combining mark a column of its own, but `input_char_positions` kept walking the text character by character — so `❤️` was drawn two columns wide and counted as one. Everything geometric read the short count: a click on `X` in `❤️X` selected past it, wrapping broke a column early, and vertical caret motion aimed at the wrong column. Walk the same cells instead. Only the base of a cell carries the width, so a click still lands on the base rather than a mark riding on it, and the riders sit at the column the caret takes after the cell. A cell now also tints as a unit when a selection covers any character in it — it is one glyph, so half-highlighting it drew a mark unselected next to its selected base. --- Cargo.lock | 1 + Cargo.toml | 6 + src/terminal/view.rs | 363 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 327 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f03e4c82..934d4b5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9805,6 +9805,7 @@ dependencies = [ "tray-icon", "tty7-core", "unicode-segmentation", + "unicode-width", "uuid", "windows 0.58.0", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 5b7e1a8b..eb921bdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,12 @@ memchr = "2" # emoji, and half a flag renders as a bare letter. Already in the tree via # gpui, so this pins no new code. unicode-segmentation = "1" +# Column widths for the input bar (`terminal::view`). The bar has to lay text +# out the way the terminal grid will, and the grid gets its widths from this +# same crate by way of `alacritty_terminal` — a hand-rolled code-point table +# drifts from it the moment Unicode adds a block. Already in the tree via +# `tty7-cli`, so this pins no new code. +unicode-width = "0.2" smol.workspace = true smallvec.workspace = true serde = { workspace = true } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 9125a4a9..01668f14 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -5594,9 +5594,14 @@ impl TerminalView { } } }; - let cell = |color: gpui::Hsla, ch: char, selected: bool, caret: bool, underline: bool| { + let cell = |color: gpui::Hsla, + text: String, + width: usize, + selected: bool, + caret: bool, + underline: bool| { let inverted = caret && block_cursor; - let w = cell_w * (display_width(ch) as f32); + let w = cell_w * (width as f32); let mut d = div() .relative() .flex_none() @@ -5613,7 +5618,7 @@ impl TerminalView { if underline { d = d.border_b_1().border_color(fg); } - d = d.child(ch.to_string()); + d = d.child(text); if caret && !inverted { d = d.child(caret_bar()); } @@ -5627,15 +5632,24 @@ impl TerminalView { let is_multiline = chars.contains(&'\n'); - for i in 0..len { - if i == cursor && has_marked { - for mc in marked.chars() { - lines - .last_mut() - .unwrap() - .push(cell(fg, mc, false, false, true)); + let marked_cells = input_cells(&marked.chars().collect::>()); + for c in input_cells(&chars) { + // The caret sits on the base of a cell, so an IME's in-flight text + // opens wherever the caret is drawn — anywhere inside the cell, not + // only on its first character. + if has_marked && (c.start..c.end).contains(&cursor) { + for mc in &marked_cells { + lines.last_mut().unwrap().push(cell( + fg, + mc.text.clone(), + mc.width, + false, + false, + true, + )); } } + let i = c.start; if chars[i] == '\n' { if selection.is_none() && !has_marked && cursor_on && cursor == i { lines.last_mut().unwrap().push( @@ -5653,12 +5667,18 @@ impl TerminalView { lines.push(Vec::new()); continue; } - let selected = selection.is_some_and(|(s, e)| i >= s && i < e); - let caret = selection.is_none() && !has_marked && cursor_on && cursor == i; + // A cell is one glyph, so it tints as a unit: a selection that + // covers any character in it — the base or a mark riding on it — + // covers the whole thing. + let selected = selection.is_some_and(|(s, e)| s < c.end && c.start < e); + let caret = selection.is_none() + && !has_marked + && cursor_on + && (c.start..c.end).contains(&cursor); lines .last_mut() .unwrap() - .push(cell(colors[i], chars[i], selected, caret, false)); + .push(cell(colors[i], c.text, c.width, selected, caret, false)); } let ghost: Option = if selection.is_none() && !has_marked && !is_multiline { @@ -5672,8 +5692,8 @@ impl TerminalView { if cursor == len { let last = lines.last_mut().unwrap(); if has_marked { - for mc in marked.chars() { - last.push(cell(fg, mc, false, false, true)); + for mc in &marked_cells { + last.push(cell(fg, mc.text.clone(), mc.width, false, false, true)); } } else if ghost.is_none() { let mut tail = blank(cell_w).relative(); @@ -5686,9 +5706,10 @@ impl TerminalView { if let Some(rem) = ghost { let last = lines.last_mut().unwrap(); - for (gi, gc) in rem.chars().map(one_line_char).enumerate() { + let flat: Vec = rem.chars().map(one_line_char).collect(); + for (gi, gc) in input_cells(&flat).into_iter().enumerate() { let caret = gi == 0 && cursor == len && cursor_on; - last.push(cell(muted, gc, false, caret, false)); + last.push(cell(muted, gc.text, gc.width, false, caret, false)); } } @@ -6381,24 +6402,88 @@ fn highlight_runs(line: &str, positions: &[usize]) -> Vec<(String, bool)> { runs } +/// Columns this character occupies in the input bar. +/// +/// The bar lays out text the terminal is about to receive, so it has to agree +/// with the grid on where every character lands. The grid gets its widths from +/// `unicode-width` by way of `alacritty_terminal`, so the bar reads the same +/// table: a hand-written range list drifts from it the moment Unicode assigns +/// another block, and it silently counted `🀄`, `⌚` and every combining mark +/// as one plain column (#701). +/// +/// Control characters have no width of their own. The bar still gives them a +/// column — one was typed, and a cell that occupies nothing is a cell nobody +/// can put the caret on. fn display_width(c: char) -> usize { - let u = c as u32; - let wide = matches!(u, - 0x1100..=0x115F - | 0x2329 | 0x232A - | 0x2E80..=0x303E - | 0x3041..=0x33FF - | 0x3400..=0x4DBF - | 0x4E00..=0x9FFF - | 0xA000..=0xA4CF - | 0xAC00..=0xD7A3 - | 0xF900..=0xFAFF - | 0xFE10..=0xFE19 | 0xFE30..=0xFE6F - | 0xFF00..=0xFF60 | 0xFFE0..=0xFFE6 - | 0x1F300..=0x1FAFF - | 0x20000..=0x3FFFD - ); - if wide { 2 } else { 1 } + unicode_width::UnicodeWidthChar::width(c).unwrap_or(1) +} + +/// One drawn cell of the input bar. +#[derive(Debug, PartialEq)] +struct InputCell { + /// Character indices the cell covers, as `start..end`. + start: usize, + end: usize, + /// What to draw in it — a base character and whatever rides along with it. + text: String, + /// Columns it occupies. Zero for a newline, which ends the row rather than + /// taking space on it. + width: usize, +} + +/// Splits input-bar text into the cells it draws. +/// +/// Combining marks, variation selectors and the joiner inside an emoji +/// sequence take no column of their own, and the shaper has to see them in the +/// same run as their base to compose a single glyph — so they ride in the cell +/// of the character in front of them instead of each getting a box. Handing +/// them their own cell is what left `e` and its accent side by side, and every +/// mark shifted the rest of the row a column left (#701). +/// +/// A mark with no base ahead of it — text pasted mid-sequence, an IME's +/// in-flight buffer — keeps a cell and a column, so it stays visible and the +/// caret has somewhere to sit. +fn input_cells(chars: &[char]) -> Vec { + let mut cells: Vec = Vec::with_capacity(chars.len()); + for (i, &ch) in chars.iter().enumerate() { + if ch == '\n' { + cells.push(InputCell { + start: i, + end: i + 1, + text: String::new(), + width: 0, + }); + continue; + } + let w = display_width(ch); + match cells.last_mut() { + // `width > 0` keeps a mark off a newline's cell, which ends a row + // and draws nothing. + Some(last) if w == 0 && last.width > 0 => { + last.text.push(ch); + last.end = i + 1; + // U+FE0F asks for the emoji glyph, and an emoji presentation + // sequence is two columns wide even where its base is one — a + // rule that only exists at string level (UTS #51), which is why + // scoring character by character misses it. The grid re-scores + // the sequence the same way (our `alacritty_terminal` fork, + // #203), so the bar has to as well or `❤️` sits a column + // narrower here than where it lands. Never narrower than the + // base: giving a column back would mean pulling the row left. + if ch == '\u{FE0F}' { + let scored = unicode_width::UnicodeWidthStr::width(last.text.as_str()); + last.width = scored.max(last.width); + } + } + _ => cells.push(InputCell { + start: i, + end: i + 1, + text: ch.to_string(), + width: w.max(1), + }), + } + } + cells } #[derive(Debug, PartialEq)] @@ -6734,6 +6819,17 @@ fn menu_layout( (place_above, visible, first) } +/// Where each character of the input bar lands: `(row, column, width)`, one +/// entry per character, plus the row and column the text ends on. +/// +/// Walks the same cells the bar draws rather than re-deriving widths per +/// character — an emoji presentation sequence is two columns and a stranded +/// combining mark is one, and a second width table would put clicks, wrapping +/// and the caret a column away from the glyph on screen. +/// +/// Only the base of a cell carries the width, so a click can never land on a +/// character riding along with it. Those riders are parked at the column the +/// caret takes after the cell, which is where a caret sitting on one belongs. fn input_char_positions( chars: &[char], scol: usize, @@ -6742,20 +6838,22 @@ fn input_char_positions( let mut positions: Vec<(usize, usize, usize)> = Vec::with_capacity(chars.len()); let mut r = 0usize; let mut c = scol; - for &ch in chars { - if ch == '\n' { + for cell in input_cells(chars) { + if chars[cell.start] == '\n' { positions.push((r, c, 0)); r += 1; c = 0; continue; } - let w = display_width(ch).max(1); - if c + w > cols { + if c + cell.width > cols { r += 1; c = 0; } - positions.push((r, c, w)); - c += w; + positions.push((r, c, cell.width)); + c += cell.width; + for _ in cell.start + 1..cell.end { + positions.push((r, c, 0)); + } } (positions, r, c) } @@ -6959,9 +7057,10 @@ mod tests { use super::{ description_budget, drag_scroll_step, elide, encode_mouse, escape_candidate, expand_file_command_template, fallback_chain, fig_icon_emoji, fig_icon_glyph, - focus_report_bytes, highlight_runs, input_overflow_shift, input_overlay_rows, menu_layout, - paste_bytes, select_end_copy, shell_escape_path, should_show_context_menu, - smooth_scroll_step, submit_bytes, trim_trailing_spaces, wheel_route, wrapped_click_index, + focus_report_bytes, highlight_runs, input_cells, input_char_positions, + input_overflow_shift, input_overlay_rows, menu_layout, paste_bytes, select_end_copy, + shell_escape_path, should_show_context_menu, smooth_scroll_step, submit_bytes, + trim_trailing_spaces, wheel_route, wrapped_click_index, }; use alacritty_terminal::term::TermMode; use gpui::{ClipboardEntry, ClipboardItem, ExternalPaths, Modifiers}; @@ -8099,6 +8198,184 @@ mod tests { assert_eq!(display_width('±'), 1); } + /// Wide characters the grid reserves two columns for, scattered outside the + /// ranges anyone would think to hand-write: mahjong and playing cards below + /// the Miscellaneous Symbols and Pictographs block, and the handful of Wide + /// code points stranded in Misc Technical and Dingbats. + #[test] + fn display_width_covers_wide_chars_outside_the_main_emoji_blocks() { + assert_eq!(display_width('🀄'), 2); + assert_eq!(display_width('🃏'), 2); + assert_eq!(display_width('⌚'), 2); + assert_eq!(display_width('⏰'), 2); + assert_eq!(display_width('✅'), 2); + assert_eq!(display_width('❌'), 2); + } + + /// Combining marks ride on the cell of the character they decorate. Giving + /// one a column of its own shifts the rest of the line and hands the mark + /// to the shaper alone, with no base to attach to. + #[test] + fn display_width_combining_marks_take_no_column() { + // COMBINING ACUTE ACCENT — the second half of a decomposed `é`. + assert_eq!(display_width('\u{0301}'), 0); + // VARIATION SELECTOR-16, which asks for the emoji glyph. + assert_eq!(display_width('\u{FE0F}'), 0); + // ZERO WIDTH JOINER, the glue inside 👩‍💻. + assert_eq!(display_width('\u{200D}'), 0); + } + + /// The width table feeds the bar's own line breaking, so a character + /// counted short pulls everything after it one column left. + #[test] + fn input_char_positions_reserve_two_columns_for_wide_chars() { + let chars: Vec = "a🀄b".chars().collect(); + let (positions, _, _) = input_char_positions(&chars, 0, 80); + assert_eq!(positions, vec![(0, 0, 1), (0, 1, 2), (0, 3, 1)]); + } + + /// Geometry and drawing read the same cells, so a sequence the bar draws + /// two columns wide is two columns wide to wrapping and clicks as well. Two + /// width tables would put `X` under the right half of the heart. + #[test] + fn input_char_positions_agree_with_the_cells_the_bar_draws() { + for text in [ + "a🀄b", + "e\u{0301}X", + "\u{2764}\u{FE0F}X", + "\u{0301}ab", + "a\nb", + ] { + let chars: Vec = text.chars().collect(); + let (positions, _, _) = input_char_positions(&chars, 0, 80); + assert_eq!(positions.len(), chars.len(), "{text:?}"); + for cell in input_cells(&chars) { + let drawn = if chars[cell.start] == '\n' { + 0 + } else { + cell.width + }; + assert_eq!(positions[cell.start].2, drawn, "{text:?} at {}", cell.start); + for i in cell.start + 1..cell.end { + assert_eq!(positions[i].2, 0, "{text:?} at {i}"); + } + } + } + } + + /// `❤️` is two columns in the bar, so the character after it starts at + /// column 2 — and a click on either half of it lands on the heart. + #[test] + fn input_char_positions_reserve_two_columns_for_an_emoji_presentation_sequence() { + let chars: Vec = "\u{2764}\u{FE0F}X".chars().collect(); + let (positions, _, _) = input_char_positions(&chars, 0, 80); + assert_eq!(positions, vec![(0, 0, 2), (0, 2, 0), (0, 2, 1)]); + assert_eq!(click("\u{2764}\u{FE0F}X", 0, 80, 0, 0), Some(0)); + assert_eq!(click("\u{2764}\u{FE0F}X", 0, 80, 1, 0), Some(0)); + assert_eq!(click("\u{2764}\u{FE0F}X", 0, 80, 2, 0), Some(2)); + } + + /// A mark with no base gets a cell of its own on screen, so it has to get a + /// column here too — otherwise everything after it clicks one column off. + #[test] + fn input_char_positions_give_a_stranded_combining_mark_a_column() { + let chars: Vec = "\u{0301}ab".chars().collect(); + let (positions, _, _) = input_char_positions(&chars, 0, 80); + assert_eq!(positions, vec![(0, 0, 1), (0, 1, 1), (0, 2, 1)]); + } + + /// A cell wraps whole. Splitting `❤️` across rows would draw its two + /// columns on one row and count them on two. + #[test] + fn input_char_positions_wrap_a_cell_without_splitting_it() { + let chars: Vec = "abc\u{2764}\u{FE0F}".chars().collect(); + let (positions, r, c) = input_char_positions(&chars, 0, 4); + assert_eq!(positions[3], (1, 0, 2)); + assert_eq!((r, c), (1, 2)); + } + + /// Clicking the right half of a wide character lands on that character, not + /// on the one after it. + #[test] + fn wrapped_click_index_hits_both_halves_of_a_wide_char() { + assert_eq!(click("a🀄b", 0, 80, 1, 0), Some(1)); + assert_eq!(click("a🀄b", 0, 80, 2, 0), Some(1)); + assert_eq!(click("a🀄b", 0, 80, 3, 0), Some(2)); + } + + /// A combining mark shares its base's column, so a click there hits the + /// base — there is nowhere on screen that is the mark and not the base. + #[test] + fn wrapped_click_index_lands_on_the_base_not_its_combining_mark() { + // e + COMBINING ACUTE ACCENT + X. + assert_eq!(click("e\u{0301}X", 0, 80, 0, 0), Some(0)); + assert_eq!(click("e\u{0301}X", 0, 80, 1, 0), Some(2)); + } + + fn cells(text: &str) -> Vec<(usize, usize, String, usize)> { + let chars: Vec = text.chars().collect(); + input_cells(&chars) + .into_iter() + .map(|c| (c.start, c.end, c.text, c.width)) + .collect() + } + + #[test] + fn input_cells_keep_a_combining_mark_with_its_base() { + assert_eq!( + cells("e\u{0301}X"), + vec![ + (0, 2, "e\u{0301}".to_string(), 1), + (2, 3, "X".to_string(), 1), + ] + ); + } + + /// An emoji presentation sequence is two columns wide even though its base + /// is one on its own — the same re-scoring the terminal grid does. + #[test] + fn input_cells_widen_an_emoji_presentation_sequence() { + assert_eq!( + cells("\u{2764}\u{FE0F}X"), + vec![ + (0, 2, "\u{2764}\u{FE0F}".to_string(), 2), + (2, 3, "X".to_string(), 1), + ] + ); + // U+FE0E asks for the text glyph, and stays one column. + assert_eq!( + cells("\u{2764}\u{FE0E}"), + vec![(0, 2, "\u{2764}\u{FE0E}".to_string(), 1)] + ); + } + + /// A ZWJ sequence stays two cells, because that is what the grid does with + /// it: the joiner rides on the first emoji and the second one still claims + /// its own two columns. Composing the pair into one glyph is a separate + /// problem (#209) and fixing it here would put the bar a column off from + /// the row the text lands on. + #[test] + fn input_cells_split_a_zwj_sequence_the_way_the_grid_does() { + assert_eq!( + cells("\u{1F469}\u{200D}\u{1F4BB}"), + vec![ + (0, 2, "\u{1F469}\u{200D}".to_string(), 2), + (2, 3, "\u{1F4BB}".to_string(), 2), + ] + ); + } + + /// A mark with no base ahead of it still needs a column, or it is invisible + /// and the caret has nowhere to sit. A newline is not a base to hang one on. + #[test] + fn input_cells_give_a_stranded_combining_mark_a_column() { + assert_eq!(cells("\u{0301}"), vec![(0, 1, "\u{0301}".to_string(), 1)]); + assert_eq!( + cells("\n\u{0301}"), + vec![(0, 1, String::new(), 0), (1, 2, "\u{0301}".to_string(), 1),] + ); + } + fn click(text: &str, scol: usize, cols: usize, col: usize, row: usize) -> Option { let chars: Vec = text.chars().collect(); wrapped_click_index(&chars, scol, cols, col, row, false) From 975e3edf9b3458bc275c79a2d417443b76250c44 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:33:26 +0800 Subject: [PATCH 32/33] =?UTF-8?q?Fix=20Windows=20path=20quoting,=20wire=20?= =?UTF-8?q?up=20Checkout=20to=E2=80=A6,=20bound=20the=20Spawn=20reply=20(#?= =?UTF-8?q?705)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(windows,scm,daemon): quote paths per shell, wire Checkout to, bound Spawn Five fixes from a whole-codebase audit, in one sweep because they share the paths they touch. Path quoting had two implementations. file_tree::shell_quote_for wrapped the path in quotes and picked the right ones per shell (#593); view::shell_escape_path escaped with backslashes, which is POSIX-only and collides head-on with the Windows path separator, so a dropped file, a pasted path, a staged image path and an accepted completion candidate all lost their separators there. completion::complete_path stripped the same backslashes back off before looking a path up, so inline path completion could never resolve a directory on Windows either. Both now go through one core::shell_quote module, and shell_word_start tracks quoting across the word so a second Tab still finds the word it just inserted. "Checkout to..." was registered, listed in the palette, bindable, and handled by an empty match arm — invoking it did nothing at all. It now opens an inline input row in the SCM panel, the twin of the existing "create branch" one. RemoteTerminal's Spawn read the daemon's reply with no deadline, while Attach in the same file and PaneSession::spawn_over in core both bound theirs. A daemon caught mid-restart accepts the connection and never serves it, and the local route spawns synchronously on the UI thread, so the silence froze the window on "new tab". Two Windows papercuts: client_hostname spawned a console program from a GUI process (a visible console flash) where COMPUTERNAME already has the answer, and completion generators were a silent no-op with no way to tell "produced nothing" from "never ran". Three duplicated implementations merged: proc_name existed twice in the daemon with a different fallback in each, the GUI's control link was the one client socket that skipped transport::tune, and fps.rs and perf.rs were the same windowed meter copied twice. * refactor(completion): stop declaring spec fields nothing reads The Fig spec structs mirrored seven keys the completer never looks at, each held up by its own #[allow(dead_code)]. Serde ignores unknown fields by default, so dropping the declarations parses the same specs and drops the attributes with them. * refactor(daemon): delete the loopback-forward management pipeline Two protocol messages, their kind codes, encode and decode arms, two daemon dispatch arms, two wire structs and two GUI client wrappers all existed to reach SshManager::list_loopback_forwards and close_loopback_forward, which were hardcoded to Vec::new() and false. Nothing called the client wrappers either. The kind codes are left as holes rather than renumbered, the way 13 already is, so the wire format is unchanged for every other message. known-hosts management looks like the same shape but is not: its backend parses the real file, fingerprints keys and rewrites through a 0600 temp file. That one keeps its client half and gains a comment saying it is an interface waiting for a screen. * test(ssh): cover the host-key policy table and both proxy handshakes The host-key decision is lifted out of check_server_key into host_key_action, so what to do about Known/Unknown/Changed/ ChangedAlgorithm/Revoked can be read and tested without a server, a broker or a known_hosts file. Eight tests pin it, including the two subtleties the comments already claimed: verify_host_keys=false still rejects a revoked key, and a new algorithm asks the unknown-host prompt rather than a new variant older peers cannot decode. socks5_connect and http_connect are split into connect + handshake, the handshake generic over the stream, so nine tests drive them from an in-memory duplex: length-prefix framing, the variable-length bound address, auth refusal, reply codes, and the header terminator. * test(cli,daemon): cover server binary resolution and the procargs parser server_exe is split into environment lookup and resolve_server_exe, the latter taking its three sources and an is_exe predicate so seven tests can pin the precedence without touching the filesystem. Holding the sibling to is_file rather than exists fixes a directory named tty7-server shadowing the real binary on PATH. parse_macos_procargs gets six tests over the KERN_PROCARGS2 layout: exec-path skipping, however many bytes of alignment padding follow it, argc bounding argv so the environment stays out, truncation, and a short buffer. * test(ui): cover the host-op pool decisions and the local reconnect schedule The pool's retire condition moves into should_retire with the reason named: a worker must not retire on the timeout alone, because submit counted it as idle and so did not spawn a replacement for the job that landed meanwhile. LocalLink::tick's schedule moves into due(), taking the clock and the link's state as arguments. The first attempt going out immediately, the backoff only applying from the second, and a pending deadline not being pushed further out by later ticks are now pinned. The identical scheduler in remote_workspace had TestAppContext coverage; this one, which every launch depends on, had none. * fix(completion): unquote across the whole word, not just its first character The round-trip test caught two things the first cut got wrong. A quote can open partway into a word — quote_for_shell emits ~/'My Documents' so the shell still expands the tilde — and a single-quoted body is literal all through, so unescaping backslashes inside one took the separators out of 'C:\Users\me'. Scanning with a quote state handles both, and makes the '\'' seam fall out of the state changes rather than needing a case of its own. The GPUI test for accepting a candidate follows the insertion from backslash escaping to quoting. * fix(windows): unbreak the Windows build and quote for PowerShell's own dialect `Instant` was moved behind `#[cfg(unix)]` while the generator cache still uses it unconditionally, so the Windows target stopped compiling. The quoting module treated every shell but cmd.exe as POSIX, including PowerShell. PowerShell does not join a quoted string to the bare word beside it, so the `'\''` seam is not a seam there — `C:\Users\O'Brien` came out as three tokens, and the completion un-quoter turned the apostrophe back into a backslash. Quoting is now a three-way dialect (cmd / PowerShell / POSIX) chosen once and threaded through completion in place of the escapes flag. * test(file-tree): name the shell where the quoting rule is the POSIX one `shell_quote_for(_, None)` answers from the platform, so an assertion about the `'\''` seam has to say which shell it means or it fails on Windows, where the unnamed shell is PowerShell. --- crates/tty7-cli/src/server.rs | 193 +++++++++-- crates/tty7-core/src/core/git/log.rs | 9 +- crates/tty7-core/src/core/git/status.rs | 20 -- crates/tty7-core/src/daemon/install/wsl.rs | 36 -- crates/tty7-core/src/daemon/pane.rs | 35 +- crates/tty7-core/src/daemon/procinfo.rs | 40 ++- crates/tty7-core/src/daemon/protocol.rs | 50 --- crates/tty7-core/src/daemon/remote.rs | 82 +++++ crates/tty7-core/src/daemon/server.rs | 15 - crates/tty7-core/src/daemon/ssh/connect.rs | 201 ++++++++++- crates/tty7-core/src/daemon/ssh/handler.rs | 377 ++++++++++++++++----- crates/tty7-core/src/daemon/ssh/mod.rs | 12 +- src/core/keychain.rs | 11 - src/core/mod.rs | 2 + src/core/rate_meter.rs | 222 ++++++++++++ src/core/shell_quote.rs | 322 ++++++++++++++++++ src/terminal/completion.rs | 178 +++++----- src/terminal/fps.rs | 130 +++---- src/terminal/generator.rs | 69 +++- src/terminal/remote.rs | 86 +++-- src/terminal/signature.rs | 26 +- src/terminal/view.rs | 163 +++------ src/ui/app.rs | 7 +- src/ui/file_tree.rs | 38 +-- src/ui/host_ops.rs | 77 ++++- src/ui/local_link.rs | 126 ++++++- src/ui/palette.rs | 7 - src/ui/perf.rs | 108 ++---- src/ui/remote_connect.rs | 15 +- src/ui/scm/actions.rs | 4 +- src/ui/scm/panel.rs | 76 +++++ src/ui/scm/state.rs | 4 + 32 files changed, 1968 insertions(+), 773 deletions(-) create mode 100644 src/core/rate_meter.rs create mode 100644 src/core/shell_quote.rs diff --git a/crates/tty7-cli/src/server.rs b/crates/tty7-cli/src/server.rs index 77cfa924..6ab40893 100644 --- a/crates/tty7-cli/src/server.rs +++ b/crates/tty7-cli/src/server.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; @@ -278,34 +278,70 @@ pub fn logs() -> Result { } fn server_exe() -> Result { - if let Some(explicit) = std::env::var_os(SERVER_EXE_ENV).filter(|v| !v.is_empty()) { - return Ok(PathBuf::from(explicit)); - } - let name = if cfg!(windows) { + let name = server_exe_name(); + let own_dir = std::env::current_exe() + .ok() + .and_then(|own| own.parent().map(Path::to_path_buf)); + let path_dirs: Vec = std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect()) + .unwrap_or_default(); + resolve_server_exe( + std::env::var_os(SERVER_EXE_ENV) + .filter(|v| !v.is_empty()) + .as_deref(), + own_dir.as_deref(), + &path_dirs, + name, + |p| p.is_file(), + ) + .ok_or_else(|| { + anyhow::anyhow!( + "could not find {name} next to this binary or on PATH — install it, or point \ + {SERVER_EXE_ENV} at it" + ) + }) +} + +fn server_exe_name() -> &'static str { + if cfg!(windows) { "tty7-server.exe" } else { "tty7-server" - }; - if let Ok(own) = std::env::current_exe() { - if let Some(dir) = own.parent() { - let sibling = dir.join(name); - if sibling.exists() { - return Ok(sibling); - } + } +} + +/// Where the server binary is, in the order the three sources are trusted. +/// +/// An explicit override wins outright and is not checked for existence: the +/// caller asked for that path, and a "not found" from the spawn names it, where +/// silently falling through to a *different* binary would not. +/// +/// A sibling of this binary comes next — that is the shipped layout — and PATH +/// last. Both are held to the same test: the sibling used to be accepted on +/// `exists()`, so a directory named `tty7-server` shadowed the real one on PATH +/// and turned a working install into a spawn failure. +/// +/// `is_exe` is passed in so a test can answer without touching the filesystem. +fn resolve_server_exe( + explicit: Option<&std::ffi::OsStr>, + own_dir: Option<&Path>, + path_dirs: &[PathBuf], + name: &str, + is_exe: impl Fn(&Path) -> bool, +) -> Option { + if let Some(explicit) = explicit { + return Some(PathBuf::from(explicit)); + } + if let Some(dir) = own_dir { + let sibling = dir.join(name); + if is_exe(&sibling) { + return Some(sibling); } } - if let Some(paths) = std::env::var_os("PATH") { - for dir in std::env::split_paths(&paths) { - let candidate = dir.join(name); - if candidate.is_file() { - return Ok(candidate); - } - } - } - bail!( - "could not find {name} next to this binary or on PATH — install it, or point \ - {SERVER_EXE_ENV} at it" - ) + path_dirs + .iter() + .map(|dir| dir.join(name)) + .find(|candidate| is_exe(candidate)) } #[cfg(unix)] @@ -335,3 +371,112 @@ fn detach(cmd: &mut Command) { #[cfg(not(any(unix, windows)))] fn detach(_cmd: &mut Command) {} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsStr; + + const NAME: &str = "tty7-server"; + + fn dirs(paths: &[&str]) -> Vec { + paths.iter().map(PathBuf::from).collect() + } + + /// Stands in for the filesystem: only the listed paths are runnable files. + fn only(files: &'static [&'static str]) -> impl Fn(&Path) -> bool { + move |p: &Path| files.iter().any(|f| Path::new(f) == p) + } + + #[test] + fn the_override_wins_and_is_taken_at_its_word() { + let got = resolve_server_exe( + Some(OsStr::new("/opt/custom/tty7-server")), + Some(Path::new("/usr/local/bin")), + &dirs(&["/usr/bin"]), + NAME, + only(&["/usr/local/bin/tty7-server", "/usr/bin/tty7-server"]), + ); + assert_eq!(got, Some(PathBuf::from("/opt/custom/tty7-server"))); + } + + /// Not existence-checked on purpose: a bad override should fail loudly by + /// that name, not quietly run a different binary. + #[test] + fn a_nonexistent_override_is_still_returned() { + let got = resolve_server_exe( + Some(OsStr::new("/nope/tty7-server")), + Some(Path::new("/usr/local/bin")), + &dirs(&["/usr/bin"]), + NAME, + only(&["/usr/bin/tty7-server"]), + ); + assert_eq!(got, Some(PathBuf::from("/nope/tty7-server"))); + } + + #[test] + fn a_sibling_beats_path() { + let got = resolve_server_exe( + None, + Some(Path::new("/opt/tty7")), + &dirs(&["/usr/bin"]), + NAME, + only(&["/opt/tty7/tty7-server", "/usr/bin/tty7-server"]), + ); + assert_eq!(got, Some(PathBuf::from("/opt/tty7/tty7-server"))); + } + + #[test] + fn path_is_searched_in_order_when_there_is_no_sibling() { + let got = resolve_server_exe( + None, + Some(Path::new("/opt/tty7")), + &dirs(&["/a", "/b", "/c"]), + NAME, + only(&["/b/tty7-server", "/c/tty7-server"]), + ); + assert_eq!(got, Some(PathBuf::from("/b/tty7-server"))); + } + + /// The bug behind holding both candidates to `is_file`: a *directory* + /// named `tty7-server` beside this binary used to satisfy `exists()`, so it + /// shadowed the real one on PATH and the spawn failed on a working install. + #[test] + fn a_directory_by_that_name_does_not_shadow_the_real_binary() { + let got = resolve_server_exe( + None, + Some(Path::new("/opt/tty7")), + &dirs(&["/usr/bin"]), + NAME, + // /opt/tty7/tty7-server exists but is not a file, so it is absent here. + only(&["/usr/bin/tty7-server"]), + ); + assert_eq!(got, Some(PathBuf::from("/usr/bin/tty7-server"))); + } + + #[test] + fn nothing_anywhere_is_a_miss_rather_than_a_guess() { + assert_eq!( + resolve_server_exe( + None, + Some(Path::new("/opt/tty7")), + &dirs(&["/usr/bin"]), + NAME, + only(&[]), + ), + None + ); + } + + #[test] + fn no_own_directory_falls_through_to_path() { + let got = resolve_server_exe( + None, + None, + &dirs(&["/usr/bin"]), + NAME, + only(&["/usr/bin/tty7-server"]), + ); + assert_eq!(got, Some(PathBuf::from("/usr/bin/tty7-server"))); + } +} diff --git a/crates/tty7-core/src/core/git/log.rs b/crates/tty7-core/src/core/git/log.rs index c34840c9..ad3251f9 100644 --- a/crates/tty7-core/src/core/git/log.rs +++ b/crates/tty7-core/src/core/git/log.rs @@ -58,8 +58,13 @@ pub const MAX_LOG_BYTES: usize = 16 * 1024 * 1024; pub const REC_SEP: u8 = 0x1e; pub const FIELD_SEP: u8 = 0x1f; -/// A timestamp plus the author's own UTC offset, so times can be shown in the -/// zone they were written in. Parsed from `%aI` / `%cI`. +/// A timestamp plus the author's own UTC offset, parsed from `%aI` / `%cI`. +/// +/// `offset_minutes` is already subtracted out of `unix`, which is the field +/// everything currently renders from. Keeping the offset costs four bytes and +/// preserves the one thing the conversion to UTC throws away — what o'clock it +/// was where the commit was written. Nothing shows that yet; the parser is +/// simply not the place to lose it. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub struct OffsetTs { pub unix: i64, diff --git a/crates/tty7-core/src/core/git/status.rs b/crates/tty7-core/src/core/git/status.rs index 00999163..20a1b4c4 100644 --- a/crates/tty7-core/src/core/git/status.rs +++ b/crates/tty7-core/src/core/git/status.rs @@ -147,19 +147,6 @@ impl ConflictKind { _ => return None, }) } - - /// Whether our side still has a file — decides if "open changes" can show - /// an ours/theirs diff or only one stage. - pub fn ours_exists(self) -> bool { - !matches!(self, ConflictKind::BothDeleted | ConflictKind::DeletedByUs) - } - - pub fn theirs_exists(self) -> bool { - !matches!( - self, - ConflictKind::BothDeleted | ConflictKind::DeletedByThem - ) - } } #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] @@ -1277,13 +1264,6 @@ mod tests { by_path(&parsed, "deleted-by-us.rs").conflict, Some(ConflictKind::DeletedByUs) ); - assert!( - !by_path(&parsed, "deleted-by-us.rs") - .conflict - .unwrap() - .ours_exists() - ); - for entry in &parsed.entries { assert_eq!(entry.kind, EntryKind::Unmerged); assert!(!entry.is_staged(), "{} leaked into Staged", entry.path.text); diff --git a/crates/tty7-core/src/daemon/install/wsl.rs b/crates/tty7-core/src/daemon/install/wsl.rs index 8c43e82a..4f18a992 100644 --- a/crates/tty7-core/src/daemon/install/wsl.rs +++ b/crates/tty7-core/src/daemon/install/wsl.rs @@ -86,30 +86,6 @@ pub fn host_label(distro: &str) -> String { format!("wsl:{distro}") } -pub fn list_distros() -> Vec { - let mut cmd = std::process::Command::new(WSL_EXE); - cmd.args(["-l", "-q"]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - let Ok(out) = crate::core::proc::hide_console(&mut cmd).output() else { - return Vec::new(); - }; - if !out.status.success() { - return Vec::new(); - } - parse_distro_list(&out.stdout) -} - -pub fn parse_distro_list(bytes: &[u8]) -> Vec { - decode_wsl_text(bytes) - .lines() - .map(|line| line.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}' || c == '\0')) - .filter(|line| !line.is_empty() && !line.starts_with("docker-desktop")) - .map(str::to_string) - .collect() -} - pub fn decode_wsl_text(bytes: &[u8]) -> String { if !bytes.contains(&0) { return strip_bom(&String::from_utf8_lossy(bytes)); @@ -1166,18 +1142,6 @@ mod tests { assert_eq!(out.trim(), "/", "the fallback must be `/`, not a failure"); } - #[test] - fn the_distro_list_is_decoded_and_filtered() { - let raw = utf16le( - "Ubuntu-22.04\r\ndocker-desktop\r\ndocker-desktop-data\r\nArch\r\n\r\n", - true, - ); - assert_eq!(parse_distro_list(&raw), vec!["Ubuntu-22.04", "Arch"]); - assert!(parse_distro_list(&[]).is_empty()); - let padded = utf16le("Ubuntu\0\r\n", true); - assert_eq!(parse_distro_list(&padded), vec!["Ubuntu"]); - } - #[test] fn script_paths_are_shell_quoted() { let script = stat_script("/home/o'brien/my dir/tty7-server-1.0.0"); diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index ad6fc8ad..13f1de19 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -2182,7 +2182,7 @@ impl DaemonPane { .lock() .ok() .and_then(|m| m.as_ref().and_then(|m| m.process_group_leader())) - .and_then(proc_name) + .and_then(super::procinfo::proc_name) .unwrap_or_default() } @@ -3015,39 +3015,6 @@ fn hex_val(b: u8) -> Option { } } -#[cfg(target_os = "macos")] -fn proc_name(pid: i32) -> Option { - if pid <= 0 { - return None; - } - let mut buf = [0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; - let ret = - unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr() as *mut libc::c_void, buf.len() as u32) }; - if ret <= 0 { - return None; - } - let path = std::str::from_utf8(&buf[..ret as usize]).ok()?; - Some(path.rsplit('/').next().unwrap_or(path).to_string()) -} - -#[cfg(target_os = "linux")] -fn proc_name(pid: i32) -> Option { - if pid <= 0 { - return None; - } - if let Ok(path) = std::fs::read_link(format!("/proc/{pid}/exe")) { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - let name = name.strip_suffix(" (deleted)").unwrap_or(name); - if !name.is_empty() { - return Some(name.to_string()); - } - } - } - let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?; - let comm = comm.trim(); - (!comm.is_empty()).then(|| comm.to_string()) -} - #[cfg(test)] mod tests { diff --git a/crates/tty7-core/src/daemon/procinfo.rs b/crates/tty7-core/src/daemon/procinfo.rs index ddd9c81b..03732ccb 100644 --- a/crates/tty7-core/src/daemon/procinfo.rs +++ b/crates/tty7-core/src/daemon/procinfo.rs @@ -173,8 +173,21 @@ fn process_table() -> HashMap { HashMap::new() } +/// The executable name behind a pid. +/// +/// One copy, shared with `pane.rs`. There used to be two, and each carried a +/// guard the other lacked — this one had no `pid <= 0` check, and its Linux +/// arm had no `/proc//comm` fallback — so the two disagreed about the +/// name of the same process whenever the executable link was unreadable. +/// +/// Callers may still layer their own fallback on top: `process_table` reaches +/// for the kernel's short name when this returns `None`, which is what covers +/// a process whose path this cannot read at all. #[cfg(target_os = "macos")] -fn proc_name(pid: i32) -> Option { +pub(super) fn proc_name(pid: i32) -> Option { + if pid <= 0 { + return None; + } let mut buf = [0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; let ret = unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr() as *mut libc::c_void, buf.len() as u32) }; @@ -185,12 +198,27 @@ fn proc_name(pid: i32) -> Option { Some(path.rsplit('/').next().unwrap_or(path).to_string()) } +/// See the macOS arm above. +/// +/// `/proc//exe` is a link the kernel refuses to resolve for a process +/// owned by someone else, so the `comm` fallback is what keeps a differently +/// owned process from coming back nameless. #[cfg(target_os = "linux")] -fn proc_name(pid: i32) -> Option { - let path = std::fs::read_link(format!("/proc/{pid}/exe")).ok()?; - let name = path.file_name()?.to_str()?; - let name = name.strip_suffix(" (deleted)").unwrap_or(name); - (!name.is_empty()).then(|| name.to_string()) +pub(super) fn proc_name(pid: i32) -> Option { + if pid <= 0 { + return None; + } + if let Ok(path) = std::fs::read_link(format!("/proc/{pid}/exe")) { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + let name = name.strip_suffix(" (deleted)").unwrap_or(name); + if !name.is_empty() { + return Some(name.to_string()); + } + } + } + let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?; + let comm = comm.trim(); + (!comm.is_empty()).then(|| comm.to_string()) } #[cfg(unix)] diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 66f91615..19b3dc6b 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -190,22 +190,6 @@ pub struct LoopbackForward { pub local_port: u16, } -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct LoopbackForwardId { - pub pane_id: u64, - pub target: String, - pub remote_host: String, - pub remote_port: u16, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct LoopbackForwardInfo { - pub id: LoopbackForwardId, - pub local_port: u16, - pub age_secs: u64, - pub idle_secs: u64, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "kebab-case")] pub enum SshAuthMode { @@ -778,8 +762,6 @@ pub enum ClientMsg { exe: PathBuf, }, EnsureLoopbackForward(LoopbackForwardRequest), - ListLoopbackForwards, - CloseLoopbackForward(LoopbackForwardId), SpawnNativeSsh { cwd: Option, size: WinSize, @@ -866,7 +848,6 @@ pub enum DaemonMsg { Agent(Option), AgentStatus(Option), LoopbackForward(LoopbackForward), - LoopbackForwardList(Vec), AuthPrompt { request_id: u64, prompt: AuthPromptKind, @@ -899,8 +880,6 @@ mod kind { pub const SHUTDOWN: u8 = 8; pub const SPAWN_SHELL: u8 = 9; pub const ENSURE_LOOPBACK_FORWARD: u8 = 10; - pub const LIST_LOOPBACK_FORWARDS: u8 = 11; - pub const CLOSE_LOOPBACK_FORWARD: u8 = 12; pub const SPAWN_NATIVE_SSH: u8 = 14; pub const AUTH_RESPONSE: u8 = 15; pub const LIST_KNOWN_HOSTS: u8 = 16; @@ -933,7 +912,6 @@ mod kind { pub const SIZE: u8 = 9; pub const REMOTE_CONTEXT: u8 = 10; pub const LOOPBACK_FORWARD: u8 = 11; - pub const LOOPBACK_FORWARD_LIST: u8 = 12; pub const AUTH_PROMPT: u8 = 13; pub const SSH_STATUS: u8 = 14; pub const KNOWN_HOSTS_LIST: u8 = 15; @@ -1118,10 +1096,6 @@ impl ClientMsg { ClientMsg::EnsureLoopbackForward(req) => { write_frame(w, kind::ENSURE_LOOPBACK_FORWARD, &to_json(req)?) } - ClientMsg::ListLoopbackForwards => write_frame(w, kind::LIST_LOOPBACK_FORWARDS, &[]), - ClientMsg::CloseLoopbackForward(id) => { - write_frame(w, kind::CLOSE_LOOPBACK_FORWARD, &to_json(id)?) - } ClientMsg::SpawnNativeSsh { cwd, size, spec } => { write_frame(w, kind::SPAWN_NATIVE_SSH, &to_json(&(cwd, size, spec))?) } @@ -1233,8 +1207,6 @@ impl ClientMsg { exe: from_json(&payload)?, }, kind::ENSURE_LOOPBACK_FORWARD => ClientMsg::EnsureLoopbackForward(from_json(&payload)?), - kind::LIST_LOOPBACK_FORWARDS => ClientMsg::ListLoopbackForwards, - kind::CLOSE_LOOPBACK_FORWARD => ClientMsg::CloseLoopbackForward(from_json(&payload)?), kind::SPAWN_NATIVE_SSH => { let (cwd, size, spec) = from_json(&payload)?; ClientMsg::SpawnNativeSsh { cwd, size, spec } @@ -1326,9 +1298,6 @@ impl DaemonMsg { DaemonMsg::LoopbackForward(forward) => { write_frame(w, kind::LOOPBACK_FORWARD, &to_json(forward)?) } - DaemonMsg::LoopbackForwardList(forwards) => { - write_frame(w, kind::LOOPBACK_FORWARD_LIST, &to_json(forwards)?) - } DaemonMsg::AuthPrompt { request_id, prompt } => { write_frame(w, kind::AUTH_PROMPT, &to_json(&(request_id, prompt))?) } @@ -1388,7 +1357,6 @@ impl DaemonMsg { kind::AGENT => DaemonMsg::Agent(from_json(&payload)?), kind::AGENT_STATUS => DaemonMsg::AgentStatus(from_json(&payload)?), kind::LOOPBACK_FORWARD => DaemonMsg::LoopbackForward(from_json(&payload)?), - kind::LOOPBACK_FORWARD_LIST => DaemonMsg::LoopbackForwardList(from_json(&payload)?), kind::AUTH_PROMPT => { let (request_id, prompt) = from_json(&payload)?; DaemonMsg::AuthPrompt { request_id, prompt } @@ -1570,13 +1538,6 @@ mod tests { remote_host: "127.0.0.1".into(), remote_port: 3000, }), - ClientMsg::ListLoopbackForwards, - ClientMsg::CloseLoopbackForward(LoopbackForwardId { - pane_id: 7, - target: "dev".into(), - remote_host: "127.0.0.1".into(), - remote_port: 3000, - }), ClientMsg::ListKnownHosts, ClientMsg::DeleteKnownHost(KnownHostId { host: "example.com".into(), @@ -1741,17 +1702,6 @@ mod tests { })), DaemonMsg::AgentStatus(None), DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }), - DaemonMsg::LoopbackForwardList(vec![LoopbackForwardInfo { - id: LoopbackForwardId { - pane_id: 7, - target: "dev".into(), - remote_host: "127.0.0.1".into(), - remote_port: 3000, - }, - local_port: 49152, - age_secs: 12, - idle_secs: 3, - }]), DaemonMsg::KnownHostsList(vec![KnownHostEntry { host: "example.com".into(), marker: Some("@revoked".into()), diff --git a/crates/tty7-core/src/daemon/remote.rs b/crates/tty7-core/src/daemon/remote.rs index 3eaa2404..c6fee7b8 100644 --- a/crates/tty7-core/src/daemon/remote.rs +++ b/crates/tty7-core/src/daemon/remote.rs @@ -257,3 +257,85 @@ mod tests { assert!(parse_ssh_invocation(&argv(&["scp", "dev:/x", "."])).is_none()); } } + +#[cfg(target_os = "macos")] +#[cfg(test)] +mod procargs_tests { + use super::*; + + /// Build a KERN_PROCARGS2 buffer: argc, the exec path, then argc + /// NUL-terminated arguments, with the alignment padding the kernel leaves + /// between the path and the first argument. + fn procargs(argc: i32, exec_path: &str, args: &[&str], pad: usize) -> Vec { + let mut buf = argc.to_ne_bytes().to_vec(); + buf.extend_from_slice(exec_path.as_bytes()); + buf.push(0); + buf.extend(std::iter::repeat_n(0u8, pad)); + for a in args { + buf.extend_from_slice(a.as_bytes()); + buf.push(0); + } + buf + } + + #[test] + fn the_exec_path_is_skipped_and_argv_comes_back_in_order() { + let buf = procargs(3, "/usr/bin/ssh", &["ssh", "-p", "2222"], 0); + assert_eq!( + parse_macos_procargs(&buf), + Some(vec!["ssh".into(), "-p".into(), "2222".into()]) + ); + } + + /// The kernel pads between the exec path and argv, and the amount varies. + /// Miscounting it would return the tail of the path as argv[0]. + #[test] + fn alignment_padding_after_the_exec_path_is_skipped_however_long() { + for pad in 0..8 { + let buf = procargs(1, "/usr/bin/ssh", &["ssh"], pad); + assert_eq!( + parse_macos_procargs(&buf), + Some(vec!["ssh".to_string()]), + "{pad} bytes of padding" + ); + } + } + + /// argc counts what to read, so anything after it — the environment — + /// stays out of argv. + #[test] + fn the_environment_after_argv_is_not_read() { + let buf = procargs( + 2, + "/usr/bin/ssh", + &["ssh", "host", "PATH=/bin", "HOME=/me"], + 0, + ); + assert_eq!( + parse_macos_procargs(&buf), + Some(vec!["ssh".into(), "host".into()]) + ); + } + + /// A buffer that ends mid-argv stops there rather than running off the end. + #[test] + fn a_truncated_buffer_returns_what_it_had() { + let full = procargs(4, "/usr/bin/ssh", &["ssh", "-p", "2222", "host"], 0); + let cut = &full[..full.len() - 6]; + let got = parse_macos_procargs(cut).expect("what survived is still argv"); + assert!(got.len() < 4, "{got:?}"); + assert_eq!(got[0], "ssh"); + } + + #[test] + fn a_buffer_too_short_to_hold_argc_is_rejected() { + assert_eq!(parse_macos_procargs(&[]), None); + assert_eq!(parse_macos_procargs(&[0, 0, 0]), None); + } + + #[test] + fn a_process_with_no_arguments_at_all_is_none_rather_than_empty() { + let buf = procargs(0, "/usr/bin/ssh", &[], 0); + assert_eq!(parse_macos_procargs(&buf), None); + } +} diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 0f50f028..0af97dcb 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -880,21 +880,6 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { Ok(()) } - ClientMsg::ListLoopbackForwards => { - let mut w = write_stream; - let list = crate::daemon::ssh::SshManager::global().list_loopback_forwards(); - DaemonMsg::LoopbackForwardList(list).encode(&mut w)?; - Ok(()) - } - - ClientMsg::CloseLoopbackForward(id) => { - let mut w = write_stream; - crate::daemon::ssh::SshManager::global().close_loopback_forward(&id); - let list = crate::daemon::ssh::SshManager::global().list_loopback_forwards(); - DaemonMsg::LoopbackForwardList(list).encode(&mut w)?; - Ok(()) - } - ClientMsg::ListKnownHosts => { let mut w = write_stream; let list = crate::daemon::ssh::known_hosts::list(); diff --git a/crates/tty7-core/src/daemon/ssh/connect.rs b/crates/tty7-core/src/daemon/ssh/connect.rs index cc1e56ac..7e173d7c 100644 --- a/crates/tty7-core/src/daemon/ssh/connect.rs +++ b/crates/tty7-core/src/daemon/ssh/connect.rs @@ -295,6 +295,20 @@ async fn socks5_connect( .map_err(|e| { anyhow::anyhow!("connect to SOCKS proxy {proxy_host}:{proxy_port} failed: {e}") })?; + socks5_handshake(&mut s, target, target_port).await?; + Ok(s) +} + +/// The SOCKS5 exchange itself, over a stream that is already connected. +/// +/// Generic over the stream rather than taking a `TcpStream`, which is what +/// lets a test drive it from an in-memory duplex instead of standing up a +/// proxy. This is hand-rolled wire format with length prefixes in it; it +/// deserves to be exercised. +async fn socks5_handshake(s: &mut S, target: &str, target_port: u16) -> anyhow::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ s.write_all(&[0x05, 0x01, 0x00]).await?; let mut reply = [0u8; 2]; s.read_exact(&mut reply).await?; @@ -326,7 +340,7 @@ async fn socks5_connect( }; let mut discard = vec![0u8; addr_len + 2]; s.read_exact(&mut discard).await?; - Ok(s) + Ok(()) } async fn http_connect( @@ -340,6 +354,15 @@ async fn http_connect( .map_err(|e| { anyhow::anyhow!("connect to HTTP proxy {proxy_host}:{proxy_port} failed: {e}") })?; + http_connect_handshake(&mut s, target, target_port).await?; + Ok(s) +} + +/// See [`socks5_handshake`] — same split, same reason. +async fn http_connect_handshake(s: &mut S, target: &str, target_port: u16) -> anyhow::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ let req = format!( "CONNECT {target}:{target_port} HTTP/1.1\r\nHost: {target}:{target_port}\r\nProxy-Connection: keep-alive\r\n\r\n" ); @@ -366,7 +389,7 @@ async fn http_connect( let first = head.lines().next().unwrap_or("").trim(); anyhow::bail!("HTTP CONNECT failed: {first}"); } - Ok(s) + Ok(()) } pub fn build_config(spec: &NativeSshSpec) -> Arc { @@ -580,3 +603,177 @@ mod tests { ); } } + +#[cfg(test)] +mod proxy_handshake_tests { + use super::*; + use tokio::io::duplex; + + /// Drive a handshake against a scripted peer. + /// + /// `script` is what the fake proxy says, in order; the bytes the handshake + /// sent come back for inspection. An in-memory duplex stands in for the + /// socket, which is the whole reason the handshakes take a generic stream. + async fn run_socks5(script: &[u8], target: &str, port: u16) -> (anyhow::Result<()>, Vec) { + let (mut client, mut proxy) = duplex(4096); + let script = script.to_vec(); + let peer = tokio::spawn(async move { + // Answer first so a handshake that reads before writing cannot + // deadlock against a duplex nobody is draining. + let _ = proxy.write_all(&script).await; + let mut seen = Vec::new(); + let mut chunk = [0u8; 512]; + while let Ok(n) = proxy.read(&mut chunk).await { + if n == 0 { + break; + } + seen.extend_from_slice(&chunk[..n]); + } + seen + }); + let out = socks5_handshake(&mut client, target, port).await; + drop(client); + let sent = peer.await.unwrap_or_default(); + (out, sent) + } + + #[tokio::test] + async fn socks5_greets_with_no_auth_and_addresses_the_target_by_name() { + // no-auth accepted, then CONNECT granted with an IPv4 bound address. + let script = [ + &[0x05u8, 0x00][..], + &[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0x1f, 0x90][..], + ] + .concat(); + let (out, sent) = run_socks5(&script, "example.com", 22).await; + assert!(out.is_ok(), "{:?}", out.err()); + assert_eq!( + &sent[..3], + &[0x05, 0x01, 0x00], + "greeting is SOCKS5 no-auth" + ); + let req = &sent[3..]; + assert_eq!( + &req[..5], + &[0x05, 0x01, 0x00, 0x03, 11], + "CONNECT by domain name, 11 bytes of it" + ); + assert_eq!(&req[5..16], b"example.com"); + assert_eq!(&req[16..18], &22u16.to_be_bytes(), "port is big-endian"); + } + + #[tokio::test] + async fn socks5_reads_the_variable_length_bound_address_before_returning() { + // ATYP 0x03: a length byte then that many bytes, then the port. Getting + // this wrong leaves unread bytes that the SSH banner exchange would + // then read as protocol. + let mut script = vec![0x05, 0x00, 0x05, 0x00, 0x00, 0x03, 3]; + script.extend_from_slice(b"abc"); + script.extend_from_slice(&[0x00, 0x16]); + let (out, _) = run_socks5(&script, "example.com", 22).await; + assert!(out.is_ok(), "{:?}", out.err()); + } + + #[tokio::test] + async fn socks5_refuses_a_proxy_that_wants_authentication() { + let (out, _) = run_socks5(&[0x05, 0x02], "example.com", 22).await; + let msg = out + .expect_err("a proxy demanding auth must fail") + .to_string(); + assert!(msg.contains("no-auth"), "{msg}"); + } + + #[tokio::test] + async fn socks5_surfaces_the_connect_reply_code() { + let (out, _) = run_socks5(&[0x05, 0x00, 0x05, 0x05, 0x00, 0x01], "example.com", 22).await; + let msg = out.expect_err("reply code 5 is a refusal").to_string(); + assert!(msg.contains("reply code 5"), "{msg}"); + } + + #[tokio::test] + async fn socks5_rejects_a_host_too_long_for_its_length_byte() { + let long = "a".repeat(256); + let (out, _) = run_socks5(&[0x05, 0x00], &long, 22).await; + let msg = out + .expect_err("256 bytes will not fit in one byte") + .to_string(); + assert!(msg.contains("too long"), "{msg}"); + } + + async fn run_http(script: &str, target: &str, port: u16) -> (anyhow::Result<()>, String) { + let (mut client, mut proxy) = duplex(16384); + let script = script.to_string(); + let peer = tokio::spawn(async move { + let _ = proxy.write_all(script.as_bytes()).await; + let mut seen = Vec::new(); + let mut chunk = [0u8; 512]; + while let Ok(n) = proxy.read(&mut chunk).await { + if n == 0 { + break; + } + seen.extend_from_slice(&chunk[..n]); + } + seen + }); + let out = http_connect_handshake(&mut client, target, port).await; + drop(client); + let sent = peer.await.unwrap_or_default(); + (out, String::from_utf8_lossy(&sent).into_owned()) + } + + #[tokio::test] + async fn http_connect_asks_for_the_target_and_accepts_200() { + let (out, sent) = run_http( + "HTTP/1.1 200 Connection established\r\nVia: 1.1 proxy\r\n\r\n", + "example.com", + 22, + ) + .await; + assert!(out.is_ok(), "{:?}", out.err()); + assert!( + sent.starts_with("CONNECT example.com:22 HTTP/1.1\r\n"), + "{sent:?}" + ); + assert!(sent.contains("Host: example.com:22\r\n"), "{sent:?}"); + assert!(sent.ends_with("\r\n\r\n"), "request must be terminated"); + } + + #[tokio::test] + async fn http_connect_stops_at_the_header_terminator_and_not_before() { + // A header block containing a bare \r\n must not be mistaken for the + // end; only \r\n\r\n ends it. If this read short, the leftover header + // bytes would land in the SSH banner exchange. + let (out, _) = run_http( + "HTTP/1.1 200 OK\r\nX-A: 1\r\nX-B: 2\r\n\r\n", + "example.com", + 22, + ) + .await; + assert!(out.is_ok(), "{:?}", out.err()); + } + + #[tokio::test] + async fn http_connect_reports_the_status_line_on_refusal() { + let (out, _) = run_http( + "HTTP/1.1 407 Proxy Authentication Required\r\n\r\n", + "h", + 22, + ) + .await; + let msg = out.expect_err("407 is a refusal").to_string(); + assert!(msg.contains("407"), "{msg}"); + } + + /// `" 200"` with the space is what the status check looks for, so a 2000-ish + /// code or a 200 appearing in a header must not be mistaken for success. + #[tokio::test] + async fn http_connect_does_not_take_a_header_mentioning_200_for_success() { + let (out, _) = run_http( + "HTTP/1.1 502 Bad Gateway\r\nX-Upstream: 200\r\n\r\n", + "h", + 22, + ) + .await; + assert!(out.is_err(), "only the status line decides"); + } +} diff --git a/crates/tty7-core/src/daemon/ssh/handler.rs b/crates/tty7-core/src/daemon/ssh/handler.rs index 170b336a..1c8f54cd 100644 --- a/crates/tty7-core/src/daemon/ssh/handler.rs +++ b/crates/tty7-core/src/daemon/ssh/handler.rs @@ -20,36 +20,110 @@ pub struct ClientHandler { pub remote_forwards: RemoteForwardTable, } +/// What a host-key status calls for, before anything is asked or written. +/// +/// Split out of `check_server_key` so the policy can be read and tested on its +/// own: deciding whether to trust a key needs no server, no broker and no +/// known_hosts file, and only the carrying-out does. +#[derive(Debug)] +pub(super) enum HostKeyAction { + Accept, + Reject, + Ask(Box), +} + +/// The whole host-key policy table. +/// +/// `verify_host_keys = false` still rejects a revoked key: turning verification +/// off says "I do not know this host and do not care", not "ignore a key its +/// owner has published as compromised". +pub(super) fn host_key_action( + status: HostKeyStatus, + verify_host_keys: bool, + host: &str, + port: u16, + algorithm: String, + fingerprint_sha256: String, +) -> HostKeyAction { + if !verify_host_keys { + return match status { + HostKeyStatus::Revoked => HostKeyAction::Reject, + _ => HostKeyAction::Accept, + }; + } + match status { + HostKeyStatus::Known => HostKeyAction::Accept, + HostKeyStatus::Revoked => HostKeyAction::Reject, + HostKeyStatus::Unknown => HostKeyAction::Ask(Box::new(AuthPromptKind::HostKeyUnknown { + host: host.to_string(), + port, + algorithm, + fingerprint_sha256, + previously_known_as: None, + })), + // Deliberately the unknown-host prompt and not a variant of its own: + // `AuthPromptKind` crosses to the GUI *and* to whatever `tty7-server` + // the far end happens to be running, and a new externally-tagged + // variant is a hard decode failure on any peer that predates it. The + // extra field is additive in both directions. + HostKeyStatus::ChangedAlgorithm { + known_algorithm, .. + } => HostKeyAction::Ask(Box::new(AuthPromptKind::HostKeyUnknown { + host: host.to_string(), + port, + algorithm, + fingerprint_sha256, + previously_known_as: Some(known_algorithm), + })), + HostKeyStatus::Changed { + old_fingerprint_sha256, + } => HostKeyAction::Ask(Box::new(AuthPromptKind::HostKeyChanged { + host: host.to_string(), + port, + algorithm, + fingerprint_sha256, + old_fingerprint_sha256, + })), + } +} + +/// Whether a prompt response accepts the key, and whether it asked for the key +/// to be written to known_hosts. +/// +/// Rejecting never records: a `remember` alongside `accept: false` is the +/// dialog's checkbox state, not a request to trust the key. +pub(super) fn accepted_and_remembered(resp: &AuthResponse) -> (bool, bool) { + match resp { + AuthResponse::HostKeyDecision { accept, remember } => (*accept, *accept && *remember), + _ => (false, false), + } +} + impl ClientHandler { fn apply_decision(&self, resp: AuthResponse, key: &PublicKey) -> bool { - match resp { - AuthResponse::HostKeyDecision { - accept: true, - remember, - } => { - if remember { - // The superseded line has to go before the new one lands. - // `known_hosts::check` answers `Known` on any - // same-algorithm match, so an override that only appended - // left the key the user had just rejected trusted for good. - // If it cannot be dropped, do not append either: being - // asked again next time is the better half of that trade. - match known_hosts::forget_superseded(&self.host, self.port, key) { - Ok(()) => { - if let Err(e) = known_hosts::append_trusted(&self.host, self.port, key) - { - log::warn!("failed to record host key in known_hosts: {e}"); - } - } - Err(e) => log::warn!( - "not recording host key: the superseded known_hosts line could not be removed: {e}" - ), + let (accept, remember) = accepted_and_remembered(&resp); + if !accept { + return false; + } + if remember { + // The superseded line has to go before the new one lands. + // `known_hosts::check` answers `Known` on any same-algorithm match, + // so an override that only appended left the key the user had just + // rejected trusted for good. If it cannot be dropped, do not append + // either: being asked again next time is the better half of that + // trade. + match known_hosts::forget_superseded(&self.host, self.port, key) { + Ok(()) => { + if let Err(e) = known_hosts::append_trusted(&self.host, self.port, key) { + log::warn!("failed to record host key in known_hosts: {e}"); } } - true + Err(e) => log::warn!( + "not recording host key: the superseded known_hosts line could not be removed: {e}" + ), } - _ => false, } + true } } @@ -60,74 +134,26 @@ impl russh::client::Handler for ClientHandler { &mut self, server_public_key: &PublicKey, ) -> Result { - if !self.verify_host_keys { - let revoked = matches!( - known_hosts::check(&self.host, self.port, server_public_key), - HostKeyStatus::Revoked + let status = known_hosts::check(&self.host, self.port, server_public_key); + if !self.verify_host_keys && matches!(status, HostKeyStatus::Revoked) { + log::warn!( + "rejecting revoked host key for {}:{} despite verify_host_keys=false", + self.host, + self.port ); - if revoked { - log::warn!( - "rejecting revoked host key for {}:{} despite verify_host_keys=false", - self.host, - self.port - ); - } - return Ok(!revoked); } - - let algorithm = server_public_key.algorithm().as_str().to_string(); - let fingerprint_sha256 = known_hosts::fingerprint_sha256(server_public_key); - - match known_hosts::check(&self.host, self.port, server_public_key) { - HostKeyStatus::Known => Ok(true), - HostKeyStatus::Revoked => Ok(false), - HostKeyStatus::Unknown => { - let resp = self - .broker - .prompt(AuthPromptKind::HostKeyUnknown { - host: self.host.clone(), - port: self.port, - algorithm, - fingerprint_sha256, - previously_known_as: None, - }) - .await; - Ok(self.apply_decision(resp, server_public_key)) - } - // Deliberately the unknown-host prompt and not a variant of its - // own: `AuthPromptKind` crosses to the GUI *and* to whatever - // `tty7-server` the far end happens to be running, and a new - // externally-tagged variant is a hard decode failure on any peer - // that predates it. The extra field is additive in both - // directions. - HostKeyStatus::ChangedAlgorithm { - known_algorithm, .. - } => { - let resp = self - .broker - .prompt(AuthPromptKind::HostKeyUnknown { - host: self.host.clone(), - port: self.port, - algorithm, - fingerprint_sha256, - previously_known_as: Some(known_algorithm), - }) - .await; - Ok(self.apply_decision(resp, server_public_key)) - } - HostKeyStatus::Changed { - old_fingerprint_sha256, - } => { - let resp = self - .broker - .prompt(AuthPromptKind::HostKeyChanged { - host: self.host.clone(), - port: self.port, - algorithm, - fingerprint_sha256, - old_fingerprint_sha256, - }) - .await; + match host_key_action( + status, + self.verify_host_keys, + &self.host, + self.port, + server_public_key.algorithm().as_str().to_string(), + known_hosts::fingerprint_sha256(server_public_key), + ) { + HostKeyAction::Accept => Ok(true), + HostKeyAction::Reject => Ok(false), + HostKeyAction::Ask(prompt) => { + let resp = self.broker.prompt(*prompt).await; Ok(self.apply_decision(resp, server_public_key)) } } @@ -178,3 +204,174 @@ impl russh::client::Handler for ClientHandler { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn action(status: HostKeyStatus, verify: bool) -> HostKeyAction { + host_key_action( + status, + verify, + "example.com", + 2222, + "ssh-ed25519".to_string(), + "SHA256:new".to_string(), + ) + } + + #[test] + fn a_key_already_on_file_is_accepted_without_asking() { + assert!(matches!( + action(HostKeyStatus::Known, true), + HostKeyAction::Accept + )); + } + + #[test] + fn a_revoked_key_is_rejected_even_with_verification_off() { + assert!(matches!( + action(HostKeyStatus::Revoked, true), + HostKeyAction::Reject + )); + // The point of the whole arm: `verify_host_keys = false` means "I do + // not know this host", not "ignore a key its owner has published as + // compromised". + assert!(matches!( + action(HostKeyStatus::Revoked, false), + HostKeyAction::Reject + )); + } + + #[test] + fn verification_off_accepts_everything_else_without_asking() { + for status in [ + HostKeyStatus::Known, + HostKeyStatus::Unknown, + HostKeyStatus::Changed { + old_fingerprint_sha256: "SHA256:old".into(), + }, + HostKeyStatus::ChangedAlgorithm { + known_fingerprint_sha256: "SHA256:old".into(), + known_algorithm: "ssh-rsa".into(), + }, + ] { + assert!( + matches!(action(status.clone(), false), HostKeyAction::Accept), + "{status:?} should be accepted outright with verification off" + ); + } + } + + #[test] + fn an_unknown_host_is_asked_about_with_no_prior_algorithm() { + let HostKeyAction::Ask(prompt) = action(HostKeyStatus::Unknown, true) else { + panic!("an unknown host has to be asked about"); + }; + let AuthPromptKind::HostKeyUnknown { + host, + port, + algorithm, + fingerprint_sha256, + previously_known_as, + } = *prompt + else { + panic!("an unknown host gets the unknown-host prompt"); + }; + assert_eq!(host, "example.com"); + assert_eq!(port, 2222); + assert_eq!(algorithm, "ssh-ed25519"); + assert_eq!(fingerprint_sha256, "SHA256:new"); + assert_eq!(previously_known_as, None); + } + + /// A host that grows an ed25519 key beside its ssh-rsa one has not been + /// tampered with, so it gets the *unknown* prompt rather than the + /// man-in-the-middle one — carrying the old algorithm so the dialog can + /// name what the host was known by. Sending a new prompt variant instead + /// would be a hard decode failure on any older peer. + #[test] + fn a_new_algorithm_asks_the_unknown_prompt_naming_the_old_one() { + let HostKeyAction::Ask(prompt) = action( + HostKeyStatus::ChangedAlgorithm { + known_fingerprint_sha256: "SHA256:old".into(), + known_algorithm: "ssh-rsa".into(), + }, + true, + ) else { + panic!("a new algorithm has to be asked about"); + }; + let AuthPromptKind::HostKeyUnknown { + previously_known_as, + .. + } = *prompt + else { + panic!("a new algorithm must not use the changed-key prompt"); + }; + assert_eq!(previously_known_as.as_deref(), Some("ssh-rsa")); + } + + /// A key that contradicts one on file under the same algorithm is the + /// man-in-the-middle case, and gets the louder prompt with both + /// fingerprints. + #[test] + fn a_contradicting_key_asks_the_changed_prompt_with_both_fingerprints() { + let HostKeyAction::Ask(prompt) = action( + HostKeyStatus::Changed { + old_fingerprint_sha256: "SHA256:old".into(), + }, + true, + ) else { + panic!("a changed key has to be asked about"); + }; + let AuthPromptKind::HostKeyChanged { + fingerprint_sha256, + old_fingerprint_sha256, + .. + } = *prompt + else { + panic!("a changed key must use the changed-key prompt"); + }; + assert_eq!(fingerprint_sha256, "SHA256:new"); + assert_eq!(old_fingerprint_sha256, "SHA256:old"); + } + + #[test] + fn only_an_accepting_response_trusts_the_key() { + assert_eq!( + accepted_and_remembered(&AuthResponse::HostKeyDecision { + accept: true, + remember: false + }), + (true, false) + ); + assert_eq!( + accepted_and_remembered(&AuthResponse::HostKeyDecision { + accept: true, + remember: true + }), + (true, true) + ); + assert_eq!( + accepted_and_remembered(&AuthResponse::Cancelled), + (false, false) + ); + assert_eq!( + accepted_and_remembered(&AuthResponse::Secret("hunter2".into())), + (false, false), + "a secret is not an answer to a host-key question" + ); + } + + /// Rejecting never writes to known_hosts, whatever the checkbox said. + #[test] + fn a_rejection_never_records_the_key() { + assert_eq!( + accepted_and_remembered(&AuthResponse::HostKeyDecision { + accept: false, + remember: true + }), + (false, false) + ); + } +} diff --git a/crates/tty7-core/src/daemon/ssh/mod.rs b/crates/tty7-core/src/daemon/ssh/mod.rs index 189d4597..7e10d3ae 100644 --- a/crates/tty7-core/src/daemon/ssh/mod.rs +++ b/crates/tty7-core/src/daemon/ssh/mod.rs @@ -24,8 +24,8 @@ use std::time::Duration; use russh::{ChannelMsg, Pty}; use crate::daemon::protocol::{ - AuthPromptKind, AuthResponse, LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, - ManagedForward, NativeSshSpec, SshForwardRule, SshPhase, SshTestNeed, SshTestReport, WinSize, + AuthPromptKind, AuthResponse, LoopbackForward, ManagedForward, NativeSshSpec, SshForwardRule, + SshPhase, SshTestNeed, SshTestReport, WinSize, }; use crate::daemon::remote_link::{self, RemoteEntry, RemoteLink}; use crate::daemon::router::{RouteChannel, RouteSetup}; @@ -138,14 +138,6 @@ impl SshManager { )) } - pub fn list_loopback_forwards(&self) -> Vec { - Vec::new() - } - - pub fn close_loopback_forward(&self, _id: &LoopbackForwardId) -> bool { - false - } - pub fn spawn_native_session( &'static self, pane_id: u64, diff --git a/src/core/keychain.rs b/src/core/keychain.rs index 9308955e..b94fa841 100644 --- a/src/core/keychain.rs +++ b/src/core/keychain.rs @@ -66,16 +66,6 @@ pub trait CredentialStore: Send + Sync { fn delete_key_passphrase(&self, key_sha512_hex: &str) -> CredentialResult<()> { self.delete(SERVICE_KEY_PASSPHRASE, key_sha512_hex) } - - #[allow(dead_code)] - fn get_ref(&self, cref: &CredentialRef) -> CredentialResult> { - self.get(cref.service(), &cref.account) - } - - #[allow(dead_code)] - fn delete_ref(&self, cref: &CredentialRef) -> CredentialResult<()> { - self.delete(cref.service(), &cref.account) - } } #[derive(Debug, Default, Clone, Copy)] @@ -172,7 +162,6 @@ mod tests { let cref = store.set_password("deploy", "host", 22, "hunter2").unwrap(); assert_eq!(cref, CredentialRef::password("deploy", "host", 22)); - assert_eq!(store.get_ref(&cref).unwrap().as_deref(), Some("hunter2")); assert_eq!( store.password_for("deploy", "host", 22).unwrap().as_deref(), Some("hunter2") diff --git a/src/core/mod.rs b/src/core/mod.rs index 7835f623..2e660e41 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -8,7 +8,9 @@ pub mod cli_install; pub mod config; pub mod explorer_context_menu; pub mod keychain; +pub mod rate_meter; pub mod session; +pub mod shell_quote; pub mod ssh_config; pub mod update; pub mod window_state; diff --git a/src/core/rate_meter.rs b/src/core/rate_meter.rs new file mode 100644 index 00000000..637c7e09 --- /dev/null +++ b/src/core/rate_meter.rs @@ -0,0 +1,222 @@ +//! A windowed rate-and-latency meter, shared by the two debug counters. +//! +//! `terminal::fps` and `ui::perf` each carried their own copy of this — same +//! window, same accumulate-then-flush shape, same environment-flag parsing — +//! differing only in the words on the line and whether the meter is keyed. +//! Those are the two things this parameterises. +//! +//! Nothing here runs unless the matching environment variable is set. + +use std::time::{Duration, Instant}; + +/// How long samples accumulate before a line is emitted. +pub const WINDOW: Duration = Duration::from_secs(1); + +/// Whether an environment variable's value turns a counter on. +/// +/// Unset, empty and `0` are off; anything else is on. Taking the value rather +/// than reading the variable is what makes it testable. +pub fn flag_enables(value: Option<&str>) -> bool { + value.is_some_and(|v| !v.is_empty() && v != "0") +} + +/// The words that make a meter's line read as English. +/// +/// One meter counts frames and times painting; the other counts calls and +/// times building. Everything else about them is identical. +pub struct Wording { + /// Bracketed prefix identifying the counter, e.g. `fps`. + pub tag: &'static str, + /// Unit for the rate, e.g. `fps` or `calls/s`. + pub rate_unit: &'static str, + /// Plural noun for what was counted, e.g. `frames`. + pub counted: &'static str, + /// Verb for what was timed, e.g. `paint`. + pub timed: &'static str, +} + +pub struct Meter { + window_start: Instant, + count: u32, + total: Duration, + max: Duration, +} + +impl Meter { + pub fn new(window_start: Instant) -> Self { + Self { + window_start, + count: 0, + total: Duration::ZERO, + max: Duration::ZERO, + } + } + + /// Fold one sample in, and return the summary line if the window closed. + /// + /// `label` names the thing being measured when a counter keeps one meter + /// per callsite; the unkeyed counter passes `None` and gets no label. + /// Emitting resets the meter, so a line covers exactly the window it + /// reports. + pub fn record( + &mut self, + now: Instant, + sample: Duration, + w: &Wording, + label: Option<&str>, + ) -> Option { + self.count += 1; + self.total += sample; + self.max = self.max.max(sample); + + let elapsed = now.duration_since(self.window_start); + if elapsed < WINDOW { + return None; + } + let secs = elapsed.as_secs_f64(); + let rate = self.count as f64 / secs; + let avg_ms = self.total.as_secs_f64() * 1000.0 / self.count as f64; + let max_ms = self.max.as_secs_f64() * 1000.0; + let named = match label { + Some(l) => format!(" {l}:"), + None => String::new(), + }; + let line = format!( + "[{}]{named} {rate:.1} {} over {secs:.2}s ({} {}) | {} avg {avg_ms:.2}ms max {max_ms:.2}ms", + w.tag, w.rate_unit, self.count, w.counted, w.timed + ); + *self = Meter::new(now); + Some(line) + } + + /// Samples folded into the window still open. Tests read this; nothing else + /// needs it. + #[cfg(test)] + pub fn count(&self) -> u32 { + self.count + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FPS: Wording = Wording { + tag: "fps", + rate_unit: "fps", + counted: "frames", + timed: "paint", + }; + + #[test] + fn flag_semantics_cover_unset_empty_zero_and_set() { + assert!(!flag_enables(None), "unset leaves the counter off"); + assert!(!flag_enables(Some("")), "empty value is off"); + assert!(!flag_enables(Some("0")), "explicit 0 is off"); + assert!(flag_enables(Some("1"))); + assert!(flag_enables(Some("yes"))); + } + + #[test] + fn samples_below_the_window_accumulate_silently() { + let start = Instant::now(); + let mut m = Meter::new(start); + assert_eq!( + m.record( + start + Duration::from_millis(10), + Duration::from_millis(2), + &FPS, + None + ), + None + ); + assert_eq!( + m.record( + start + Duration::from_millis(20), + Duration::from_millis(5), + &FPS, + None + ), + None + ); + assert_eq!(m.count(), 2, "both samples folded into the open window"); + } + + #[test] + fn crossing_the_window_flushes_and_resets() { + let start = Instant::now(); + let mut m = Meter::new(start); + assert!( + m.record( + start + Duration::from_millis(100), + Duration::from_millis(2), + &FPS, + None + ) + .is_none() + ); + assert!( + m.record( + start + Duration::from_millis(200), + Duration::from_millis(6), + &FPS, + None + ) + .is_none() + ); + let flush_at = start + Duration::from_millis(1500); + let line = m + .record(flush_at, Duration::from_millis(4), &FPS, None) + .expect("crossing the window emits the aggregate line"); + assert_eq!( + line, + "[fps] 2.0 fps over 1.50s (3 frames) | paint avg 4.00ms max 6.00ms" + ); + assert_eq!(m.count(), 0); + assert_eq!(m.total, Duration::ZERO); + assert_eq!(m.max, Duration::ZERO); + assert_eq!(m.window_start, flush_at); + } + + #[test] + fn a_sample_exactly_on_the_boundary_flushes() { + let start = Instant::now(); + let mut m = Meter::new(start); + let line = m.record(start + WINDOW, Duration::from_millis(1), &FPS, None); + assert!(line.is_some(), "a sample exactly at the boundary flushes"); + assert!(line.unwrap().contains("(1 frames)")); + } + + #[test] + fn a_label_names_the_callsite_in_the_line() { + const PERF: Wording = Wording { + tag: "perf", + rate_unit: "calls/s", + counted: "calls", + timed: "build", + }; + let start = Instant::now(); + let mut m = Meter::new(start); + assert!( + m.record( + start + Duration::from_millis(500), + Duration::from_millis(2), + &PERF, + Some("render") + ) + .is_none() + ); + let line = m + .record( + start + Duration::from_millis(1000), + Duration::from_millis(6), + &PERF, + Some("render"), + ) + .expect("crossing the window emits the aggregate line"); + assert_eq!( + line, + "[perf] render: 2.0 calls/s over 1.00s (2 calls) | build avg 4.00ms max 6.00ms" + ); + } +} diff --git a/src/core/shell_quote.rs b/src/core/shell_quote.rs new file mode 100644 index 00000000..555fd117 --- /dev/null +++ b/src/core/shell_quote.rs @@ -0,0 +1,322 @@ +//! One set of rules for putting a real path onto a command line, shared by +//! everything that inserts one. +//! +//! Three places need this: the file tree's `cd` and paste, the terminal's +//! drop / clipboard / staged-image insertion, and completion accepting a +//! candidate. They used to carry three separate implementations, and on +//! Windows two of them disagreed — `file_tree::shell_quote_for` wrapped the +//! path in quotes and worked, while `view::shell_escape_path` escaped with +//! backslashes and ate the path separators of `C:\Users\me` (#593 fixed the +//! first one and never reached the other two). +//! +//! Three shells, three rules: +//! +//! - cmd.exe treats only double quotes as quoting. A single quote is an +//! ordinary character there, so the POSIX form splits the path at its first +//! space. Windows paths cannot contain `"`, so there is nothing to escape +//! inside the quotes. +//! - PowerShell takes `'...'`, and writes an embedded `'` twice. The POSIX +//! `'\''` seam is not a seam there — PowerShell does not join a quoted +//! string to the bare word beside it — so `C:\Users\O'Brien` came out as +//! something PowerShell reads as three tokens. +//! - Every POSIX shell takes `'...'` too, and breaks out for an embedded `'` +//! via `'\''`. +//! +//! Backslash escaping is not used to quote anywhere. Only POSIX shells +//! understand it, and on Windows it collides head-on with the path separator. +//! +//! A leading `~/` stays outside the quotes: quoting it would make it a literal +//! and lose the home expansion the user is asking for. + +/// Characters that need no quoting in any shell we target. +fn is_bare(c: char) -> bool { + c.is_alphanumeric() || "/.-_~+".contains(c) +} + +/// How a shell wants a literal string written. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Quoting { + /// cmd.exe: `"..."`, and a Windows path cannot hold a `"` to escape. + Cmd, + /// PowerShell and pwsh: `'...'`, with an embedded `'` written `''`. + PowerShell, + /// Everything else: `'...'`, with an embedded `'` written `'\''`. + Posix, +} + +/// The shell binary's name, without a directory and without a `.exe` suffix. +fn base_name(program: &str) -> &str { + let base = program.rsplit(['\\', '/']).next().unwrap_or(program); + let cut = base.len().saturating_sub(4); + match base.get(cut..) { + Some(tail) if tail.eq_ignore_ascii_case(".exe") => &base[..cut], + _ => base, + } +} + +/// Which dialect the pane's shell speaks. +/// +/// `shell_program` is the pane's shell binary as `ShellSpec::program` reports +/// it. `None` means the pane has not resolved one yet, and the platform is the +/// only evidence there is: a Windows pane is overwhelmingly PowerShell, and +/// everywhere else it is something POSIX. (WSL panes get their paths rewritten +/// to `/mnt/...` before they reach here.) +pub fn quoting_for(shell_program: Option<&str>) -> Quoting { + let Some(base) = shell_program.map(base_name) else { + return if cfg!(windows) { + Quoting::PowerShell + } else { + Quoting::Posix + }; + }; + if base.eq_ignore_ascii_case("cmd") { + Quoting::Cmd + } else if base.eq_ignore_ascii_case("powershell") || base.eq_ignore_ascii_case("pwsh") { + Quoting::PowerShell + } else { + Quoting::Posix + } +} + +/// Quote `path` as a single argument for the shell the pane is running. +pub fn quote_for_shell(path: &str, shell_program: Option<&str>) -> String { + quote_as(path, quoting_for(shell_program)) +} + +/// [`quote_for_shell`] with the dialect already decided. +fn quote_as(path: &str, quoting: Quoting) -> String { + if path.is_empty() { + return match quoting { + Quoting::Cmd => "\"\"".to_string(), + _ => "''".to_string(), + }; + } + // `~/` has to stay unquoted for the shell to expand it, so quote only the + // rest. A bare `~` is already covered by `is_bare`. + if let Some(rest) = path.strip_prefix("~/") { + if rest.is_empty() { + return "~/".to_string(); + } + return format!("~/{}", quote_as(rest, quoting)); + } + if path.chars().all(is_bare) { + return path.to_string(); + } + match quoting { + Quoting::Cmd => format!("\"{path}\""), + Quoting::PowerShell => format!("'{}'", path.replace('\'', "''")), + Quoting::Posix => format!("'{}'", path.replace('\'', r"'\''")), + } +} + +/// Undo [`quote_for_shell`] far enough to look the path up on disk. +/// +/// Completion re-reads the word under the cursor after the user has already +/// accepted one candidate, so whatever quoting went in has to come back out +/// before the word can be resolved against the filesystem. The word is +/// mid-typing and therefore usually *un*terminated, so a lone leading quote +/// counts. +/// +/// Only a POSIX shell treats a backslash as an escape character. A user who +/// typed `My\ Docs` by hand there expects it honoured; on Windows the same +/// character is a path separator and must survive untouched. +/// +/// Inside single quotes it is neither, on any shell: a single-quoted string is +/// literal from end to end. Unescaping there would take the separators out of +/// `'C:\Users\me'` — exactly the form [`quote_for_shell`] produces for that +/// path. +/// +/// The scan tracks quoting across the whole word rather than looking at the +/// first character, because a quote can open partway in: `~/'My Documents'` has +/// to keep its `~/` outside so the shell expands it, and the `'\''` seam that +/// carries a quote through a POSIX single-quoted string is three state changes +/// in a row rather than a special case. +pub fn unquote_word(word: &str, quoting: Quoting) -> String { + let posix_escapes = quoting == Quoting::Posix; + let mut out = String::with_capacity(word.len()); + let mut quote: Option = None; + let mut chars = word.chars().peekable(); + while let Some(c) = chars.next() { + match (quote, c) { + // PowerShell's own seam: inside `'...'` a doubled quote is one + // literal quote and closes nothing. + (Some('\''), '\'') if quoting == Quoting::PowerShell && chars.peek() == Some(&'\'') => { + chars.next(); + out.push('\''); + } + (Some(q), _) if c == q => quote = None, + // A backslash escapes inside double quotes and outside quotes, but + // never inside single ones. A trailing one has nothing to escape + // and stands for itself. + (Some('"') | None, '\\') if posix_escapes => match chars.next() { + Some(next) => out.push(next), + None => out.push('\\'), + }, + (Some(_), _) => out.push(c), + (None, '\'' | '"') => quote = Some(c), + (None, _) => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_plain_path_is_left_alone() { + assert_eq!( + quote_for_shell("/Users/me/notes.txt", None), + "/Users/me/notes.txt" + ); + assert_eq!(quote_for_shell("notes.txt", None), "notes.txt"); + assert_eq!(quote_for_shell("--message", None), "--message"); + } + + #[test] + fn posix_shells_get_single_quotes() { + assert_eq!( + quote_for_shell("/Users/me/My File (1).txt", Some("zsh")), + "'/Users/me/My File (1).txt'" + ); + assert_eq!( + quote_for_shell("/a/$HOME & more", None), + "'/a/$HOME & more'" + ); + assert_eq!(quote_for_shell("it's here", Some("zsh")), r"'it'\''s here'"); + assert_eq!(quote_for_shell("", None), "''"); + } + + #[test] + fn a_newline_survives_inside_the_quotes() { + assert_eq!(quote_for_shell("a\nb", None), "'a\nb'"); + } + + /// The bug this module exists for: a Windows path used to come out as + /// `C:\\Users\\me\\My\ Docs`, which the shell then un-escaped back into a + /// path with no separators at all. + #[test] + fn a_windows_path_keeps_its_separators() { + assert_eq!( + quote_for_shell(r"C:\Users\me\My Docs", Some("powershell.exe")), + r"'C:\Users\me\My Docs'" + ); + assert_eq!( + quote_for_shell(r"C:\Users\me\My Docs", Some(r"C:\Windows\System32\cmd.exe")), + "\"C:\\Users\\me\\My Docs\"" + ); + } + + #[test] + fn cmd_exe_is_recognised_by_basename_on_either_separator() { + for p in [ + "cmd", + "cmd.exe", + "CMD.EXE", + r"C:\Windows\System32\cmd.exe", + "/c/Windows/System32/cmd.exe", + ] { + assert_eq!( + quote_for_shell("a b", Some(p)), + "\"a b\"", + "{p} should be recognised as cmd.exe" + ); + } + assert_eq!(quote_for_shell("a b", Some("pwsh")), "'a b'"); + assert_eq!(quote_for_shell("a b", Some("powershell.exe")), "'a b'"); + } + + #[test] + fn a_tilde_stays_outside_the_quotes_so_the_shell_expands_it() { + assert_eq!(quote_for_shell("~/My Documents", None), "~/'My Documents'"); + assert_eq!(quote_for_shell("~/notes.txt", None), "~/notes.txt"); + assert_eq!(quote_for_shell("~", None), "~"); + // Not a home reference — a file whose name starts with a tilde. + assert_eq!(quote_for_shell("~weird name", None), "'~weird name'"); + } + + /// An apostrophe is the one character the three dialects disagree about, + /// and `C:\Users\O'Brien` is a real Windows home directory. Whatever a + /// shell is handed has to be what comes back out of it. + #[test] + fn unquoting_undoes_what_quoting_did_in_every_dialect() { + for shell in [Some("zsh"), Some("powershell.exe"), Some("cmd.exe")] { + for path in [ + "/Users/me/My File (1).txt", + "it's here", + r"C:\Users\me\My Docs", + r"C:\Users\O'Brien\notes.txt", + "~/My Documents", + ] { + let quoted = quote_for_shell(path, shell); + assert_eq!( + unquote_word("ed, quoting_for(shell)), + path, + "round trip of {path} under {shell:?} (quoted as {quoted})" + ); + } + } + } + + /// PowerShell does not join a quoted string to the bare word beside it, so + /// the POSIX `'\''` seam is not a seam there — it is three tokens. + #[test] + fn powershell_doubles_an_embedded_quote_where_posix_breaks_out() { + assert_eq!( + quote_for_shell(r"C:\Users\O'Brien\a.txt", Some("powershell.exe")), + r"'C:\Users\O''Brien\a.txt'" + ); + assert_eq!( + quote_for_shell("it's here", Some("pwsh")), + "'it''s here'", + "pwsh speaks the same dialect" + ); + assert_eq!( + quote_for_shell(r"C:\Users\O'Brien\a.txt", Some("CMD.EXE")), + "\"C:\\Users\\O'Brien\\a.txt\"", + "cmd.exe quotes with \", so an apostrophe needs nothing" + ); + } + + #[test] + fn an_unterminated_quote_still_unquotes() { + // What completion actually sees: the user is mid-word. + assert_eq!(unquote_word("'My Doc", Quoting::Posix), "My Doc"); + assert_eq!(unquote_word("\"My Doc", Quoting::Cmd), "My Doc"); + } + + #[test] + fn a_hand_typed_backslash_escape_is_honoured_only_where_it_is_one() { + assert_eq!( + unquote_word(r"My\ Documents", Quoting::Posix), + "My Documents" + ); + // On Windows the same bytes are a path, not an escape — this is the + // half of the bug that made inline path completion unable to resolve + // any directory there. + assert_eq!(unquote_word(r"C:\Users\me", Quoting::Cmd), r"C:\Users\me"); + assert_eq!( + unquote_word(r"C:\Users\me", Quoting::PowerShell), + r"C:\Users\me" + ); + assert_eq!(unquote_word(r"trailing\", Quoting::Posix), r"trailing\"); + } + + #[test] + fn the_shell_decides_the_dialect() { + assert_eq!(quoting_for(Some("zsh")), Quoting::Posix); + assert_eq!(quoting_for(Some("/bin/bash")), Quoting::Posix); + assert_eq!(quoting_for(Some("cmd.exe")), Quoting::Cmd); + assert_eq!(quoting_for(Some("powershell.exe")), Quoting::PowerShell); + assert_eq!(quoting_for(Some("POWERSHELL.EXE")), Quoting::PowerShell); + assert_eq!(quoting_for(Some("pwsh")), Quoting::PowerShell); + let posix_escapes_for = |s| quoting_for(s) == Quoting::Posix; + assert!(posix_escapes_for(Some("zsh"))); + assert!(posix_escapes_for(Some("/bin/bash"))); + assert!(!posix_escapes_for(Some("cmd.exe"))); + assert!(!posix_escapes_for(Some("powershell.exe"))); + assert!(!posix_escapes_for(Some("pwsh"))); + assert_eq!(posix_escapes_for(None), !cfg!(windows)); + } +} diff --git a/src/terminal/completion.rs b/src/terminal/completion.rs index d342ba02..3a3ca0aa 100644 --- a/src/terminal/completion.rs +++ b/src/terminal/completion.rs @@ -1,6 +1,8 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; +use crate::core::shell_quote::{Quoting, unquote_word}; + use super::signature::{self, Arg, CmdNode, Signature}; struct WordCand { @@ -117,8 +119,23 @@ fn current_command(chars: &[char], word_start: usize) -> Option { (!base.is_empty()).then(|| base.to_string()) } -pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option { - complete_inner(line, cursor, cwd, cwd.is_some()) +/// `shell` is the pane's shell binary, which decides how the word under the +/// cursor is quoted — above all whether a backslash in it is an escape +/// character or a path separator. Get it wrong on Windows and every native +/// path loses its separators before the lookup, so no directory ever resolves. +pub fn complete( + line: &str, + cursor: usize, + cwd: Option<&Path>, + shell: Option<&str>, +) -> Option { + complete_inner( + line, + cursor, + cwd, + cwd.is_some(), + crate::core::shell_quote::quoting_for(shell), + ) } /// Completion for a pane whose filesystem this process can only reach through @@ -129,7 +146,9 @@ pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option Option { - complete_inner(line, cursor, Some(cwd), false) + // A WSL pane runs a Linux shell over Linux paths, so a backslash is an + // escape there whatever this machine happens to be. + complete_inner(line, cursor, Some(cwd), false, Quoting::Posix) } fn complete_inner( @@ -137,6 +156,7 @@ fn complete_inner( cursor: usize, cwd: Option<&Path>, this_machine: bool, + quoting: Quoting, ) -> Option { let chars: Vec = line.chars().collect(); let cursor = cursor.min(chars.len()); @@ -147,7 +167,7 @@ fn complete_inner( let (word_cands, pending) = if is_command && !word.contains('/') { (complete_command(&word, this_machine), Vec::new()) } else { - match complete_signature(&chars, word_start, &word, cwd) { + match complete_signature(&chars, word_start, &word, cwd, quoting) { Some(sig) => ( sig.cands, if this_machine { @@ -165,7 +185,7 @@ fn complete_inner( 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()) + (complete_path(&word, cwd, dirs_only, quoting), Vec::new()) } }, } @@ -192,25 +212,36 @@ fn complete_inner( } } -/// Finds the start of the shell word at `cursor`. A whitespace character -/// preceded by an odd-length run of backslashes belongs to the word, as in -/// `My\ Documents/` after a path candidate has been inserted. +/// Finds the start of the shell word at `cursor`. +/// +/// A space only ends the word when the shell would treat it as a separator, so +/// this has to track quoting: a path candidate goes in as `'My Documents'/`, +/// and a user who typed `My\ Documents` by hand means the same thing. The scan +/// runs forward, the same way [`segment_start`] does, because the word under +/// the cursor is usually still being typed and its closing quote does not +/// exist yet — there is nothing for a backward scan to match against. fn shell_word_start(chars: &[char], cursor: usize) -> usize { - let mut start = cursor.min(chars.len()); - while start > 0 { - if !chars[start - 1].is_whitespace() { - start -= 1; + let end = cursor.min(chars.len()); + let mut start = 0; + let mut quote: Option = None; + let mut escaped = false; + for (i, &c) in chars[..end].iter().enumerate() { + if escaped { + escaped = false; continue; } - let escapes = chars[..start - 1] - .iter() - .rev() - .take_while(|&&c| c == '\\') - .count(); - if escapes % 2 == 0 { - break; + match (quote, c) { + // As in `segment_start`: a backslash escapes the next character + // everywhere but inside single quotes. A Windows path's separators + // survive this — the character after one is never a quote or a + // space, so swallowing it changes nothing. + (None, '\\') | (Some('"'), '\\') => escaped = true, + (None, '\'' | '"') => quote = Some(c), + (Some(q), _) if c == q => quote = None, + (Some(_), _) => {} + (None, c) if c.is_whitespace() => start = i + 1, + (None, _) => {} } - start -= 1; } start } @@ -304,7 +335,9 @@ pub fn remote_path_request( return None; } - let word = unescape_path_word(&word); + // The far end of a remote pane is always POSIX — a backslash there is an + // escape, never a separator. + let word = unquote_word(&word, Quoting::Posix); let (dir_part, prefix) = match word.rfind('/') { Some(i) => (&word[..=i], &word[i + 1..]), None => ("", word.as_str()), @@ -364,11 +397,11 @@ pub fn remote_path_candidates(req: &RemotePathRequest, entries: &[RemoteEntry]) out } -fn complete_path(word: &str, cwd: &Path, dirs_only: bool) -> Vec { - // Candidates are emitted unescaped and escaped at insertion time. Undo the - // corresponding backslash quoting for lookup, so a second Tab after - // inserting `My\ Documents/` still enters the real directory. - let word = unescape_path_word(word); +fn complete_path(word: &str, cwd: &Path, dirs_only: bool, quoting: Quoting) -> Vec { + // Candidates are emitted unquoted and quoted at insertion time. Undo the + // corresponding quoting for lookup, so a second Tab after inserting + // `'My Documents'/` still enters the real directory. + let word = unquote_word(word, quoting); let (dir_part, prefix) = match word.rfind(std::path::is_separator) { Some(i) => (&word[..=i], &word[i + 1..]), None => ("", word.as_str()), @@ -413,25 +446,6 @@ fn complete_path(word: &str, cwd: &Path, dirs_only: bool) -> Vec { out } -fn unescape_path_word(word: &str) -> String { - let mut out = String::with_capacity(word.len()); - let mut escaped = false; - for ch in word.chars() { - if escaped { - out.push(ch); - escaped = false; - } else if ch == '\\' { - escaped = true; - } else { - out.push(ch); - } - } - if escaped { - out.push('\\'); - } - out -} - struct SigResult { cands: Vec, pending: Vec, @@ -442,6 +456,7 @@ fn complete_signature( word_start: usize, word: &str, cwd: Option<&Path>, + quoting: Quoting, ) -> Option { let prefix: String = chars[..word_start].iter().collect(); let tokens: Vec<&str> = prefix[segment_start(&prefix)..] @@ -480,7 +495,7 @@ fn complete_signature( push_arg_suggestions(&mut out, arg, word); if let Some(cwd) = cwd { if arg.wants_paths() { - out.extend(complete_path(word, cwd, arg.wants_dirs_only())); + out.extend(complete_path(word, cwd, arg.wants_dirs_only(), quoting)); } } let pending = match cwd { @@ -518,7 +533,7 @@ fn complete_signature( push_arg_suggestions(&mut out, arg, word); if let Some(cwd) = cwd { if arg.wants_paths() { - out.extend(complete_path(word, cwd, arg.wants_dirs_only())); + out.extend(complete_path(word, cwd, arg.wants_dirs_only(), quoting)); } } if cwd.is_some() { @@ -775,9 +790,14 @@ mod tests { } fn texts(line: &str) -> Vec { - complete(line, line.chars().count(), Some(Path::new("/"))) - .map(|c| c.candidates.into_iter().map(|c| c.text).collect()) - .unwrap_or_default() + complete( + line, + line.chars().count(), + Some(Path::new("/")), + Some("zsh"), + ) + .map(|c| c.candidates.into_iter().map(|c| c.text).collect()) + .unwrap_or_default() } #[test] @@ -801,7 +821,7 @@ mod tests { // after it keeps the path fallback it always had. The cwd is a tree we // built rather than `/`, which holds nothing predictable on Windows. let dir = temp_tree("after-pipe", &[("etc", true)]); - let c = complete("ls | grep et", 12, Some(&dir)).unwrap(); + let c = complete("ls | grep et", 12, Some(&dir), Some("zsh")).unwrap(); assert!( c.candidates .iter() @@ -852,7 +872,7 @@ mod tests { let t = texts("git "); assert!(t.iter().any(|s| s == "commit"), "git subcommands: {t:?}"); assert!(t.iter().any(|s| s == "status")); - let c = complete("git ", 4, Some(Path::new("/"))).unwrap(); + let c = complete("git ", 4, Some(Path::new("/")), Some("zsh")).unwrap(); let commit = c.candidates.iter().find(|c| c.text == "commit").unwrap(); assert_eq!(commit.kind, CandidateKind::Value); assert!(commit.description.is_some()); @@ -867,7 +887,7 @@ mod tests { #[test] fn signature_offers_flags_for_the_active_subcommand() { - let c = complete("git commit --", 13, Some(Path::new("/"))).unwrap(); + let c = complete("git commit --", 13, Some(Path::new("/")), Some("zsh")).unwrap(); let msg = c.candidates.iter().find(|c| c.text == "--message").unwrap(); assert_eq!(msg.kind, CandidateKind::Flag); assert_eq!( @@ -889,7 +909,7 @@ mod tests { fn generator_arg_pends_scripts_and_suppresses_path_fallback() { let dir = temp_tree("gen-checkout", &[("sentinel.txt", false), ("subdir", true)]); let line = "git checkout "; - let c = complete(line, line.chars().count(), Some(dir.as_path())) + let c = complete(line, line.chars().count(), Some(dir.as_path()), Some("zsh")) .expect("generator slot is a completion"); assert!( !c.pending.is_empty(), @@ -913,7 +933,7 @@ mod tests { #[test] fn generator_script_tokens_join_with_single_spaces() { - let c = complete("git checkout ", 13, Some(Path::new("/"))).unwrap(); + let c = complete("git checkout ", 13, Some(Path::new("/")), Some("zsh")).unwrap(); let branch = c .pending .iter() @@ -965,7 +985,7 @@ mod tests { fn dir_only_commands_complete_only_directories() { let dir = temp_tree("dironly", &[("target", true), ("tar.gz", false)]); let only_dirs = |line: &str| { - complete(line, line.chars().count(), Some(dir.as_path())) + complete(line, line.chars().count(), Some(dir.as_path()), Some("zsh")) .map(|c| c.candidates.into_iter().map(|c| c.text).collect::>()) .unwrap_or_default() }; @@ -986,6 +1006,7 @@ mod tests { "frobnicate read", "frobnicate read".chars().count(), Some(dir.as_path()), + Some("zsh"), ) .unwrap(); assert_eq!(c.candidates[0].text, "readme.md"); @@ -1085,7 +1106,7 @@ mod tests { #[test] fn command_position_offers_builtins_with_word_range() { - let c = complete("ech", 3, Some(Path::new("/"))).unwrap(); + let c = complete("ech", 3, Some(Path::new("/")), Some("zsh")).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)); @@ -1098,7 +1119,7 @@ mod tests { &[("apple.txt", false), ("apply.sh", false), ("assets", true)], ); let line = "cat a"; - let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap(); + let c = complete(line, line.chars().count(), Some(dir.as_path()), Some("zsh")).unwrap(); let names: Vec<&str> = c.candidates.iter().map(|c| c.text.as_str()).collect(); assert_eq!(names, vec!["assets", "apply.sh", "apple.txt"]); let assets = c.candidates.iter().find(|c| c.text == "assets").unwrap(); @@ -1110,7 +1131,7 @@ mod tests { fn completion_reenters_a_directory_inserted_with_an_escaped_space() { let dir = temp_tree("escaped-path", &[("My Documents/notes.txt", false)]); let line = r"cat My\ Documents/no"; - let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap(); + let c = complete(line, line.chars().count(), Some(dir.as_path()), Some("zsh")).unwrap(); assert_eq!(c.candidates[0].text, "My Documents/notes.txt"); assert_eq!( (c.candidates[0].start, c.candidates[0].end), @@ -1123,7 +1144,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(), Some(dir.as_path())).unwrap(); + let c = complete(line, line.chars().count(), Some(dir.as_path()), Some("zsh")).unwrap(); assert_eq!(c.candidates[0].text, "sub/file.rs"); assert_eq!(c.candidates[0].start, 4); } @@ -1131,9 +1152,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, Some(dir.as_path())).unwrap(); + let c = complete("ls v", 4, Some(dir.as_path()), Some("zsh")).unwrap(); assert!(c.candidates.iter().all(|c| !c.text.starts_with('.'))); - let c = complete("ls .", 4, Some(dir.as_path())).unwrap(); + let c = complete("ls .", 4, Some(dir.as_path()), Some("zsh")).unwrap(); assert!(c.candidates.iter().any(|c| c.text == ".secret")); } @@ -1149,7 +1170,7 @@ mod tests { ], ); let line = "cat x"; - let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap(); + let c = complete(line, line.chars().count(), Some(dir.as_path()), Some("zsh")).unwrap(); let names: Vec<&str> = c.candidates.iter().map(|c| c.text.as_str()).collect(); assert_eq!(names, vec!["xa", "xy", "xyz", "xyzzy"]); } @@ -1158,19 +1179,20 @@ mod tests { fn a_remote_pane_completes_commands_but_never_local_paths() { let dir = temp_tree("remote", &[("only-here.txt", false), ("subdir", true)]); - let c = complete("cat only", 8, Some(dir.as_path())).expect("local pane completes paths"); + let c = complete("cat only", 8, Some(dir.as_path()), Some("zsh")) + .expect("local pane completes paths"); assert!(c.candidates.iter().any(|c| c.text.starts_with("only-here"))); - assert!(complete("cat only", 8, None).is_none()); - assert!(complete("cat ", 4, None).is_none()); + assert!(complete("cat only", 8, None, Some("zsh")).is_none()); + assert!(complete("cat ", 4, None, Some("zsh")).is_none()); - let c = complete("ech", 3, None).expect("command completion needs no cwd"); + let c = complete("ech", 3, None, Some("zsh")).expect("command completion needs no cwd"); assert!(c.candidates.iter().any(|c| c.text == "echo")); } #[test] fn a_remote_pane_offers_builtins_but_never_this_machines_binaries() { - let remote: Vec = complete("l", 1, None) + let remote: Vec = complete("l", 1, None, Some("zsh")) .map(|c| c.candidates.into_iter().map(|c| c.text).collect()) .unwrap_or_default(); assert!( @@ -1181,7 +1203,7 @@ mod tests { #[cfg(unix)] { - let local: Vec = complete("l", 1, Some(Path::new("/"))) + let local: Vec = complete("l", 1, Some(Path::new("/")), Some("zsh")) .map(|c| c.candidates.into_iter().map(|c| c.text).collect()) .unwrap_or_default(); assert!( @@ -1246,27 +1268,27 @@ mod tests { #[test] fn a_remote_pane_still_gets_a_signatures_static_candidates() { - let c = complete("git ", 4, None).expect("subcommands need no filesystem"); + let c = complete("git ", 4, None, Some("zsh")).expect("subcommands need no filesystem"); assert!(c.candidates.iter().any(|c| c.text == "commit")); assert!(c.candidates.iter().any(|c| c.text == "push")); - let c = complete("git ch", 6, None).expect("subcommands need no filesystem"); + let c = complete("git ch", 6, None, Some("zsh")).expect("subcommands need no filesystem"); assert!(c.candidates.iter().any(|c| c.text == "checkout")); assert!(!c.candidates.iter().any(|c| c.text == "commit")); - let c = complete("git commit --", 13, None).expect("flags need no filesystem"); + let c = complete("git commit --", 13, None, Some("zsh")).expect("flags need no filesystem"); assert!(c.candidates.iter().any(|c| c.text == "--message")); } #[test] fn a_remote_pane_never_runs_a_generator() { - let local = complete("git checkout ", 13, Some(Path::new("/"))).unwrap(); + let local = complete("git checkout ", 13, Some(Path::new("/")), Some("zsh")).unwrap(); assert!( !local.pending.is_empty(), "expected the local branch generator to still be declared" ); - if let Some(remote) = complete("git checkout ", 13, None) { + if let Some(remote) = complete("git checkout ", 13, None, Some("zsh")) { assert!( remote.pending.is_empty(), "a remote pane scheduled local generators: {:?}", @@ -1370,15 +1392,15 @@ mod tests { #[test] fn no_candidates_returns_none() { let dir = temp_tree("empty", &[("zzz", false)]); - assert!(complete("cat q", 5, Some(dir.as_path())).is_none()); - assert!(complete("", 0, Some(dir.as_path())).is_none()); - assert!(complete(" ", 3, Some(dir.as_path())).is_none()); + assert!(complete("cat q", 5, Some(dir.as_path()), Some("zsh")).is_none()); + assert!(complete("", 0, Some(dir.as_path()), Some("zsh")).is_none()); + assert!(complete(" ", 3, Some(dir.as_path()), Some("zsh")).is_none()); } #[test] fn mid_line_cursor_completes_only_the_word_before_it() { let dir = temp_tree("midline", &[("apple.txt", false)]); - let c = complete("cat ap x.log", 6, Some(dir.as_path())).unwrap(); + let c = complete("cat ap x.log", 6, Some(dir.as_path()), Some("zsh")).unwrap(); let apple = c.candidates.iter().find(|c| c.text == "apple.txt").unwrap(); assert_eq!((apple.start, apple.end), (4, 6)); let (line, cursor) = Replacement { diff --git a/src/terminal/fps.rs b/src/terminal/fps.rs index 5269cdb4..d196bfe1 100644 --- a/src/terminal/fps.rs +++ b/src/terminal/fps.rs @@ -1,56 +1,26 @@ +//! Frame-rate and paint-time counter, printed once a second to stderr when +//! `TTY7_FPS` is set. The meter itself lives in [`crate::core::rate_meter`], +//! shared with `ui::perf`. + use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; +use crate::core::rate_meter::{Meter, Wording, flag_enables}; + +const WORDING: Wording = Wording { + tag: "fps", + rate_unit: "fps", + counted: "frames", + timed: "paint", +}; + pub fn enabled() -> bool { static ON: OnceLock = OnceLock::new(); *ON.get_or_init(|| flag_enables(std::env::var("TTY7_FPS").ok().as_deref())) } -fn flag_enables(value: Option<&str>) -> bool { - value.is_some_and(|v| !v.is_empty() && v != "0") -} - -const WINDOW: Duration = Duration::from_secs(1); - -struct Meter { - window_start: Instant, - frames: u32, - paint_total: Duration, - paint_max: Duration, -} - -impl Meter { - fn new(window_start: Instant) -> Self { - Self { - window_start, - frames: 0, - paint_total: Duration::ZERO, - paint_max: Duration::ZERO, - } - } - - fn record(&mut self, now: Instant, paint: Duration) -> Option { - self.frames += 1; - self.paint_total += paint; - self.paint_max = self.paint_max.max(paint); - - let elapsed = now.duration_since(self.window_start); - if elapsed < WINDOW { - return None; - } - let secs = elapsed.as_secs_f64(); - let fps = self.frames as f64 / secs; - let avg_ms = self.paint_total.as_secs_f64() * 1000.0 / self.frames as f64; - let max_ms = self.paint_max.as_secs_f64() * 1000.0; - let line = format!( - "[fps] {fps:.1} fps over {secs:.2}s ({} frames) | paint avg {avg_ms:.2}ms max {max_ms:.2}ms", - self.frames - ); - *self = Meter::new(now); - Some(line) - } -} - +/// One meter, not one per callsite: there is only ever one thing being +/// measured here, the window's paint. fn meter() -> &'static Mutex> { static M: OnceLock>> = OnceLock::new(); M.get_or_init(|| Mutex::new(None)) @@ -60,7 +30,7 @@ pub fn record(paint: Duration) { let now = Instant::now(); let mut guard = meter().lock().unwrap(); let m = guard.get_or_insert_with(|| Meter::new(now)); - if let Some(line) = m.record(now, paint) { + if let Some(line) = m.record(now, paint, &WORDING, None) { eprintln!("{line}"); } } @@ -69,62 +39,42 @@ pub fn record(paint: Duration) { mod tests { use super::*; + /// The wording is the only thing this module contributes to the line, so + /// it is the only thing worth pinning here — the windowing behaviour is + /// covered where the meter lives. #[test] - fn flag_semantics_cover_unset_empty_zero_and_set() { - assert!(!flag_enables(None), "unset leaves timing off"); - assert!(!flag_enables(Some("")), "empty value is off"); - assert!(!flag_enables(Some("0")), "explicit 0 is off"); - assert!(flag_enables(Some("1"))); - assert!(flag_enables(Some("yes"))); - } - - #[test] - fn meter_accumulates_silently_below_the_window() { - let start = Instant::now(); - let mut m = Meter::new(start); - assert_eq!( - m.record(start + Duration::from_millis(10), Duration::from_millis(2)), - None - ); - assert_eq!( - m.record(start + Duration::from_millis(20), Duration::from_millis(5)), - None - ); - assert_eq!(m.frames, 2, "both frames folded into the open window"); - } - - #[test] - fn meter_flushes_and_resets_after_a_window() { + fn the_line_reads_as_frames_and_paint_time() { let start = Instant::now(); let mut m = Meter::new(start); assert!( - m.record(start + Duration::from_millis(100), Duration::from_millis(2)) - .is_none() + m.record( + start + Duration::from_millis(100), + Duration::from_millis(2), + &WORDING, + None + ) + .is_none() ); assert!( - m.record(start + Duration::from_millis(200), Duration::from_millis(6)) - .is_none() + m.record( + start + Duration::from_millis(200), + Duration::from_millis(6), + &WORDING, + None + ) + .is_none() ); - let flush_at = start + Duration::from_millis(1500); let line = m - .record(flush_at, Duration::from_millis(4)) + .record( + start + Duration::from_millis(1500), + Duration::from_millis(4), + &WORDING, + None, + ) .expect("crossing the window emits the aggregate line"); assert_eq!( line, "[fps] 2.0 fps over 1.50s (3 frames) | paint avg 4.00ms max 6.00ms" ); - assert_eq!(m.frames, 0); - assert_eq!(m.paint_total, Duration::ZERO); - assert_eq!(m.paint_max, Duration::ZERO); - assert_eq!(m.window_start, flush_at); - } - - #[test] - fn meter_flushes_exactly_on_the_window_boundary() { - let start = Instant::now(); - let mut m = Meter::new(start); - let line = m.record(start + WINDOW, Duration::from_millis(1)); - assert!(line.is_some(), "a frame exactly at the boundary flushes"); - assert!(line.unwrap().contains("(1 frames)")); } } diff --git a/src/terminal/generator.rs b/src/terminal/generator.rs index 1fa666f6..772b4de9 100644 --- a/src/terminal/generator.rs +++ b/src/terminal/generator.rs @@ -3,15 +3,14 @@ use std::collections::HashMap; #[cfg(unix)] use std::io::Read; use std::path::{Path, PathBuf}; +use std::process::Command; #[cfg(unix)] -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; -#[cfg(unix)] const TIMEOUT: Duration = Duration::from_millis(800); -#[cfg(unix)] const MAX_STDOUT: usize = 256 * 1024; const CACHE_TTL: Duration = Duration::from_secs(5); @@ -49,9 +48,69 @@ impl Drop for Reaped { } } +/// The POSIX shell that runs generator scripts on Windows, if there is one. +/// +/// Generators are POSIX shell one-liners lifted from the Fig specs, so running +/// one takes a POSIX shell. Windows ships none; Git for Windows puts `bash.exe` +/// on PATH and covers most developers who would have a spec installed at all. +/// +/// Finding nothing has always meant no candidates, but it used to mean that in +/// silence — and a completion menu whose generator produced nothing looks +/// exactly like one that never ran. This says which it was, once per process. #[cfg(not(unix))] -fn run_uncached(_script: &str, _cwd: &Path) -> Vec { - Vec::new() +fn posix_shell() -> Option<&'static Path> { + static SHELL: OnceLock> = OnceLock::new(); + SHELL + .get_or_init(|| { + let found = std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect::>()) + .unwrap_or_default() + .into_iter() + .map(|dir| dir.join("bash.exe")) + .find(|p| p.is_file()); + match &found { + Some(p) => { + log::debug!("completion generators will run under {}", p.display()) + } + None => log::info!( + "no bash.exe on PATH: completion generators are off, so any spec that \ + fills its candidates from one will offer nothing" + ), + } + found + }) + .as_deref() +} + +#[cfg(not(unix))] +fn run_uncached(script: &str, cwd: &Path) -> Vec { + let Some(shell) = posix_shell() else { + return Vec::new(); + }; + let mut cmd = Command::new(shell); + cmd.arg("-c").arg(script).current_dir(cwd); + // `output_within` kills the child at the deadline the way the unix path's + // `killpg` does, minus the process group — Windows has no equivalent, so a + // generator that forks leaves its children to the OS. + let out = tty7_core::core::proc::output_within( + tty7_core::core::proc::hide_console(&mut cmd), + TIMEOUT, + ); + match out { + Ok(out) if out.status.success() => { + let mut bytes = out.stdout; + bytes.truncate(MAX_STDOUT); + parse(script, &String::from_utf8_lossy(&bytes)) + } + Ok(out) => { + log::debug!("generator {script:?} exited {}", out.status); + Vec::new() + } + Err(e) => { + log::debug!("generator {script:?} failed: {e}"); + Vec::new() + } + } } #[cfg(unix)] diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 4064c0cf..f35b3575 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -22,10 +22,9 @@ use crate::core::config::CursorStyle as ConfigCursorStyle; use crate::core::osc::OscTokenizer; use crate::daemon::protocol::{ AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId, - LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, LoopbackForwardRequest, - ManagedForward, NativeSshSpec, PaneProcs, RemoteContext, RestoreFrom, SftpEntry, - SftpJobProgress, SftpOp, SftpOpResult, SftpTransferSpec, ShellSpec, SshForwardRule, SshPhase, - SshTestReport, WinSize, WorkspaceOp, WorkspaceRequest, + LoopbackForward, LoopbackForwardRequest, ManagedForward, NativeSshSpec, PaneProcs, + RemoteContext, RestoreFrom, SftpEntry, SftpJobProgress, SftpOp, SftpOpResult, SftpTransferSpec, + ShellSpec, SshForwardRule, SshPhase, SshTestReport, WinSize, WorkspaceOp, WorkspaceRequest, }; use crate::daemon::transport::{self, Stream}; use gpui::EntityId; @@ -380,7 +379,7 @@ impl RemoteTerminal { restore, } .encode(&mut stream)?; - let pane_id = match DaemonMsg::read(&mut stream)? { + let pane_id = match spawn_reply(&mut stream, attach_reply_wait(route), "Spawn")? { DaemonMsg::Spawned { pane_id } => pane_id, // Passed through, not wrapped: the caller already logs which // spawn this was, and the window shows only this text. @@ -1404,34 +1403,6 @@ impl RemoteTerminal { } } - pub fn list_loopback_forwards() -> Vec { - fn query() -> anyhow::Result> { - let mut stream = connect()?; - ClientMsg::ListLoopbackForwards.encode(&mut stream)?; - match DaemonMsg::read(&mut stream)? { - DaemonMsg::LoopbackForwardList(list) => Ok(list), - other => Err(anyhow::anyhow!( - "unexpected reply to ListLoopbackForwards: {other:?}" - )), - } - } - query().unwrap_or_default() - } - - pub fn close_loopback_forward(id: LoopbackForwardId) -> Vec { - fn query(id: LoopbackForwardId) -> anyhow::Result> { - let mut stream = connect()?; - ClientMsg::CloseLoopbackForward(id).encode(&mut stream)?; - match DaemonMsg::read(&mut stream)? { - DaemonMsg::LoopbackForwardList(list) => Ok(list), - other => Err(anyhow::anyhow!( - "unexpected reply to CloseLoopbackForward: {other:?}" - )), - } - } - query(id).unwrap_or_default() - } - pub fn spawn_native_ssh( size: TermSize, cell_w: u16, @@ -1475,7 +1446,13 @@ impl RemoteTerminal { spec, } .encode(&mut stream)?; - let pane_id = match DaemonMsg::read(&mut stream)? { + // Native SSH is always dialled through the local daemon, whatever the + // far end turns out to be, so this waits on the local budget. + let pane_id = match spawn_reply( + &mut stream, + attach_reply_wait(&PaneRoute::Local), + "SpawnNativeSsh", + )? { DaemonMsg::Spawned { pane_id } => pane_id, DaemonMsg::Error(msg) => { return Err(anyhow::anyhow!("daemon refused SpawnNativeSsh: {msg}")); @@ -1544,6 +1521,14 @@ impl RemoteTerminal { } } + /// The client half of known-hosts management. + /// + /// Nothing calls this yet: there is no known-hosts surface in the window or + /// the CLI. What sits behind it is not a stub, though — `ssh::known_hosts` + /// parses the real file, fingerprints each key, and rewrites through a + /// 0600 temp file — so this is an interface waiting for a screen, not + /// scaffolding around nothing. Deleting it would throw away the finished + /// half of the feature. pub fn list_known_hosts() -> Vec { fn query() -> anyhow::Result> { let mut stream = connect()?; @@ -1575,6 +1560,8 @@ impl RemoteTerminal { }) } + /// See [`Self::list_known_hosts`] — same story, and it returns the list + /// after the removal so a caller can redraw from one round trip. pub fn delete_known_host(id: KnownHostId) -> Vec { fn query(id: KnownHostId) -> anyhow::Result> { let mut stream = connect()?; @@ -1796,6 +1783,37 @@ fn attach_reply_wait(route: &PaneRoute) -> std::time::Duration { } } +/// Read the daemon's answer to a spawn request, under the deadline `Attach` +/// uses for the same route. +/// +/// A daemon caught mid-restart accepts the connection and then never serves +/// it. `Attach` has been guarded against that silence since #673, and core's +/// `PaneSession::spawn_over` bounds the identical exchange, but this path read +/// with no deadline at all — and the local route spawns synchronously on the +/// UI thread (`ui::app`'s `PaneRoute::Local` branch), so a daemon that went +/// quiet froze the whole window on "new tab". +/// +/// The timeout is reported as a plain message with no `io::Error` in its +/// chain, which is what keeps `daemon_disconnected_before_spawn_reply` from +/// claiming it: silence is not a hangup, and retrying it would only wait +/// again. `what` names the request, since the window shows only this text. +fn spawn_reply( + stream: &mut Stream, + wait: std::time::Duration, + what: &str, +) -> anyhow::Result { + let _ = stream.set_read_timeout(Some(wait)); + let reply = DaemonMsg::read(stream); + let _ = stream.set_read_timeout(None); + match reply { + Ok(msg) => Ok(msg), + Err(e) if would_block(&e) => Err(anyhow::anyhow!("no answer to {what} within {wait:?}")), + Err(e) => { + Err(anyhow::Error::new(e).context(format!("reading the daemon's answer to {what}"))) + } + } +} + /// An `Attach` that produced no bytes within its wait — nobody served the /// connection. Distinct from a refusal (`Error` frame) and from a hangup so /// the caller can say the true thing: the pane may well still exist. diff --git a/src/terminal/signature.rs b/src/terminal/signature.rs index 47de0709..867e017c 100644 --- a/src/terminal/signature.rs +++ b/src/terminal/signature.rs @@ -4,13 +4,13 @@ use std::sync::{Arc, Mutex, OnceLock}; use serde::Deserialize; +// Keys the specs carry but this completer never reads are simply not declared: +// serde ignores unknown fields by default, so a spec parses the same either +// way. Declaring them anyway cost seven `#[allow(dead_code)]` attributes +// propping up fields nothing asked for. Add one back when something reads it. + #[derive(Debug, Deserialize)] pub struct Signature { - #[allow(dead_code)] - pub name: String, - #[allow(dead_code)] - #[serde(default)] - pub description: Option, #[serde(default)] pub options: Vec, #[serde(default)] @@ -45,12 +45,6 @@ pub struct Opt { pub description: Option, #[serde(default)] pub args: Vec, - #[allow(dead_code)] - #[serde(default)] - pub required: bool, - #[allow(dead_code)] - #[serde(default)] - pub repeatable: bool, #[serde(default)] pub hidden: bool, #[serde(default)] @@ -65,15 +59,6 @@ impl Opt { #[derive(Debug, Deserialize)] pub struct Arg { - #[allow(dead_code)] - #[serde(default)] - pub name: Option, - #[allow(dead_code)] - #[serde(default)] - pub optional: bool, - #[allow(dead_code)] - #[serde(default)] - pub variadic: bool, #[serde(default)] pub template: Vec, #[serde(default)] @@ -224,7 +209,6 @@ mod tests { #[test] fn git_signature_parses_and_memoizes() { let sig = signature("git").expect("git spec on disk"); - assert_eq!(sig.name, "git"); assert!(sig.subcommands.len() > 20, "git has many subcommands"); let again = signature("git").unwrap(); assert!(Arc::ptr_eq(&sig, &again)); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 01668f14..ecb48e8d 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -30,6 +30,7 @@ use crate::core::actions::{ SplitDown, SplitRight, ToggleMaximizePane, }; use crate::core::config::{BellMode, Config, LinkFileOpen, MouseZoomModifier, NotifyMode}; +use crate::core::shell_quote::quote_for_shell; use crate::daemon::protocol::{RemoteContext, ShellSpec}; use crate::ui::i18n::{L10nKey, t, t_fmt}; @@ -675,56 +676,7 @@ fn trim_trailing_spaces(text: &str) -> String { .join("\n") } -fn shell_escape_path(path: &str) -> String { - if path.is_empty() { - return "''".to_string(); - } - if path.contains(['\n', '\r']) { - return format!("'{}'", path.replace('\'', "'\\''")); - } - let mut out = String::with_capacity(path.len() + 8); - for ch in path.chars() { - if matches!( - ch, - ' ' | '\t' - | '"' - | '\'' - | '\\' - | '$' - | '`' - | '#' - | '=' - | '!' - | '~' - | '[' - | ']' - | '{' - | '}' - | '(' - | ')' - | '<' - | '>' - | '|' - | ';' - | '*' - | '?' - | '&' - ) { - out.push('\\'); - } - out.push(ch); - } - out -} - -fn escape_candidate(text: &str) -> String { - match text.strip_prefix("~/") { - Some(rest) => format!("~/{}", shell_escape_path(rest)), - None => shell_escape_path(text), - } -} - -fn clipboard_paste_text(item: &ClipboardItem) -> Option { +fn clipboard_paste_text(item: &ClipboardItem, shell: Option<&str>) -> Option { let escaped: Vec = item .entries() .iter() @@ -733,7 +685,7 @@ fn clipboard_paste_text(item: &ClipboardItem) -> Option { _ => None, }) .flatten() - .map(|p| shell_escape_path(&p.to_string_lossy())) + .map(|p| quote_for_shell(&p.to_string_lossy(), shell)) .collect(); if !escaped.is_empty() { return Some(escaped.join(" ")); @@ -1707,6 +1659,16 @@ impl TerminalView { self.shell_spec.clone() } + /// The shell binary this pane is running, for the path-quoting rules. + /// + /// `None` before the pane has resolved one, which [`quote_for_shell`] + /// answers from the platform — PowerShell on Windows, POSIX elsewhere. + /// The one pane that guess is wrong for is a cmd.exe pane that has not + /// reported in yet. + fn shell_program(&self) -> Option { + self.shell_spec.as_ref().map(|s| s.program.clone()) + } + pub fn ssh_spec(&self) -> Option> { self.ssh_spec.clone() } @@ -2828,7 +2790,7 @@ impl TerminalView { let Some(item) = cx.read_from_clipboard() else { return; }; - if let Some(text) = clipboard_paste_text(&item) { + if let Some(text) = clipboard_paste_text(&item, self.shell_program().as_deref()) { self.paste(text, cx); return; } @@ -2844,10 +2806,11 @@ impl TerminalView { } fn drop_files(&mut self, paths: &ExternalPaths, cx: &mut Context) { + let shell = self.shell_program(); let text = paths .paths() .iter() - .map(|p| shell_escape_path(&p.to_string_lossy())) + .map(|p| quote_for_shell(&p.to_string_lossy(), shell.as_deref())) .collect::>() .join(" "); if text.is_empty() { @@ -2895,7 +2858,7 @@ impl TerminalView { .as_ref() .is_some_and(|w| w.shares_localhost()); let path = staged_path_for_pane(&path.to_string_lossy(), shares_localhost); - let text = shell_escape_path(&path); + let text = quote_for_shell(&path, self.shell_program().as_deref()); self.paste(format!("{text} "), cx); true } @@ -2972,9 +2935,11 @@ impl TerminalView { return; } }; - let text = shell_escape_path(&remote); if this - .update(cx, |view, cx| view.paste(format!("{text} "), cx)) + .update(cx, |view, cx| { + let text = quote_for_shell(&remote, view.shell_program().as_deref()); + view.paste(format!("{text} "), cx) + }) .is_err() { return; @@ -2998,8 +2963,9 @@ impl TerminalView { host: &str, reason: &str, ) { - let text = shell_escape_path(&local.to_string_lossy()); + let local = local.to_string_lossy().into_owned(); let _ = this.update_in(cx, |view, window, cx| { + let text = quote_for_shell(&local, view.shell_program().as_deref()); view.paste(format!("{text} "), cx); view.warn_image_upload_failed(host, reason, window, cx); }); @@ -4215,7 +4181,12 @@ impl TerminalView { let cursor = self.cmd.cursor(); let comp = match &share_cwd { Some(share) => super::completion::complete_foreign(&line, cursor, share), - None => super::completion::complete(&line, cursor, cwd.as_deref()), + None => super::completion::complete( + &line, + cursor, + cwd.as_deref(), + self.shell_program().as_deref(), + ), }; let Some(comp) = comp else { if self.spawn_remote_path_completion(&line, cursor, forward, cx) { @@ -4287,11 +4258,12 @@ impl TerminalView { .skip(word_start) .take(word_end - word_start) .collect(); + let shell = self.shell_program(); let s = CompletionSession::new(word_start, word.clone(), cands, pending_generators); if !has_pending && let Some(lcp) = s.common_prefix() && lcp.chars().count() > word.chars().count() - && escape_candidate(&lcp) == lcp + && quote_for_shell(&lcp, shell.as_deref()) == lcp { self.apply_candidate(line, word_start, word_end, &lcp); } @@ -4503,6 +4475,7 @@ impl TerminalView { fn completion_tab_step(&mut self, forward: bool, cx: &mut Context) { if forward { + let shell = self.shell_program(); let Some(s) = self.completion.as_ref() else { return; }; @@ -4516,7 +4489,7 @@ impl TerminalView { self.completion_accept(cx); return; } - if escape_candidate(&lcp) == lcp { + if quote_for_shell(&lcp, shell.as_deref()) == lcp { self.apply_candidate(&line, word_start, cursor, &lcp); self.cursor_visible = true; cx.notify(); @@ -4550,7 +4523,7 @@ impl TerminalView { let line = self.cmd.text(); let len = line.chars().count(); let cursor = self.cmd.cursor().min(len); - let mut text = escape_candidate(&cand.text); + let mut text = quote_for_shell(&cand.text, self.shell_program().as_deref()); if cand.is_dir() { if !text.ends_with('/') { text.push('/'); @@ -7055,11 +7028,10 @@ mod tests { staging_cache, staging_dir_is_safe, wsl_path, wsl_share_distro, wsl_share_path, }; use super::{ - description_budget, drag_scroll_step, elide, encode_mouse, escape_candidate, - expand_file_command_template, fallback_chain, fig_icon_emoji, fig_icon_glyph, - focus_report_bytes, highlight_runs, input_cells, input_char_positions, - input_overflow_shift, input_overlay_rows, menu_layout, paste_bytes, select_end_copy, - shell_escape_path, should_show_context_menu, smooth_scroll_step, submit_bytes, + description_budget, drag_scroll_step, elide, encode_mouse, expand_file_command_template, + fallback_chain, fig_icon_emoji, fig_icon_glyph, focus_report_bytes, highlight_runs, + input_cells, input_char_positions, input_overflow_shift, input_overlay_rows, menu_layout, + paste_bytes, select_end_copy, should_show_context_menu, smooth_scroll_step, submit_bytes, trim_trailing_spaces, wheel_route, wrapped_click_index, }; use alacritty_terminal::term::TermMode; @@ -8114,43 +8086,7 @@ mod tests { } #[test] - fn shell_escape_path_escapes_spaces_and_metachars() { - assert_eq!( - shell_escape_path("/Users/me/notes.txt"), - "/Users/me/notes.txt" - ); - assert_eq!( - shell_escape_path("/Users/me/My File (1).txt"), - "/Users/me/My\\ File\\ \\(1\\).txt" - ); - assert_eq!( - shell_escape_path("/a/$HOME & more"), - "/a/\\$HOME\\ \\&\\ more" - ); - assert_eq!(shell_escape_path(""), "''"); - assert_eq!(shell_escape_path("a\nb"), "'a\nb'"); - } - - #[test] - fn escape_candidate_quotes_what_the_shell_would_resplit() { - assert_eq!(escape_candidate("notes.txt"), "notes.txt"); - assert_eq!(escape_candidate("--message"), "--message"); - assert_eq!(escape_candidate("My Documents"), "My\\ Documents"); - assert_eq!(escape_candidate("a(1)&b"), "a\\(1\\)\\&b"); - assert_eq!( - escape_candidate("~/My Documents"), - "~/My\\ Documents", - "a leading ~/ is the user's own text and must stay expandable" - ); - assert_eq!( - escape_candidate("~weird name"), - "\\~weird\\ name", - "a bare ~ that is not a home prefix is just a filename character" - ); - } - - #[test] - fn clipboard_paste_text_escapes_and_space_joins_files() { + fn clipboard_paste_text_quotes_and_space_joins_files() { let item = ClipboardItem { entries: vec![ClipboardEntry::ExternalPaths(ExternalPaths( vec![ @@ -8161,12 +8097,15 @@ mod tests { ))], }; assert_eq!( - clipboard_paste_text(&item).as_deref(), - Some("/Users/me/My\\ File.txt /tmp/b.log") + clipboard_paste_text(&item, Some("zsh")).as_deref(), + Some("'/Users/me/My File.txt' /tmp/b.log") ); let text = ClipboardItem::new_string("echo hi".to_string()); - assert_eq!(clipboard_paste_text(&text).as_deref(), Some("echo hi")); + assert_eq!( + clipboard_paste_text(&text, Some("zsh")).as_deref(), + Some("echo hi") + ); } #[test] @@ -9883,7 +9822,7 @@ mod gpui_tests { } #[gpui::test] - fn accepting_a_candidate_escapes_it_for_the_shell(cx: &mut TestAppContext) { + fn accepting_a_candidate_quotes_it_for_the_shell(cx: &mut TestAppContext) { crate::core::config::pin_test_config_dir(); let (window, _daemon) = harness(cx); window @@ -9892,13 +9831,17 @@ mod gpui_tests { view.completion_insert(&dir_candidate("My Documents", 3, 5), 3); assert_eq!( view.cmd.text(), - "cd My\\ Documents/", - "an unescaped candidate resplits into two arguments and the command breaks" + "cd 'My Documents'/", + "an unquoted candidate resplits into two arguments and the command breaks" ); view.cmd.set("cd ~/My"); view.completion_insert(&dir_candidate("~/My Documents", 3, 6), 3); - assert_eq!(view.cmd.text(), "cd ~/My\\ Documents/"); + assert_eq!( + view.cmd.text(), + "cd ~/'My Documents'/", + "the ~ stays outside the quotes so the shell still expands it" + ); view.cmd.set("git commit --mess"); view.completion_insert( diff --git a/src/ui/app.rs b/src/ui/app.rs index 7c3d91d5..d787a78f 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -36,9 +36,7 @@ use crate::ui::palette::{ use crate::ui::pane::{CloseOutcome, Dir, Pane, PaneSlot}; use crate::ui::presets::Fill; use crate::ui::scm::ScmIntent; -use crate::ui::settings::{ - Recording, SettingsSection, SettingsState, ThemeEditor, humanize_action, -}; +use crate::ui::settings::{Recording, SettingsSection, SettingsState, ThemeEditor}; use crate::ui::theme::{apply_theme, set_menus}; /// What to start in a pane that is about to be opened. @@ -5023,9 +5021,6 @@ impl Tty7App { ScmSync => self.run_scm_action(ScmIntent::Sync, window, cx), ScmCreateBranch => self.run_scm_action(ScmIntent::CreateBranch, window, cx), OpenBranchPicker => self.run_scm_action(ScmIntent::CheckoutBranch, window, cx), - // The branch picker fills this in once it can list refs; until - // then the palette never emits it. - CheckoutBranch(_) => {} ToggleDiffViewMode => self.toggle_diff_view_mode(cx), OpenThemePicker | OpenSshConnectInput => {} ActivateTab(i) => self.activate(i, window, cx), diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 823fae2f..f5408ec8 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -709,32 +709,12 @@ fn rollback_write( children.remove(host, &dir.to_path_buf()); } -/// Quote a path for the shell the pane is actually running. cmd.exe only -/// treats double quotes as quoting — a single quote is an ordinary character -/// there, so the POSIX form would split the path at its first space -/// (#593). PowerShell and every POSIX shell take the single-quoted form, so -/// an unknown shell keeps it too. +/// Quote a path for the shell the pane is actually running. +/// +/// The rules live in [`crate::core::shell_quote`], shared with the terminal's +/// own path insertion so the two cannot drift apart again (#593). pub(crate) fn shell_quote_for(path: &Path, shell_program: Option<&str>) -> String { - let s = path.to_string_lossy(); - if !s.is_empty() - && s.chars() - .all(|c| c.is_alphanumeric() || "/.-_~+".contains(c)) - { - return s.into_owned(); - } - let is_cmd = shell_program - .map(|p| { - let base = p.rsplit(['\\', '/']).next().unwrap_or(p); - base.eq_ignore_ascii_case("cmd") || base.eq_ignore_ascii_case("cmd.exe") - }) - .unwrap_or(false); - if is_cmd { - // Windows paths cannot contain a double quote, so there is nothing - // to escape inside the quotes. - format!("\"{s}\"") - } else { - format!("'{}'", s.replace('\'', r"'\''")) - } + crate::core::shell_quote::quote_for_shell(&path.to_string_lossy(), shell_program) } impl Tty7App { @@ -2678,7 +2658,13 @@ mod tests { fn shell_quote_leaves_safe_paths_and_quotes_the_rest() { assert_eq!(shell_quote_for(Path::new("/a/b.txt"), None), "/a/b.txt"); assert_eq!(shell_quote_for(Path::new("/a dir/f"), None), "'/a dir/f'"); - assert_eq!(shell_quote_for(Path::new("/a'b"), None), r"'/a'\''b'"); + // An apostrophe is the one character the dialects disagree about, so + // the shell has to be named — with none given the answer is the + // platform's, and this assertion is about the POSIX rule. + assert_eq!( + shell_quote_for(Path::new("/a'b"), Some("zsh")), + r"'/a'\''b'" + ); } #[test] diff --git a/src/ui/host_ops.rs b/src/ui/host_ops.rs index 4cf22478..ceacf716 100644 --- a/src/ui/host_ops.rs +++ b/src/ui/host_ops.rs @@ -35,11 +35,25 @@ mod blocking { } impl State { + /// Whether a submission should start a thread rather than lean on the + /// idle ones. Strictly greater: `jobs == idle` is already covered. fn wants_another_thread(&self) -> bool { self.jobs.len() > self.idle && self.threads < MAX_THREADS } } + /// Whether a worker whose wait timed out should retire. + /// + /// Not on the timer alone. A job can be pushed between the timeout firing + /// and this thread reacquiring the lock, and `submit` decides whether to + /// spawn by counting idle threads — so it saw this one as available and + /// did not spawn. Retiring on `timed_out` by itself would carry that job's + /// only worker away with it, and the job would sit in the queue until some + /// unrelated submission happened to start a thread. + fn should_retire(timed_out: bool, pending_jobs: usize) -> bool { + timed_out && pending_jobs == 0 + } + fn pool() -> &'static Arc { static POOL: OnceLock> = OnceLock::new(); POOL.get_or_init(|| { @@ -98,7 +112,7 @@ mod blocking { .unwrap_or_else(|e| e.into_inner()); st = guard; st.idle -= 1; - if timeout.timed_out() && st.jobs.is_empty() { + if should_retire(timeout.timed_out(), st.jobs.len()) { st.threads -= 1; return; } @@ -107,6 +121,67 @@ mod blocking { job(); } } + + #[cfg(test)] + mod tests { + use super::*; + + fn state(jobs: usize, threads: usize, idle: usize) -> State { + let mut q: VecDeque = VecDeque::new(); + for _ in 0..jobs { + q.push_back(Box::new(|| {})); + } + State { + jobs: q, + threads, + idle, + } + } + + #[test] + fn an_idle_thread_is_preferred_over_a_new_one() { + assert!( + !state(1, 1, 1).wants_another_thread(), + "one job and one idle thread needs nobody new" + ); + assert!( + !state(2, 2, 2).wants_another_thread(), + "jobs == idle is already covered" + ); + assert!( + state(3, 2, 2).wants_another_thread(), + "one job more than there are idle threads" + ); + } + + #[test] + fn the_first_job_starts_the_first_thread() { + assert!(state(1, 0, 0).wants_another_thread()); + } + + #[test] + fn the_pool_stops_growing_at_its_ceiling() { + assert!(state(1000, MAX_THREADS - 1, 0).wants_another_thread()); + assert!( + !state(1000, MAX_THREADS, 0).wants_another_thread(), + "a backlog does not buy more than MAX_THREADS" + ); + } + + #[test] + fn a_worker_retires_only_on_a_timeout_with_nothing_queued() { + assert!(should_retire(true, 0)); + assert!(!should_retire(false, 0), "a wake-up is not a timeout"); + } + + /// The whole reason the queue is consulted: `submit` counted this + /// thread as idle and therefore did not spawn one, so retiring here + /// would leave the job it just pushed with no worker. + #[test] + fn a_job_that_landed_during_the_timeout_keeps_the_worker_alive() { + assert!(!should_retire(true, 1)); + } + } } async fn off_thread(f: F) -> Option diff --git a/src/ui/local_link.rs b/src/ui/local_link.rs index a483218a..8756fb6f 100644 --- a/src/ui/local_link.rs +++ b/src/ui/local_link.rs @@ -82,14 +82,18 @@ impl LocalLink { log::info!("lost the control link to the local daemon; reconnecting"); link.client = None; } - match link.next_attempt { - None if link.backoff.attempt() == 0 => {} - None => { - link.next_attempt = Some(now + link.backoff.delay()); + match due( + link.next_attempt, + link.backoff.attempt(), + link.backoff.delay(), + now, + ) { + Due::Now => {} + Due::Wait => return, + Due::ScheduleAt(at) => { + link.next_attempt = Some(at); return; } - Some(at) if at > now => return, - Some(_) => {} } link.next_attempt = None; link.attempting = true; @@ -168,6 +172,42 @@ impl LocalLink { } } +/// What a tick should do about reconnecting. +#[derive(Debug, PartialEq, Eq)] +enum Due { + /// Try now. + Now, + /// Something is already scheduled and is not due yet. + Wait, + /// Nothing was scheduled; put the next attempt here and come back. + ScheduleAt(std::time::Instant), +} + +/// The reconnect schedule, with the clock and the link's state passed in. +/// +/// The very first attempt goes out immediately — at startup the daemon is +/// usually seconds from being up, and making the window wait a backoff for the +/// first try would be a visible stall — and only from the second does the +/// backoff get a say. +/// +/// Nothing here reads a global or the wall clock, which is what lets it be +/// tested. The structurally identical scheduler in `remote_workspace` is +/// covered through a `TestAppContext`; this one, which every user depends on at +/// launch, was covered not at all. +fn due( + scheduled: Option, + attempts_so_far: u32, + delay: std::time::Duration, + now: std::time::Instant, +) -> Due { + match scheduled { + None if attempts_so_far == 0 => Due::Now, + None => Due::ScheduleAt(now + delay), + Some(at) if at > now => Due::Wait, + Some(_) => Due::Now, + } +} + fn connect_blocking() -> std::io::Result> { use tty7_core::daemon::control::ControlHello; @@ -175,11 +215,18 @@ fn connect_blocking() -> std::io::Result> { let hello = ControlHello::gui(uuid::Uuid::new_v4().to_string(), "this computer"); let sink: tty7_core::daemon::control::EventSink = Box::new(local_event_sink); #[cfg(unix)] - let client = ControlClient::over_unix( - std::os::unix::net::UnixStream::connect(tty7_core::host::server::control_socket_path()?)?, - &hello, - sink, - )?; + let client = { + let stream = std::os::unix::net::UnixStream::connect( + tty7_core::host::server::control_socket_path()?, + )?; + // Every other client socket goes through `tune` on its way up — 256 KiB + // buffers on Unix, nodelay on Windows — because it is `connect_endpoint` + // that calls it, and this is the one connect that does not go through + // there. It is also the busiest: the control link carries every event + // the window redraws from. + tty7_core::daemon::transport::tune(&stream); + ControlClient::over_unix(stream, &hello, sink)? + }; #[cfg(windows)] let client = ControlClient::over_tcp(tty7_core::host::server::connect_control()?, &hello, sink)?; @@ -197,3 +244,60 @@ fn dialect_refusal(e: &std::io::Error) -> Option "ssh-manage-profiles", SaveSshSessionAsHost => "ssh-save-connection", OpenSshConnect(_) - | CheckoutBranch(_) | SetTheme(_) | ActivateTab(_) | ConnectSavedProfile(_) @@ -316,7 +310,6 @@ impl CommandKind { | OpenThemePicker | OpenSshConnectInput | OpenSshConnect(_) - | CheckoutBranch(_) | SetTheme(_) | ActivateTab(_) | ConnectSavedProfile(_) diff --git a/src/ui/perf.rs b/src/ui/perf.rs index 6d69d577..95d16ac9 100644 --- a/src/ui/perf.rs +++ b/src/ui/perf.rs @@ -1,57 +1,27 @@ +//! Per-callsite call-rate and build-time counter, printed once a second to +//! stderr when `TTY7_PROFILE` is set. The meter itself lives in +//! [`crate::core::rate_meter`], shared with `terminal::fps`. + use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; +use crate::core::rate_meter::{Meter, Wording, flag_enables}; + +const WORDING: Wording = Wording { + tag: "perf", + rate_unit: "calls/s", + counted: "calls", + timed: "build", +}; + pub fn enabled() -> bool { static ON: OnceLock = OnceLock::new(); *ON.get_or_init(|| flag_enables(std::env::var("TTY7_PROFILE").ok().as_deref())) } -fn flag_enables(value: Option<&str>) -> bool { - value.is_some_and(|v| !v.is_empty() && v != "0") -} - -const WINDOW: Duration = Duration::from_secs(1); - -struct Meter { - window_start: Instant, - calls: u32, - total: Duration, - max: Duration, -} - -impl Meter { - fn new(window_start: Instant) -> Self { - Self { - window_start, - calls: 0, - total: Duration::ZERO, - max: Duration::ZERO, - } - } - - fn record(&mut self, label: &str, now: Instant, build: Duration) -> Option { - self.calls += 1; - self.total += build; - self.max = self.max.max(build); - - let elapsed = now.duration_since(self.window_start); - if elapsed < WINDOW { - return None; - } - let secs = elapsed.as_secs_f64(); - let rate = self.calls as f64 / secs; - let avg_ms = self.total.as_secs_f64() * 1000.0 / self.calls as f64; - let max_ms = self.max.as_secs_f64() * 1000.0; - let line = format!( - "[perf] {label}: {rate:.1} calls/s over {secs:.2}s ({} calls) | build avg {avg_ms:.2}ms max {max_ms:.2}ms", - self.calls - ); - *self = Meter::new(now); - Some(line) - } -} - +/// One meter per label: several callsites report here and each wants its own +/// window, so that a slow one is visible rather than averaged away. fn meters() -> &'static Mutex> { static M: OnceLock>> = OnceLock::new(); M.get_or_init(|| Mutex::new(HashMap::new())) @@ -61,7 +31,7 @@ pub fn record(label: &'static str, build: Duration) { let now = Instant::now(); let mut guard = meters().lock().unwrap(); let m = guard.entry(label).or_insert_with(|| Meter::new(now)); - if let Some(line) = m.record(label, now, build) { + if let Some(line) = m.record(now, build, &WORDING, Some(label)) { eprintln!("{line}"); } } @@ -70,54 +40,32 @@ pub fn record(label: &'static str, build: Duration) { mod tests { use super::*; + /// The wording and the label are this module's contribution to the line; + /// the windowing behaviour is covered where the meter lives. #[test] - fn flag_semantics_cover_unset_empty_zero_and_set() { - assert!(!flag_enables(None), "unset leaves profiling off"); - assert!(!flag_enables(Some("")), "empty value is off"); - assert!(!flag_enables(Some("0")), "explicit 0 is off"); - assert!(flag_enables(Some("1"))); - assert!(flag_enables(Some("yes"))); - } - - #[test] - fn meter_accumulates_silently_below_the_window() { - let start = Instant::now(); - let mut m = Meter::new(start); - assert_eq!( - m.record( - "x", - start + Duration::from_millis(10), - Duration::from_millis(2) - ), - None - ); - assert_eq!( - m.calls, 1, - "the sub-window build folded into the open window" - ); - } - - #[test] - fn meter_flushes_and_resets_after_a_window() { + fn the_line_names_the_callsite_and_reads_as_calls_and_build_time() { let start = Instant::now(); let mut m = Meter::new(start); assert!( m.record( - "render", start + Duration::from_millis(500), - Duration::from_millis(2) + Duration::from_millis(2), + &WORDING, + Some("render") ) .is_none() ); - let flush_at = start + Duration::from_millis(1000); let line = m - .record("render", flush_at, Duration::from_millis(6)) + .record( + start + Duration::from_millis(1000), + Duration::from_millis(6), + &WORDING, + Some("render"), + ) .expect("crossing the window emits the aggregate line"); assert_eq!( line, "[perf] render: 2.0 calls/s over 1.00s (2 calls) | build avg 4.00ms max 6.00ms" ); - assert_eq!(m.calls, 0, "the flush starts a fresh window"); - assert_eq!(m.window_start, flush_at); } } diff --git a/src/ui/remote_connect.rs b/src/ui/remote_connect.rs index ec15c8cb..b04ea116 100644 --- a/src/ui/remote_connect.rs +++ b/src/ui/remote_connect.rs @@ -417,7 +417,20 @@ fn new_session_token() -> String { fn client_hostname() -> String { static NAME: OnceLock = OnceLock::new(); NAME.get_or_init(|| { - std::process::Command::new("hostname") + // Windows publishes the name in the environment, so the usual case + // spawns nothing at all. That matters here: a console program started + // from a GUI process flashes a console window on screen even when its + // output is piped, and this runs while the user is looking at the + // connect dialog. + #[cfg(windows)] + if let Some(name) = std::env::var_os("COMPUTERNAME") { + let name = name.to_string_lossy().trim().to_string(); + if !name.is_empty() { + return name; + } + } + let mut cmd = std::process::Command::new("hostname"); + tty7_core::core::proc::hide_console(&mut cmd) .output() .ok() .filter(|o| o.status.success()) diff --git a/src/ui/scm/actions.rs b/src/ui/scm/actions.rs index c162ffca..3d5ea2e1 100644 --- a/src/ui/scm/actions.rs +++ b/src/ui/scm/actions.rs @@ -185,9 +185,7 @@ impl Tty7App { cx, ), ScmIntent::CreateBranch => self.scm_begin_create_branch(window, cx), - // Checking out is a pick, not a verb: the switcher hangs off the - // branch name, which is where the list of branches already is. - ScmIntent::CheckoutBranch => {} + ScmIntent::CheckoutBranch => self.scm_begin_checkout_branch(window, cx), } } diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index fe13fd5f..bf3dc850 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -258,10 +258,12 @@ impl Tty7App { let branch = self.scm_branch_row(&repo, &status, cx); let naming = self.scm_new_branch_row(&repo, cx); + let switching = self.scm_checkout_branch_row(&repo, cx); let commit = self.scm_commit_box(&repo, &status, window, cx); let buttons = self.scm_commit_buttons(&repo, &status, cx); let mut pinned = vec![branch]; pinned.extend(naming); + pinned.extend(switching); pinned.push(commit); pinned.push(buttons); // A commit's detail replaces the working tree's, so the two can never @@ -678,11 +680,33 @@ impl Tty7App { let input = cx.new(|cx| InputState::new(window, cx).placeholder(t(L10nKey::ScmCreateBranch))); let handle = input.read(cx).focus_handle(cx); + self.scm.checkout_branch = None; self.scm.new_branch = Some(input); window.focus(&handle, cx); cx.notify(); } + /// Open the inline "switch to which branch" input. + /// + /// The branch-name button already drops a menu of every local branch, and + /// that is the better way to pick one. This is the other half: the command + /// palette entry and its key binding, which have no button to hang a menu + /// off. It used to be wired to nothing at all — the action was registered, + /// listed and bindable, and invoking it silently did nothing. + pub(crate) fn scm_begin_checkout_branch( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let input = + cx.new(|cx| InputState::new(window, cx).placeholder(t(L10nKey::ScmCheckoutBranch))); + let handle = input.read(cx).focus_handle(cx); + self.scm.new_branch = None; + self.scm.checkout_branch = Some(input); + window.focus(&handle, cx); + cx.notify(); + } + /// The inline "name your branch" row. /// /// A text input rather than a dialog: `window.prompt` can only offer @@ -736,6 +760,58 @@ impl Tty7App { ) } + /// The inline "switch to which branch" row, twin of [`Self::scm_new_branch_row`]. + fn scm_checkout_branch_row( + &mut self, + repo: &RepoKey, + cx: &mut Context, + ) -> Option { + let input = self.scm.checkout_branch.clone()?; + let repo = repo.clone(); + Some( + h_flex() + .id("scm-checkout-branch") + .flex_none() + .items_center() + .h(px(30.)) + .px(px(CONTENT_INSET)) + .child(div().flex_1().min_w_0().child(Input::new(&input).xsmall())) + .on_key_down( + cx.listener(move |this, ev: &gpui::KeyDownEvent, window, cx| { + match ev.keystroke.key.as_str() { + "escape" => { + this.scm.checkout_branch = None; + cx.notify(); + } + "enter" => { + let Some(input) = this.scm.checkout_branch.take() else { + return; + }; + let name = input.read(cx).value().trim().to_string(); + cx.notify(); + if name.is_empty() { + return; + } + // A name that is not a branch is git's to + // reject — `scm_op` surfaces the failure the + // same way it does for every other operation, + // and second-guessing it here would also have + // to know about remote-tracking refs and tags. + this.scm_op( + repo.clone(), + GitOp::CheckoutBranch { name }, + window, + cx, + ); + } + _ => {} + } + }), + ) + .into_any_element(), + ) + } + /// The message box. /// /// `key_context` rather than a focus trap: `secondary-enter` is diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs index d31e34f0..a201385f 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -70,6 +70,10 @@ pub(crate) struct ScmPanelState { pub(crate) branches_loading: HashSet, /// The inline "name your branch" input, present only while it is open. pub(crate) new_branch: Option>, + /// The inline "switch to which branch" input, present only while it is + /// open. Never open at the same time as `new_branch` — opening either one + /// closes the other, since the panel has room for one input row. + pub(crate) checkout_branch: Option>, /// Unsent commit messages, one per working tree. pub(crate) drafts: HashMap, /// The commit box. `None` until the panel has been rendered once: an From 74bb98697d8621b7b243d2d9aa19edbeab26c29e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:05:16 +0800 Subject: [PATCH 33/33] Keep a stalled remote link off the UI thread (#709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): keep a stalled remote link off the UI thread A pane's writing half was a blocking socket with no write timeout, written to synchronously from gpui event handlers. When the far end stopped draining — a congested remote workspace, where the router's copy_bidirectional stops reading our half — the send buffer filled and write(2) parked in the kernel. One UI thread draws every window, so that was every window frozen until the link recovered. macOS gives a unix stream 8K, which is about 1400 keystrokes: a single paste. Move the socket onto a sender thread. write/resize/respond_auth/Detach now encode a frame, push it onto a bounded queue and return; the sender writes with the lock released and is welcome to park for as long as the far end makes it. A second handle on the socket is kept for shutdown, which returns at once even while another thread is parked in write(2) — the only way teardown can break that state. The backlog is bounded at 4 MiB. Reaching it is a dead link rather than a slow one, and is reported through the same path — and once — as an outright refused write. Refusals are now met on the sender thread, so a pane learns of one a moment after the keystroke rather than during it. Teardown gives what is queued 50ms to go out before cutting the socket: on a draining link the sender is idle and Detach leaves in microseconds, and on a stalled one it never leaves at all, which closing a pane must not wait to find out. * fix(terminal): a big paste is a paste, and a retired link keeps its own tongue Review follow-ups on the pane-writer queue. The "said it once" flag lived on the pane and was cleared on relink, but the retiring sender still held the same `Arc`. A doomed write completing after the reset spent the new link's one chance to speak, and the next real refusal went unreported. The flag belongs to a link, not a pane, so `LinkWriter::new` now mints its own. A frame can be over the whole backlog bound on its own — `paste` sends the clipboard as one `Input` — and refusing it marked a perfectly healthy pane gone. An oversized frame onto an empty queue now goes through and lifts the bound by its own size while it is outstanding, so what queues behind it is still held to four megabytes. Also: `close` is idempotent, so the teardown that calls it twice does not spend two grace periods; a sender that has given up closes the queue behind it rather than letting keystrokes pile to the bound it will never drain; and the #673 note that was dropped in the move is back. The backlog test passed with 27K of margin against a send buffer that is 8K on macOS but 212K on Linux, where the sender discounts what it got onto the wire — it queues twice the bound now. --- src/terminal/remote.rs | 502 +++++++++++++++++++++++++++++++++++------ 1 file changed, 432 insertions(+), 70 deletions(-) diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index f35b3575..b29bcf12 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -181,6 +181,290 @@ impl PaneRoute { } } +/// How much unsent input a pane holds before it calls the link lost. A link +/// that is draining never gets near this: the sender thread is parked waiting +/// for work, so the queue holds at most the frames of one burst. Reaching it +/// means nothing on the far side has taken a byte for as long as it takes to +/// type — or paste — four megabytes, which is a dead link, not a slow one. +const MAX_BACKLOG: usize = 4 << 20; + +/// How long a teardown lets the sender finish what is already queued before it +/// cuts the socket out from under it. `Detach` is the last frame a pane sends +/// and it is worth a moment: on a draining link the sender is idle, so it goes +/// out in microseconds and this returns at once. On a link that has stopped +/// draining it will never go out at all, and closing the pane must not wait +/// around to discover that — the daemon reads the closed socket as a detach +/// anyway. +const DRAIN_GRACE: std::time::Duration = std::time::Duration::from_millis(50); + +/// What the sender thread needs to report a link that stopped taking input. +/// The signals the pane holds, cloned out so the failure can be raised from the +/// thread that actually meets it. +#[derive(Clone)] +struct InputLoss { + /// Set by the first frame *this* link refused, so the loss is said once + /// rather than once per keystroke. One per link rather than one per pane: a + /// relink hands the retired sender's last, doomed write and the new + /// sender's first real one two different flags, so the dying link cannot + /// spend the new link's one chance to speak. + said: Arc, + reader_quit: Arc, + exited: Arc, + proxy: EventProxy, +} + +impl InputLoss { + fn new(reader_quit: Arc, exited: Arc, proxy: EventProxy) -> InputLoss { + InputLoss { + said: Arc::new(AtomicBool::new(false)), + reader_quit, + exited, + proxy, + } + } + + /// The link refused a frame. Every keystroke after the first would say the + /// same thing, so this side says it once; and unless the reader has been + /// retired for a relink or a release, the pane is marked exited the way the + /// reader marks it on EOF — it is the same socket, noticed from the writing + /// side first — so the window shows the pane as gone instead of taking + /// input into it that nothing will ever read. The reader still raises its + /// own `Exit` when it finds the same socket closed; the handler is + /// idempotent, so a link that is genuinely gone may be reported twice. + /// + /// This is hardening for a *closed* link, not the cure for #673: a socket + /// some process holds open and never reads accepts writes into its send + /// buffer, so they succeed and vanish until the buffer fills, and nothing + /// here fires until the backlog bound does. What stops that pane existing + /// at all is `attach_on` refusing to call a silent `Attach` attached. + fn note(&self, err: &std::io::Error) { + if self.said.swap(true, Ordering::SeqCst) { + return; + } + log::warn!("the daemon link stopped taking this pane's input: {err}"); + if self.reader_quit.load(Ordering::SeqCst) { + return; + } + self.exited.store(true, Ordering::SeqCst); + self.proxy.send_event(AlacEvent::Wakeup); + self.proxy.send_event(AlacEvent::Exit); + } +} + +#[derive(Default)] +struct SendQueue { + frames: VecDeque>, + /// Bytes queued but not yet on the wire — including the batch the sender + /// currently holds, which is why this is not just `frames`' total. That + /// batch is exactly what a stalled link parks in, so leaving it out would + /// mean the backlog bound could never be reached. + bytes: usize, + /// How much of `bytes` belongs to frames that were over the whole bound on + /// their own, and so were let through on their own terms. The bound is + /// raised by exactly this while they are outstanding, and put back the + /// moment the queue empties — otherwise one big paste would leave every + /// keystroke behind it looking like a dead link. + oversize: usize, + /// Set by teardown, or by a sender that has given up: either way the queue + /// takes nothing more. Teardown's sender writes what is left first. + closing: bool, + /// Set by the sender once there is nothing left to write. Teardown waits + /// on this for `DRAIN_GRACE`, no longer. + drained: bool, +} + +/// A pane's writing half, moved onto a thread of its own. +/// +/// The socket underneath is blocking and has no write timeout, so a peer that +/// stops reading parks `write(2)` in the kernel until it starts again. Every +/// caller of `RemoteTerminal::write` is a gpui event handler — a keystroke, a +/// paste, a mouse report, a focus change — and one UI thread draws every +/// window, so a park there is every window frozen. On macOS a unix stream gives +/// up after 8K of send buffer, which is about 1400 keystrokes: a single paste. +/// +/// So nothing on the UI thread touches the socket. Frames are encoded, queued, +/// and handed to a sender thread that is welcome to park for as long as the far +/// end makes it. +struct LinkWriter { + /// A second handle on the same socket, kept for `shutdown` alone. + /// `shutdown(2)` returns at once even while another thread is parked in + /// `write(2)` on that socket — which is exactly the state teardown has to + /// be able to break, and exactly what a handle behind the sender's own lock + /// could not do. + closer: Stream, + queue: Arc<(Mutex, std::sync::Condvar)>, + loss: InputLoss, + thread: Option>, +} + +impl LinkWriter { + fn new(stream: Stream, loss: InputLoss) -> std::io::Result { + let closer = stream.try_clone()?; + let queue = Arc::new((Mutex::new(SendQueue::default()), std::sync::Condvar::new())); + let sending = Arc::clone(&queue); + let sender_loss = loss.clone(); + let thread = std::thread::Builder::new() + .name("tty7-pane-writer".into()) + .spawn(move || send_loop(stream, sending, sender_loss))?; + Ok(LinkWriter { + closer, + queue, + loss, + thread: Some(thread), + }) + } + + /// Queues a frame. Never blocks: the socket belongs to the sender thread, + /// and the only thing that happens here is a push onto a `VecDeque`. + fn send(&self, msg: ClientMsg) { + let mut frame = Vec::new(); + if let Err(e) = msg.encode(&mut frame) { + log::warn!("could not encode a frame for this pane's link: {e}"); + return; + } + let (lock, wake) = &*self.queue; + let Ok(mut q) = lock.lock() else { return }; + if q.closing { + return; + } + // A frame over the bound all by itself is not a backlog — a paste is + // whatever the clipboard holds, and refusing a big one would kill a + // perfectly healthy pane. Onto an empty queue it goes through anyway, + // and lifts the bound by its own size for as long as it is outstanding, + // so what piles up behind it is still held to the same four megabytes. + // Onto a queue that already has something on it, the ordinary bound + // applies: one such frame is a paste, a second one arriving before the + // first has moved is a link that is not moving. + let oversize = frame.len() > MAX_BACKLOG && q.bytes == 0; + if !oversize && q.bytes + frame.len() > MAX_BACKLOG + q.oversize { + // Dropped rather than queued: a backlog this deep is a link nothing + // is reading, and growing it only trades a frozen window for an + // exhausted heap. Reported in the same words, and once, as a write + // the link refuses outright — the pane is gone either way. + drop(q); + self.loss.note(&std::io::Error::new( + std::io::ErrorKind::WouldBlock, + format!("nothing has drained this pane's link for {MAX_BACKLOG} bytes of input"), + )); + return; + } + if oversize { + q.oversize = frame.len(); + } + q.bytes += frame.len(); + q.frames.push_back(frame); + q.drained = false; + drop(q); + wake.notify_one(); + } + + /// Retires the sender and cuts the link. Gives what is queued `DRAIN_GRACE` + /// to go out — see the constant — and then shuts the socket down whether it + /// went or not. Never joins: a sender parked on a dead link would take the + /// UI thread down with it, the same trap `stop_reader` documents. + /// + /// Called twice on the way out — `stop_reader` closes the link, then the + /// field drop closes it again — so the second call has to be free rather + /// than another `DRAIN_GRACE` spent waiting for a sender that is already + /// gone. A retired handle is one whose thread has been let go. + fn close(&mut self) { + if self.thread.is_none() { + return; + } + let (lock, wake) = &*self.queue; + if let Ok(mut q) = lock.lock() { + q.closing = true; + wake.notify_one(); + if let Ok((waited, _)) = wake.wait_timeout_while(q, DRAIN_GRACE, |q| !q.drained) { + drop(waited); + } + } + let _ = self.closer.shutdown(std::net::Shutdown::Both); + drop(self.thread.take()); + } +} + +impl Drop for LinkWriter { + fn drop(&mut self) { + self.close(); + } +} + +fn send_loop( + mut stream: Stream, + queue: Arc<(Mutex, std::sync::Condvar)>, + loss: InputLoss, +) { + use std::io::Write as _; + let (lock, wake) = &*queue; + + // Abandons whatever is still queued and reports the link settled. Wakes a + // teardown that is inside `DRAIN_GRACE` waiting to hear it: what is left + // here is never going out, and there is nothing to be gained by making the + // window sit out the rest of the grace period to find that out. Closes the + // queue on the way, too — with no thread left to drain it, anything queued + // after this is just a keystroke held onto until the pane drops. + let give_up = || { + if let Ok(mut q) = lock.lock() { + q.frames.clear(); + q.bytes = 0; + q.oversize = 0; + q.closing = true; + q.drained = true; + wake.notify_all(); + } + }; + + loop { + let batch = { + let Ok(mut q) = lock.lock() else { return }; + loop { + if !q.frames.is_empty() { + break; + } + // Nothing left to write, so the link is as flushed as this + // thread can make it. Said before the `closing` check, so a + // teardown racing a sender that has already finished hears it + // rather than waiting out `DRAIN_GRACE` for nothing. + q.drained = true; + wake.notify_all(); + if q.closing { + return; + } + let Ok(next) = wake.wait(q) else { return }; + q = next; + } + std::mem::take(&mut q.frames) + }; + // Counted against the backlog until it is actually out. Discounting it + // at the moment it left the `VecDeque` would let a sender parked on the + // first frame of a huge batch hold the whole thing off the books, and + // the bound the batch is meant to enforce would never be reached. + let taken: usize = batch.iter().map(Vec::len).sum(); + // Written with the lock released: parking here is the whole point, and + // a sender holding the queue lock while it parked would put every + // `send` — every keystroke — behind the same wait it exists to absorb. + for frame in batch { + if let Err(e) = stream.write_all(&frame) { + loss.note(&e); + give_up(); + return; + } + } + if let Err(e) = stream.flush() { + loss.note(&e); + give_up(); + return; + } + if let Ok(mut q) = lock.lock() { + q.bytes = q.bytes.saturating_sub(taken); + if q.bytes == 0 { + q.oversize = 0; + } + } + } +} + pub struct RemoteTerminal { pub term: Arc>>, pub events: smol::channel::Receiver, @@ -192,7 +476,7 @@ pub struct RemoteTerminal { /// alongside `size` so a display-scale change still reaches the child even /// when the grid dimensions are unchanged. synced_cell: (u16, u16), - writer: Mutex, + link: LinkWriter, cwd: Arc>>, shell_state: Arc>, remote_context: Arc>>, @@ -236,10 +520,6 @@ pub struct RemoteTerminal { /// flag under the term lock before every grid mutation, so once it is set /// the abandoned thread can only exit, never write. reader_quit: Arc, - /// Set by the first `Input` the link refused, so the loss is said once - /// rather than once per keystroke. Cleared when a relink installs a link - /// that has not refused anything yet. - input_lost: AtomicBool, } /// The workspace id a spawn carries, so the pane's shell gets `$TTY7_WS` and a @@ -530,12 +810,12 @@ impl RemoteTerminal { turns: self.turns.clone(), }, ); - if let Ok(mut writer) = self.writer.lock() { - *writer = stream; - } self.reader_thread = Some(reader); self.reader_quit = quit; - self.input_lost.store(false, Ordering::SeqCst); + // Installed after `reader_quit`, so the sender reports a refusal + // against the reader this link actually has. Assigning retires the old + // `LinkWriter` through its `Drop`, which is what closes the old socket. + self.link = LinkWriter::new(stream, self.input_loss())?; self.route = route.clone(); self.synced_size = false; self.resize(size, cell_w, cell_h); @@ -606,6 +886,11 @@ impl RemoteTerminal { }, ); + let link = LinkWriter::new( + write_half, + InputLoss::new(reader_quit.clone(), exited_flag.clone(), proxy.clone()), + )?; + Ok(Self { term, events: rx, @@ -614,7 +899,7 @@ impl RemoteTerminal { size, synced_size: false, synced_cell: (0, 0), - writer: Mutex::new(write_half), + link, cwd, shell_state, remote_context, @@ -636,14 +921,22 @@ impl RemoteTerminal { proxy, reader_thread: Some(reader_thread), reader_quit, - input_lost: AtomicBool::new(false), }) } + /// The signals the sender thread raises a refused frame through, bundled + /// for the `LinkWriter` about to be installed. Read after `reader_quit` has + /// been swapped, so a relink's new sender answers to the new reader. + fn input_loss(&self) -> InputLoss { + InputLoss::new( + self.reader_quit.clone(), + self.exited_flag.clone(), + self.proxy.clone(), + ) + } + pub fn detach_link(&mut self) { - if let Ok(mut writer) = self.writer.lock() { - let _ = ClientMsg::Detach.encode(&mut *writer); - } + self.link.send(ClientMsg::Detach); self.stop_reader(); self.poll_exited(); } @@ -658,9 +951,10 @@ impl RemoteTerminal { /// touching the grid again. fn stop_reader(&mut self) { self.reader_quit.store(true, Ordering::SeqCst); - if let Ok(writer) = self.writer.lock() { - let _ = writer.shutdown(std::net::Shutdown::Both); - } + // The sender owns the socket now, and closing it is its job: `close` + // gives whatever is queued a brief moment to go out and then shuts the + // socket down regardless, which is also what wakes the reader. + self.link.close(); drop(self.reader_thread.take()); } @@ -1179,45 +1473,17 @@ impl RemoteTerminal { self.child_exited.load(Ordering::SeqCst) } + /// Queues a keystroke — or a paste, or a mouse report — for the link. + /// + /// Callers are gpui event handlers on the UI thread, so this returns + /// without touching the socket. A link that has stopped draining is a + /// problem for the sender thread, not for the window. pub fn write>>(&self, bytes: B) { let bytes = bytes.into(); if bytes.is_empty() { return; } - let Ok(mut writer) = self.writer.lock() else { - return; - }; - if let Err(e) = ClientMsg::Input(bytes.into_owned()).encode(&mut *writer) { - drop(writer); - self.note_input_lost(&e); - } - } - - /// The link refused an `Input`. Every keystroke after the first would say - /// the same thing, so this side says it once; and unless the reader has - /// been retired for a relink or a release, the pane is marked exited the - /// way the reader marks it on EOF — it is the same socket, noticed from the - /// writing side first — so the window shows the pane as gone instead of - /// taking input into it that nothing will ever read. The reader still - /// raises its own `Exit` when it finds the same socket closed; the handler - /// is idempotent, so a link that is genuinely gone may be reported twice. - /// - /// This is hardening for a *closed* link, not the cure for #673: a socket - /// some process holds open and never reads accepts writes into its send - /// buffer, so they succeed and vanish until the buffer fills, and nothing - /// here fires. What stops that pane existing at all is `attach_on` - /// refusing to call a silent `Attach` attached. - fn note_input_lost(&self, err: &std::io::Error) { - if self.input_lost.swap(true, Ordering::SeqCst) { - return; - } - log::warn!("the daemon link stopped taking this pane's input: {err}"); - if self.reader_quit.load(Ordering::SeqCst) { - return; - } - self.exited_flag.store(true, Ordering::SeqCst); - self.proxy.send_event(AlacEvent::Wakeup); - self.proxy.send_event(AlacEvent::Exit); + self.link.send(ClientMsg::Input(bytes.into_owned())); } /// Whether the daemon behind this pane echoes a `DaemonMsg::Size` into the @@ -1273,9 +1539,7 @@ impl RemoteTerminal { } let win = win_size(size, cell_w, cell_h); - if let Ok(mut writer) = self.writer.lock() { - let _ = ClientMsg::Resize(win).encode(&mut *writer); - } + self.link.send(ClientMsg::Resize(win)); } pub fn foreground_cwd(&self) -> Option { @@ -1512,13 +1776,10 @@ impl RemoteTerminal { } pub fn respond_auth(&self, request_id: u64, response: AuthResponse) { - if let Ok(mut writer) = self.writer.lock() { - let _ = ClientMsg::AuthResponse { - request_id, - response, - } - .encode(&mut *writer); - } + self.link.send(ClientMsg::AuthResponse { + request_id, + response, + }); } /// The client half of known-hosts management. @@ -1947,9 +2208,7 @@ fn daemon_disconnected_before_spawn_reply(err: &anyhow::Error) -> bool { impl Drop for RemoteTerminal { fn drop(&mut self) { - if let Ok(mut writer) = self.writer.lock() { - let _ = ClientMsg::Detach.encode(&mut *writer); - } + self.link.send(ClientMsg::Detach); self.stop_reader(); } } @@ -3436,6 +3695,100 @@ mod tests { } } + /// A peer that holds the link open and stops reading is what the router + /// looks like from here when the far end is congested: `copy_bidirectional` + /// stops draining our half and the send buffer fills. The socket is + /// blocking and has no write timeout, so `write(2)` parks — and it used to + /// park on the UI thread, which draws every window. macOS gives a unix + /// stream 8K, so it took about 1400 keystrokes, or one paste. + /// + /// Typing into such a pane must now cost the window nothing at all. + #[test] + fn a_link_that_stopped_reading_does_not_park_the_writing_thread() { + crate::core::config::pin_test_config_dir(); + let (client_side, daemon_side) = UnixStream::pair().unwrap(); + let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); + + // Far past the 8K that used to be fatal, one keystroke at a time, on + // this very thread: a park anywhere in here is a frozen window. + let started = std::time::Instant::now(); + for _ in 0..64 * 1024 { + term.write(vec![b'x']); + } + let spent = started.elapsed(); + + drop(daemon_side); + assert!( + spent < std::time::Duration::from_secs(1), + "64K keystrokes into a link nobody is draining took {spent:?} — \ + the caller is a gpui event handler, so this is the UI thread" + ); + } + + /// The backlog is bounded, and reaching the bound is not a slow link but a + /// dead one: nothing has taken a byte for four megabytes of typing. Saying + /// so is what stops the pane quietly swallowing input forever, and it is + /// said in the same words — and once — as an outright refused write. + #[test] + fn a_backlog_nothing_drains_is_reported_as_a_lost_link() { + crate::core::config::pin_test_config_dir(); + let (client_side, daemon_side) = UnixStream::pair().unwrap(); + let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); + + // 8K a shot rather than a byte, so this is a few hundred frames and not + // a few million: the bound is on bytes queued, not on frames. + // + // Twice the bound rather than a frame or two past it. The sender does + // get some of this onto the wire before it parks — as much as the send + // buffer holds, which is 8K on macOS but a couple hundred K on Linux — + // and that much is discounted from the backlog. A margin narrower than + // the widest of those buffers is a test that passes on one platform and + // not the other. + let chunk = vec![b'x'; 8 << 10]; + for _ in 0..2 * MAX_BACKLOG / chunk.len() { + term.write(chunk.clone()); + } + + assert!( + term.exited_flag.load(Ordering::SeqCst), + "a link that has taken nothing for {MAX_BACKLOG} bytes is gone, and the pane must say so" + ); + let mut exits = 0; + while let Ok(ev) = term.events.try_recv() { + exits += usize::from(matches!(ev, AlacEvent::Exit)); + } + assert_eq!(exits, 1, "said once, not once per keystroke"); + drop(daemon_side); + } + + /// One frame can be larger than the whole backlog bound: a paste is + /// whatever the clipboard holds. That is not a link nothing is draining, + /// and a pane must not die of being pasted into — nor may the keystrokes + /// that follow read as a backlog merely because the paste is still going. + #[test] + fn a_paste_larger_than_the_backlog_bound_is_not_a_dead_link() { + crate::core::config::pin_test_config_dir(); + let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); + let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); + + // A peer that keeps reading: the link is healthy, just carrying a lot. + let draining = std::thread::spawn(move || { + let _ = std::io::copy(&mut daemon_side, &mut std::io::sink()); + }); + + term.write(vec![b'x'; MAX_BACKLOG + (1 << 20)]); + for _ in 0..1024 { + term.write(vec![b'y']); + } + + assert!( + !term.exited_flag.load(Ordering::SeqCst), + "a five megabyte paste is a paste, not a link that has stopped taking input" + ); + drop(term); + draining.join().unwrap(); + } + /// Input the link refuses used to vanish: `write` threw the error away, so a /// pane whose daemon had stopped reading kept taking keystrokes into /// nothing. The refusal now marks the pane exited by the reader's own signal @@ -3443,18 +3796,25 @@ mod tests { /// an open receiving half and the writing side is the one that finds out. /// (Shutting the peer's receiving half instead is not portable: Linux /// answers the next write with EPIPE, macOS buffers it.) + /// + /// The refusal is met on the sender thread now, so the pane learns of it a + /// moment after the keystroke rather than during it. That is the trade the + /// queue buys: the window never waits on the socket to find out. #[test] fn a_write_the_link_refuses_marks_the_pane_gone_once() { crate::core::config::pin_test_config_dir(); let (client_side, _daemon_side) = UnixStream::pair().unwrap(); let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - term.writer - .lock() - .unwrap() + term.link + .closer .shutdown(std::net::Shutdown::Write) .unwrap(); term.write(b"echo hi\r".to_vec()); + let noticed = std::time::Instant::now() + std::time::Duration::from_secs(3); + while !term.exited_flag.load(Ordering::SeqCst) && std::time::Instant::now() < noticed { + std::thread::sleep(std::time::Duration::from_millis(5)); + } assert!( term.exited_flag.load(Ordering::SeqCst), "a refused Input is the link gone, and the pane has to say so" @@ -3475,9 +3835,11 @@ mod tests { assert_eq!(exits, 1, "said once, not once per keystroke"); } - /// A link retired for a relink refuses writes too — `stop_reader` shuts it - /// down — and that must not read as the pane dying under the swap, for the - /// same reason the retired reader exits silently. + /// A link retired for a relink takes no more input — `stop_reader` closes + /// the queue and shuts the socket down — and that must not read as the pane + /// dying under the swap, for the same reason the retired reader exits + /// silently. The frame is now turned away at the queue rather than by the + /// socket, but the pane has to stay alive either way. #[test] fn a_write_on_a_retired_link_does_not_mark_the_pane_gone() { crate::core::config::pin_test_config_dir();