diff --git a/.claude/skills/repomix-reference-tty7/SKILL.md b/.claude/skills/repomix-reference-tty7/SKILL.md new file mode 100644 index 00000000..67af7f3a --- /dev/null +++ b/.claude/skills/repomix-reference-tty7/SKILL.md @@ -0,0 +1,79 @@ +--- +name: repomix-reference-tty7 +description: Reference codebase for Tty7. Use this skill when you need to understand the structure, implementation patterns, or code details of the Tty7 project. +--- + +# Tty7 Codebase Reference + +76 files | 29369 lines | 318031 tokens + +## Overview + +Use this skill when you need to: +- Understand project structure and file organization +- Find where specific functionality is implemented +- Read source code for any file +- Search for code patterns or keywords + +## Files + +| File | Contents | +|------|----------| +| `references/summary.md` | **Start here** - Purpose, format explanation, and statistics | +| `references/project-structure.md` | Directory tree with line counts per file | +| `references/files.md` | All file contents (search with `## File: `) | +| `references/tech-stacks.md` | Languages, frameworks, and dependencies per package (search with `## Tech Stack: `) | + +## How to Use + +### 1. Find file locations + +Check `project-structure.md` for the directory tree: + +``` +src/ + index.ts (42 lines) + utils/ + helpers.ts (128 lines) +``` + +### 2. Read file contents + +Grep in `files.md` for the file path: + +``` +## File: src/utils/helpers.ts +``` + +### 3. Search for code + +Grep in `files.md` for keywords: + +``` +function calculateTotal +``` + +## Common Use Cases + +**Understand a feature:** +1. Search `project-structure.md` for related file names +2. Read the main implementation file in `files.md` +3. Search for imports/references to trace dependencies + +**Debug an error:** +1. Grep the error message or class name in `files.md` +2. Check line counts in `project-structure.md` to find large files + +**Find all usages:** +1. Grep function or variable name in `files.md` + +## Tips + +- Use line counts in `project-structure.md` to estimate file complexity +- Search `## File:` pattern to jump between files +- Check `summary.md` for excluded files, format details, and file statistics +- Check `tech-stacks.md` for languages, frameworks, and dependencies (search `## Tech Stack:` to list packages) + +--- + +This skill was generated by [Repomix](https://github.com/yamadashy/repomix) diff --git a/.claude/skills/repomix-reference-tty7/references/files.md b/.claude/skills/repomix-reference-tty7/references/files.md new file mode 100644 index 00000000..ef7d9f69 --- /dev/null +++ b/.claude/skills/repomix-reference-tty7/references/files.md @@ -0,0 +1,29674 @@ +# Files + +## File: .claude/skills/verify/SKILL.md +````markdown +--- +name: verify +description: How to build, launch, and observe tty7 (native GPUI macOS app) to verify a change end-to-end on this machine. +--- + +# Verifying tty7 changes at runtime + +## Build & launch + +- `cargo test` does NOT refresh `target/debug/tty7` — run `cargo build` before + launching, or you'll be driving a stale binary (settings UI missing your new + toggle is the telltale). +- Isolate config/session/daemon with `TTY7_CONFIG_DIR=` (also + `--config-dir`). The daemon is per-config-dir, so an isolated dir gets its + own daemon and never touches `~/.config/tty7` or the user's daemon. +- A GUI launched against a daemon from an older binary can misbehave — kill + your own scratch daemon (`pgrep -fl "target/debug/tty7"`; only kill PIDs + whose `--config-dir` is your scratch dir) and let the fresh GUI respawn it. +- App startup lands on a Settings tab when the scratch session is empty; "+" + in the tab strip opens a terminal tab. + +## Driving the GUI (no agent-browser — native GPUI, not Electron) + +- Enumerate windows and match YOUR instance by PID: + `Quartz.CGWindowListCopyWindowInfo(...)` → filter `kCGWindowOwnerName == + 'tty7'`, check `kCGWindowOwnerPID` against the process you spawned. +- Screenshot: `screencapture -x -o -l out.png` (`-o` drops the + shadow so screenshot-pixels / 2 = window-point coordinates on Retina). +- Synthesize mouse input with Quartz `CGEventCreateMouseEvent` + + `CGEventPost` (works without osascript's accessibility grant). Double-click + = two down/up pairs with `kCGMouseEventClickState` 1 then 2. + +## Hazards on this machine + +- **Shared workstation**: the user (and other agent sessions) often run + `target/debug/tty7 --config-dir .tty7-dev` and the installed + `/Applications/tty7.app` concurrently, share the clipboard, rebuild the + same `target/` dir, and restart tty7 processes — your instance can be + killed under you and `pbpaste` can change between your commands. +- Synthetic clicks land on the FRONTMOST window. Before every click, verify + your window is first in the layer-0 z-order list; otherwise you are + clicking into the user's windows. If z-order keeps changing, stop driving + the GUI — fall back to the headless harness below. +- The `gpui_tests` module in `src/terminal/view.rs` is a real (headless) + App + Window + socketpair harness with a working test clipboard — it's the + deepest automatable evidence when live-GUI driving is unsafe. +```` + +## File: .cargo/config.toml +````toml +# `cargo dev` — run the app against a throwaway config directory (`.tty7-dev/`) +# instead of the real `~/.config/tty7/`, so debugging never clobbers your live +# config/session/history. Extra args pass through, e.g. `cargo dev -- --foo`. +[alias] +dev = "run -- --config-dir .tty7-dev" +```` + +## File: .github/ISSUE_TEMPLATE/config.yml +````yaml +blank_issues_enabled: true +contact_links: + - name: Questions & ideas + url: https://github.com/l0ng-ai/tty7/discussions + about: Not sure it's a bug? Want to discuss an idea first? Start a discussion. + - name: Security vulnerabilities + url: https://github.com/l0ng-ai/tty7/security/advisories/new + about: Please report security issues privately, not as public issues. +```` + +## File: .github/ISSUE_TEMPLATE/issue.yml +````yaml +name: Issue +description: Report a bug or suggest an improvement +body: + - type: dropdown + id: kind + attributes: + label: Type + options: + - Bug — something doesn't work as expected + - Idea — suggest an improvement or new capability + validations: + required: true + - type: textarea + id: detail + attributes: + label: What's going on? + description: > + For a bug: what you did, what you saw, and what you expected instead. + For an idea: the problem it solves and how it should behave. + validations: + required: true + - type: input + id: version + attributes: + label: tty7 version (bugs) + placeholder: v0.2.0 + - type: dropdown + id: platform + attributes: + label: Platform (bugs) + options: + - macOS (Apple Silicon) + - macOS (Intel) + - Windows + - Linux + - type: textarea + id: extra + attributes: + label: Anything else? + description: > + Screenshots or recordings, the exact command / escape sequence that + triggers it, the shell you were using, or the TUI app (vim, htop, …) + and its version if one is involved. +```` + +## File: .github/scripts/bundle-linux.sh +````bash +#!/bin/bash +# Usage: bundle-linux.sh +# Package the release binary into a tarball: +# dist/tty7--linux-.tar.gz +# +# Fonts and the app icon are embedded via include_bytes!, so the archive is the +# stripped executable plus a sibling completions/ dir (loaded at runtime — see +# terminal::signature) and the license/readme. gpui's x11/wayland backends still +# dynamic-link the usual system libs at runtime — see the README's Linux +# build-dependency list — so this is an unsigned build, not a +# portable AppImage. +set -euo pipefail + +TARGET="$1" +ARCH="$2" +VERSION="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" +NAME="tty7-${VERSION}-linux-${ARCH}" +STAGE="dist/${NAME}" + +rm -rf dist +mkdir -p "$STAGE" + +cp "target/${TARGET}/release/tty7" "$STAGE/tty7" +chmod +x "$STAGE/tty7" +# Release builds keep symbols (thin LTO, no profile strip); drop them here so +# the archive isn't ~100 MB of debug info. +strip "$STAGE/tty7" || echo "⚠️ strip unavailable — shipping unstripped binary" +mkdir -p "$STAGE/completions" +cp assets/completions/*.json "$STAGE/completions/" +cp LICENSE "$STAGE/LICENSE" +cp README.md "$STAGE/README.md" + +tar -C dist -czf "dist/${NAME}.tar.gz" "$NAME" +rm -rf "$STAGE" +echo "✅ dist/${NAME}.tar.gz" +```` + +## File: .github/scripts/bundle-macos.sh +````bash +#!/bin/bash +# Usage: bundle-macos.sh +# Package the release binary into dist/tty7.app and wrap it in a +# drag-to-Applications DMG: dist/tty7--macos-.dmg. +# +# Signing posture is chosen from the environment: +# * Developer ID secrets present (APPLE_SIGNING_IDENTITY + APPLE_CERTIFICATE) +# -> 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. +set -euo pipefail + +TARGET="$1" +ARCH="$2" +VERSION="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" +APP="dist/tty7.app" + +rm -rf dist +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "target/${TARGET}/release/tty7" "$APP/Contents/MacOS/tty7" +chmod +x "$APP/Contents/MacOS/tty7" +cp assets/tty7.icns "$APP/Contents/Resources/tty7.icns" +# Completion signatures are loaded at runtime (not embedded), resolved relative +# to the executable as ../Resources/completions — see terminal::signature. +mkdir -p "$APP/Contents/Resources/completions" +cp assets/completions/*.json "$APP/Contents/Resources/completions/" +printf 'APPL????' > "$APP/Contents/PkgInfo" + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleNametty7 + CFBundleDisplayNametty7 + CFBundleIdentifiercom.github.tty7 + CFBundleVersion${VERSION} + CFBundleShortVersionString${VERSION} + CFBundleExecutabletty7 + CFBundleIconFiletty7 + CFBundlePackageTypeAPPL + NSHighResolutionCapable + NSPrincipalClassNSApplication + + +PLIST + +SIGN_ID="${APPLE_SIGNING_IDENTITY:-}" + +if [[ -n "$SIGN_ID" && -n "${APPLE_CERTIFICATE:-}" ]]; then + # ---- Developer ID signing ------------------------------------------------ + # Import the cert into a throwaway keychain so we never touch the login one. + KEYCHAIN="${RUNNER_TEMP:-/tmp}/tty7-sign.keychain-db" + CERT_PATH="${RUNNER_TEMP:-/tmp}/tty7-cert.p12" + KEYCHAIN_PASSWORD="${KEYCHAIN_PASSWORD:-tty7-ci}" + # Scrub the decoded cert + temp keychain on any exit path. + cleanup() { + security delete-keychain "$KEYCHAIN" >/dev/null 2>&1 || true + rm -f "$CERT_PATH" + } + trap cleanup EXIT + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -P "${APPLE_CERTIFICATE_PASSWORD:-}" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN" + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null + security list-keychains -d user -s "$KEYCHAIN" login.keychain + + # Hardened runtime forbids JIT / unsigned executable memory by default; the + # GPU/Metal path gpui uses needs them, so grant them explicitly or the + # notarized build crashes on launch. + ENTITLEMENTS="dist/entitlements.plist" + cat > "$ENTITLEMENTS" <<'ENT' + + + + + com.apple.security.cs.allow-jit + com.apple.security.cs.allow-unsigned-executable-memory + com.apple.security.cs.disable-library-validation + + +ENT + + # Sign inner-out: the executable first, then the bundle. + codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" \ + --sign "$SIGN_ID" "$APP/Contents/MacOS/tty7" + codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" \ + --sign "$SIGN_ID" "$APP" + codesign --verify --strict --verbose=2 "$APP" + + # ---- Notarization -------------------------------------------------------- + if [[ -n "${APPLE_ID:-}" && -n "${APPLE_PASSWORD:-}" && -n "${APPLE_TEAM_ID:-}" ]]; then + # Submit a zip of the .app; on success staple the ticket onto the bundle + # so it validates offline (the distributed zip below then carries it). + ditto -c -k --keepParent "$APP" "dist/notarize.zip" + xcrun notarytool submit "dist/notarize.zip" \ + --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" --wait + xcrun stapler staple "$APP" + rm -f "dist/notarize.zip" + echo "✅ signed + notarized + stapled" + else + echo "⚠️ signed with Developer ID but notarization secrets missing — skipping notarize" + fi +else + echo "⚠️ no Developer ID secrets — adhoc signing (won't pass Gatekeeper on other machines)" + codesign --force --deep --sign - "$APP" +fi + +# Package the (now stapled) bundle as a drag-to-Applications DMG. +DMG="dist/tty7-${VERSION}-macos-${ARCH}.dmg" +STAGE="dist/dmg-stage" +rm -rf "$STAGE" +mkdir "$STAGE" +cp -R "$APP" "$STAGE/" +ln -s /Applications "$STAGE/Applications" +hdiutil create -volname "tty7" -srcfolder "$STAGE" -ov -format UDZO "$DMG" +rm -rf "$STAGE" +if [[ -n "$SIGN_ID" && -n "${APPLE_CERTIFICATE:-}" ]]; then + codesign --force --timestamp --sign "$SIGN_ID" "$DMG" +fi +echo "✅ $DMG" +```` + +## File: .github/scripts/windows-installer.iss +```` +; tty7 Windows installer (Inno Setup 6 — preinstalled on GitHub's +; windows-latest runners). Compiled by bundle-windows.ps1, which stages the +; payload and passes every path in via /D defines: +; +; /DAppVersion= version parsed from Cargo.toml +; /DStageDir= staged payload (tty7.exe, completions\, LICENSE.txt, README.md) +; /DOutputDir= where the setup exe is written +; /DOutputName= setup exe filename, without ".exe" +; +; Defaults to a per-user install ({localappdata}\Programs\tty7 — no UAC +; prompt), with an "install for all users" escape hatch in the dialog. The +; build is unsigned, so SmartScreen warns on first launch either way — same as +; the portable zip. + +#ifndef AppVersion + #error Missing /DAppVersion — this script is meant to be compiled via bundle-windows.ps1 +#endif + +[Setup] +; Never change AppId: it is how Windows ties upgrades + the uninstall entry +; to previous installs of tty7. +AppId={{9A3F6C1E-4B7D-4E2A-8C5F-D01B92E64A37} +AppName=tty7 +AppVersion={#AppVersion} +AppPublisher=tty7 contributors +AppPublisherURL=https://github.com/l0ng-ai/tty7 +AppSupportURL=https://github.com/l0ng-ai/tty7/issues +AppUpdatesURL=https://github.com/l0ng-ai/tty7/releases +DefaultDirName={autopf}\tty7 +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +MinVersion=10.0 +LicenseFile={#StageDir}\LICENSE.txt +SetupIconFile=..\..\assets\favicon.ico +UninstallDisplayIcon={app}\tty7.exe +OutputDir={#OutputDir} +OutputBaseFilename={#OutputName} +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "{#StageDir}\tty7.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#StageDir}\completions\*"; DestDir: "{app}\completions"; Flags: ignoreversion recursesubdirs +Source: "{#StageDir}\LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#StageDir}\README.md"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{autoprograms}\tty7"; Filename: "{app}\tty7.exe" +Name: "{autodesktop}\tty7"; Filename: "{app}\tty7.exe"; Tasks: desktopicon + +[Run] +Filename: "{app}\tty7.exe"; Description: "{cm:LaunchProgram,tty7}"; Flags: nowait postinstall skipifsilent +```` + +## File: .github/workflows/ci.yml +````yaml +name: CI + +# Compile + test on every push/PR. The Windows and Linux jobs are the +# compile-feedback loop for the platform-specific code a macOS dev machine +# never builds (`cfg(windows)` transport / process detach / config dir, +# the Linux `/proc` queries, the x11/wayland gpui backends). The macOS job +# guards against regressing the original target. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + fmt: + name: rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --check + + build: + name: build & test (${{ matrix.target }}) + strategy: + fail-fast: false + matrix: + include: + - runner: macos-14 + target: aarch64-apple-darwin + - runner: windows-latest + target: x86_64-pc-windows-msvc + - runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout tty7 + uses: actions/checkout@v4 + + # gpui-component is a git dependency (see Cargo.toml), so no sibling + # checkout is needed. The Windows backend (gpui_windows + DirectWrite/D3D) + # ships with the windows-latest runner's SDK — no extra system deps. + + # gpui's Linux backends need the x11/wayland/xkb/font dev packages at + # build time (build scripts resolve them via pkg-config). Same set the + # README documents for building from source on Linux. + - name: Install Linux system dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y pkg-config cmake clang libxkbcommon-dev \ + libxkbcommon-x11-dev libfontconfig1-dev libfreetype6-dev \ + libwayland-dev libx11-dev libxcb1-dev libzstd-dev libssl-dev + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + + - name: Build + run: cargo build --target ${{ matrix.target }} + + - name: Test + run: cargo test --target ${{ matrix.target }} +```` + +## File: .github/dependabot.yml +````yaml +version: 2 +updates: + # Rust crates.io dependencies (git deps like gpui / alacritty_terminal are + # pinned by hand and won't be touched here). + - package-ecosystem: cargo + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: "deps" + groups: + cargo-minor-patch: + update-types: ["minor", "patch"] + + # GitHub Actions used by the CI and release workflows. + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + commit-message: + prefix: "ci" +```` + +## File: scripts/bench/doom-fire-fps.patch +````diff +diff --git a/src/main.zig b/src/main.zig +index cceca94..342c9f2 100644 +--- a/src/main.zig ++++ b/src/main.zig +@@ -690,6 +690,19 @@ pub fn paintBuf() !void { + + try emit(fg[0]); + try emitFmt("mem: {s:.2} min / {s:.2} avg / {s:.2} max [ {d:.2} fps ]", .{ std.fmt.fmtIntSizeBin(bs_sz_min), std.fmt.fmtIntSizeBin(bs_sz_avg), std.fmt.fmtIntSizeBin(bs_sz_max), fps }); ++ ++ // bench patch: periodically dump the cumulative fps to $DOOM_FPS_FILE so a ++ // harness can collect the average without recording the output stream. ++ if (bs_frame_tic % 30 == 0) { ++ if (std.posix.getenv("DOOM_FPS_FILE")) |fps_path| { ++ if (std.fs.createFileAbsolute(fps_path, .{ .truncate = true })) |f| { ++ defer f.close(); ++ var fps_buf: [64]u8 = undefined; ++ const fps_str = std.fmt.bufPrint(&fps_buf, "{d:.2}\n", .{fps}) catch return; ++ f.writeAll(fps_str) catch {}; ++ } else |_| {} ++ } ++ } + } + + // initBuf(); defer freeBuf(); +```` + +## File: scripts/bench/fire.sh +````bash +#!/bin/zsh +# DOOM-fire FPS benchmark. Runs INSIDE the terminal under test as its shell +# (run_one.sh arranges that); $1 names the terminal for the result file. +# +# 5 runs; each lets the fire burn ~14 s, then SIGINTs it. The binary is built +# by setup.sh with doom-fire-fps.patch, which dumps the cumulative average fps +# to $DOOM_FPS_FILE every 30 frames — the file's last value IS the run's +# average, so nothing has to record the output stream. (The obvious +# alternative, script(1), wrote multi-GB recordings on fast terminals and the +# disk writes throttled later runs by 4-8×.) stdout stays the real pty: +# DOOM-fire sizes itself via ioctl(stdout), and stdin may be a pipe — the +# printf feeds the "press return" intro pauses. +SELF=${0:A} +HERE=${SELF:h} +REPO=${HERE:h:h} +WORK=${TTY7_BENCH_DIR:-$REPO/.bench} +T=${1:-unknown} +R=$WORK/results +DOOM=$WORK/DOOM-fire-zig/zig-out/bin/DOOM-fire +mkdir -p $R +out=$R/fire-$T.txt +: > $out +print -- "grid: $(stty size 2>/dev/null)" >> $out +sleep 1 +for i in 1 2 3 4 5; do + fpsfile=$WORK/doom-fps-$T-$i.txt + rm -f $fpsfile + ( printf '\n\n\n\n\n\n'; sleep 60 ) | DOOM_FPS_FILE=$fpsfile $DOOM & + sp=$! + sleep 15 + pkill -INT -f 'zig-out/bin/DOOM-fire' 2>/dev/null + sleep 1 + pkill -9 -f 'zig-out/bin/DOOM-fire' 2>/dev/null + wait $sp 2>/dev/null + fps=$(head -1 $fpsfile 2>/dev/null) + print -- "run $i: ${fps:-NA} fps" >> $out + printf '\e[0m\e[2J\e[H' + sleep 1 +done +print -- done >> $out +sleep 5 +```` + +## File: scripts/bench/io.sh +````bash +#!/bin/zsh +# Plaintext-IO benchmark. Runs INSIDE the terminal under test as its shell +# (run_one.sh arranges that); $1 names the terminal for the result file. +# +# Methodology (moktavizen/terminal-benchmark): `time cat` an 11 MB text file, +# 5 runs. Timed with zsh's EPOCHREALTIME instead of `time` so the driver can +# collect results from a file instead of reading the screen. +zmodload zsh/datetime +SELF=${0:A} +HERE=${SELF:h} +REPO=${HERE:h:h} +WORK=${TTY7_BENCH_DIR:-$REPO/.bench} +T=${1:-unknown} +R=$WORK/results +mkdir -p $R +out=$R/io-$T.txt +: > $out +print -- "grid: $(stty size 2>/dev/null)" >> $out +sleep 1 +for i in 1 2 3 4 5; do + t0=$EPOCHREALTIME + command cat $WORK/shakespeare.txt + t1=$EPOCHREALTIME + printf 'run %d: %.0f ms\n' $i $(( (t1 - t0) * 1000 )) >> $out +done +print -- done >> $out +sleep 5 +```` + +## File: scripts/bench/mem.sh +````bash +#!/bin/zsh +# Memory benchmark: cold-launch each terminal with its DEFAULT shell, idle 6 s +# at the prompt, record RSS via ps, kill, repeat. $1 = runs per terminal +# (default 3). tty7 is reported as GUI + daemon — both processes are part of +# delivering one window. +set -u +SELF=${0:A} +HERE=${SELF:h} +REPO=${HERE:h:h} +WORK=${TTY7_BENCH_DIR:-$REPO/.bench} +TTY7_BIN=${TTY7_BIN:-$REPO/target/release/tty7} +RUNS=${1:-3} +R=$WORK/results +mkdir -p $R + +rss_kb() { ps -o rss= -p ${1:-0} 2>/dev/null | awk '{print $1+0}' } + +out=$R/mem-tty7.txt +: > $out +for i in $(seq 1 $RUNS); do + cfg=$WORK/cfg-mem + rm -rf $cfg && mkdir -p $cfg && print -- '{}' > $cfg/config.json + $TTY7_BIN --config-dir $cfg >/dev/null 2>&1 & + gpid=$! + sleep 6 + dpid=$(pgrep -f -- "--daemon --config-dir $cfg" | head -1) + g=$(rss_kb $gpid) + d=$(rss_kb ${dpid:-0}) + print -- "run $i: gui ${g} KB, daemon ${d} KB, total $((g + d)) KB" >> $out + kill $gpid 2>/dev/null + [ -n "${dpid:-}" ] && kill $dpid 2>/dev/null + sleep 1 + pkill -9 -f -- "$cfg" 2>/dev/null + sleep 1 +done +print -- done >> $out + +out=$R/mem-alacritty.txt +: > $out +for i in $(seq 1 $RUNS); do + : > $WORK/alacritty-empty.toml + /Applications/Alacritty.app/Contents/MacOS/alacritty --config-file $WORK/alacritty-empty.toml >/dev/null 2>&1 & + pid=$! + sleep 6 + print -- "run $i: $(rss_kb $pid) KB" >> $out + kill $pid 2>/dev/null + sleep 2 +done +print -- done >> $out + +out=$R/mem-ghostty.txt +: > $out +for i in $(seq 1 $RUNS); do + /Applications/Ghostty.app/Contents/MacOS/ghostty --config-default-files=false >/dev/null 2>&1 & + pid=$! + sleep 6 + print -- "run $i: $(rss_kb $pid) KB" >> $out + kill $pid 2>/dev/null + sleep 2 +done +print -- done >> $out + +out=$R/mem-kitty.txt +: > $out +for i in $(seq 1 $RUNS); do + /Applications/kitty.app/Contents/MacOS/kitty --config NONE >/dev/null 2>&1 & + pid=$! + sleep 6 + print -- "run $i: $(rss_kb $pid) KB" >> $out + kill $pid 2>/dev/null + sleep 2 +done +print -- done >> $out + +echo "=== mem results ===" +for f in $R/mem-*.txt; do + echo "--- $f" + cat $f +done +```` + +## File: scripts/bench/README.md +````markdown +# Terminal benchmark harness + +Reproducible throughput/FPS/memory benchmarks for tty7 against Alacritty, +Ghostty and Kitty, following the methodology of +[moktavizen/terminal-benchmark](https://github.com/moktavizen/terminal-benchmark). +macOS only (drivers use `/Applications` paths, BSD `ps`, `pkill`). + +## What it measures + +| Test | Method | Collects | +| --- | --- | --- | +| **Plaintext IO** | `cat` an 11 MB text file inside the terminal, 5 runs | elapsed ms per run (lower = better) | +| **Frame rate** | [DOOM-fire-zig](https://github.com/const-void/DOOM-fire-zig), 5 runs × ~14 s | cumulative average fps per run (higher = better) | +| **Memory** | cold launch, default shell, idle 6 s | RSS in KB (tty7 = GUI + daemon) | + +The methodology source also measures **input latency**, but with a high-speed +camera ([is-it-snappy](https://github.com/chadaustin/is-it-snappy)) — that +can't be automated here and is skipped. + +## Usage + +```bash +scripts/bench/setup.sh # once: corpus + patched DOOM-fire (+ zig 0.14 if needed) +cargo build --release # the tty7 under test + +scripts/bench/run_one.sh tty7 io # ALWAYS first: records the reference grid +scripts/bench/run_one.sh alacritty io # matches tty7's grid automatically +scripts/bench/run_one.sh ghostty io +scripts/bench/run_one.sh kitty io +scripts/bench/run_one.sh tty7 fire # same order for the fire test +scripts/bench/run_one.sh alacritty fire +scripts/bench/run_one.sh ghostty fire +scripts/bench/run_one.sh kitty fire +scripts/bench/mem.sh # all four terminals, 3 runs each +``` + +Results land in `.bench/results/` (override the work dir with +`$TTY7_BENCH_DIR`, the tty7 binary with `$TTY7_BIN`). + +**While a run is up: don't type into the window, and don't hide or fully +occlude it** — macOS throttles occluded windows and the FPS numbers collapse +(observed: ~950 fps → ~360 fps when the window was hidden mid-run). + +## How it drives each terminal + +Every terminal opens a real window whose *shell* is the benchmark script, so +the measurement includes the full input path the user experiences: + +- **tty7**: an isolated `--config-dir` under the work dir whose `config.json` + sets `shell` to the script. GUI + daemon are launched fresh and killed + (by config-dir-scoped `pkill`) after the run — a daily-driver tty7 and its + daemon are never touched. +- **Alacritty**: `--config-file ` (isolates the user's config) plus + `-o window.dimensions.…` for the grid, `-e` for the script. +- **Ghostty**: `--config-default-files=false` plus `--window-width/height` + (cells) and `-e`. `--window-save-state=never` matters: macOS window + restoration otherwise overrides the requested size. +- **Kitty**: `--config NONE` plus `-o initial_window_width/height=c` (the + `c` suffix means cells); the script is passed as trailing args (no `-e`). + `-o remember_window_size=no` matters: it defaults to yes even under + `--config NONE`, and the restored size overrides `initial_window_*`. + +Warp is installed here but not benchmarked: closed source, no CLI to run a +script as the shell, and config isn't file-isolatable. iTerm2/Terminal.app +would need AppleScript driving and can't cleanly isolate config either. + +Grid fairness: tty7 has no size flag, so its default window is the reference — +run tty7 first, and the driver reads the recorded `grid:` line to size the +other terminals identically (cells, not pixels; fonts differ). + +## Why DOOM-fire is patched + +`doom-fire-fps.patch` (applied by `setup.sh`) makes DOOM-fire dump its +cumulative average fps to `$DOOM_FPS_FILE` every 30 frames. The upstream +binary only *paints* the number, and recording the output stream to parse it +back (`script(1)`) is a trap: at 500+ fps the recording grows to gigabytes in +seconds and its disk writes throttle later runs by 4-8×. The fps definition is +unchanged — total frames / elapsed seconds since the fire started, the same +number painted on screen. + +## Recorded baseline (2026-07-03) + +Apple M1 Pro, 32 GB, macOS 26.3.1, grid 155×40, release builds, defaults. +Optimization = the `VecDeque` replay ring + coalesced `Output` frames + +backpressure gate (see CHANGELOG "Terminal throughput ~12× faster"). + +| Test | tty7 (before) | tty7 (after) | Alacritty | Ghostty | Kitty | +| --- | ---: | ---: | ---: | ---: | ---: | +| Plaintext IO, 5-run avg | 2030 ms | **161 ms** | 232 ms | 183 ms | 217 ms | +| DOOM-fire, 5-run avg | 47 fps | **920 fps** | 542 fps | 533 fps | 546 fps | +| Memory (GUI+daemon) | 100 MB | **105 MB** | 86 MB | 112 MB | — | + +Kitty (0.47.4) was measured the same day at the same 155×40 grid; its memory +run was skipped. Upstream's Kitty frame-rate dominance (Linux/Wayland) does +not reproduce on macOS — here it lands in the same band as Alacritty/Ghostty. + +Diagnosis notes for posterity: macOS PTYs deliver ~1 KiB per read. Before the +fix, every read into a full 8 MiB `Vec` ring memmoved the whole ring +(`drain(..overflow)`), eating ~92% of the daemon reader's time — visible as +"run 1 fast, later runs slow" (the ring fills during run 1). `TTY7_TRACE=1` +on both the GUI and a foreground daemon prints the per-second accounting that +localized this. + +## Recorded baseline (2026-07-04, second throughput pass) + +Same machine and grid, all four terminals re-run the same day. Optimization = +the CHANGELOG "second throughput pass" batch (16 MiB gate, 256 KiB socket +buffers, client-side Output batching, memchr OSC fast paths, atomic gate, +QoS promotion). + +All four terminals measured back-to-back in one quiet-machine session: + +| Test | tty7 (before) | tty7 (after) | Alacritty | Ghostty | Kitty | +| --- | ---: | ---: | ---: | ---: | ---: | +| Plaintext IO, 5-run avg | 154 ms | **95 ms** | 239 ms | 179 ms | 185 ms | +| DOOM-fire, 5-run avg | ~760 fps¹ | **888 fps** | 485 fps | 552 fps | 616 fps | +| Memory (GUI+daemon) | 112 MB | 115 MB | 105 MB | 128 MB | 130 MB | + +¹ The tty7-before numbers were measured while the machine was busy (builds + +tracing running alongside); on the later quiet machine the same pre-pass +pipeline would have landed near its 07-03 920 fps. The fire before/after +delta is therefore mostly ambient load, not the optimization — see the notes +below. After the pass, quiet-machine fire runs tightened to 882–894 (±0.7%). + +Notes for interpreting these numbers, learned the hard way: + +- **The day's fps band matters more than the run.** The same pre-pass binary + that recorded 920 fps on 07-03 measured 728–857 on 07-04; competitors + reproduced within ±3%. tty7's fire number is drain-rate-bound and therefore + sensitive to ambient machine load in a way the (slower) competitors aren't. + Only compare tty7-vs-tty7 fire numbers from the same session. +- **DOOM-fire is producer-bound, not terminal-bound, at this level.** Under a + raw do-nothing PTY reader it produces ~96 MB/s at a constant ~87 KB/frame — + i.e. ~1050–1100 fps is the machine's ceiling for *any* terminal, and fps + scales linearly with drain rate (capped-drain probe: 95 MB/s → 1092 fps, + 60 MB/s → 690 fps). tty7's steady seconds already drain at 93–98 MB/s; the + gap to the ceiling is whole seconds where the *producer* gets descheduled. +- **`cat` completion time is a drain benchmark, not a render benchmark.** The + 16 MiB gate lets an 11 MB burst leave the PTY at device speed while the + client parses behind; sustained plaintext drain is 148 MB/s against a + ~170 MB/s raw-reader ceiling (the client's VT parser, ~0.7 core, is the + remaining sustained-throughput limit). +```` + +## File: scripts/bench/run_one.sh +````bash +#!/bin/zsh +# Driver: launch ONE terminal running ONE benchmark script as its shell, wait +# for the "done" marker in the result file, then clean up and print results. +# +# run_one.sh [cols rows] +# +# Grid fairness: run tty7 first — its result file records the grid it opened +# at ("grid: "), and later alacritty/ghostty runs of the same +# test default to that grid (both accept a cell-based window size at launch; +# tty7 has no such flag, so it is the reference). Pass cols/rows explicitly to +# override. +# +# Only processes whose argv references this harness's paths are ever killed — +# a daily-driver tty7/Ghostty/Alacritty running alongside is never touched. +set -u +SELF=${0:A} +HERE=${SELF:h} +REPO=${HERE:h:h} +WORK=${TTY7_BENCH_DIR:-$REPO/.bench} +TTY7_BIN=${TTY7_BIN:-$REPO/target/release/tty7} +TERM_NAME=$1 +TEST=$2 +COLS=${3:-} +ROWS=${4:-} +R=$WORK/results +mkdir -p $R +res=$R/$TEST-$TERM_NAME.txt + +# Default the grid to tty7's recorded one for the same test. +if [ -z "$COLS" ] && [ "$TERM_NAME" != tty7 ] && [ -f $R/$TEST-tty7.txt ]; then + ROWS=$(awk '/^grid:/ {print $2; exit}' $R/$TEST-tty7.txt) + COLS=$(awk '/^grid:/ {print $3; exit}' $R/$TEST-tty7.txt) +fi + +# Stale processes from a previous aborted run (scoped to harness paths). +pkill -f -- "$WORK/cfg-" 2>/dev/null +pkill -f -- "$HERE/io.sh" 2>/dev/null +pkill -f -- "$HERE/fire.sh" 2>/dev/null +sleep 1 +rm -f $res + +case $TERM_NAME in + tty7) + cfg=$WORK/cfg-$TEST-tty7 + rm -rf $cfg && mkdir -p $cfg + printf '{"shell":{"program":"%s","args":["tty7"]}}\n' "$HERE/$TEST.sh" > $cfg/config.json + $TTY7_BIN --config-dir $cfg >/dev/null 2>&1 & + pid=$! + ;; + alacritty) + opts=() + [ -n "$COLS" ] && opts=(-o "window.dimensions.columns=$COLS" -o "window.dimensions.lines=$ROWS") + : > $WORK/alacritty-empty.toml # default config, isolated from the user's + /Applications/Alacritty.app/Contents/MacOS/alacritty \ + --config-file $WORK/alacritty-empty.toml "${opts[@]}" \ + -e $HERE/$TEST.sh alacritty >/dev/null 2>&1 & + pid=$! + ;; + ghostty) + opts=() + [ -n "$COLS" ] && opts=("--window-width=$COLS" "--window-height=$ROWS") + # --window-save-state=never: macOS window restoration otherwise overrides + # the requested size with the last session's. + /Applications/Ghostty.app/Contents/MacOS/ghostty \ + --config-default-files=false --window-save-state=never "${opts[@]}" \ + -e $HERE/$TEST.sh ghostty >/dev/null 2>&1 & + pid=$! + ;; + kitty) + opts=() + # remember_window_size defaults to yes even under --config NONE, and the + # restored size would override initial_window_*; the "c" suffix means cells. + [ -n "$COLS" ] && opts=(-o remember_window_size=no \ + -o "initial_window_width=${COLS}c" -o "initial_window_height=${ROWS}c") + /Applications/kitty.app/Contents/MacOS/kitty \ + --config NONE "${opts[@]}" \ + $HERE/$TEST.sh kitty >/dev/null 2>&1 & + pid=$! + ;; + *) + echo "unknown terminal: $TERM_NAME (want tty7|alacritty|ghostty|kitty)" >&2 + exit 1 + ;; +esac + +# Wait for the in-terminal script to write its "done" marker. +deadline=$((SECONDS + 200)) +while (( SECONDS < deadline )); do + if [ -f $res ] && grep -q '^done' $res; then + break + fi + sleep 2 +done + +kill $pid 2>/dev/null +pkill -f -- "$WORK/cfg-$TEST-tty7" 2>/dev/null # tty7 GUI + its daemon +pkill -f -- "$HERE/$TEST.sh" 2>/dev/null +sleep 1 +pkill -9 -f -- "$WORK/cfg-$TEST-tty7" 2>/dev/null + +echo "=== $res ===" +cat $res 2>/dev/null || echo "(no result file — did the window open and stay visible?)" +```` + +## File: scripts/bench/setup.sh +````bash +#!/bin/zsh +# One-time setup for the terminal benchmark harness (see README.md). +# Fetches everything into the gitignored work dir ($TTY7_BENCH_DIR, default +# /.bench): the 11 MB plaintext corpus, DOOM-fire-zig (patched to dump +# its fps for collection), and a zig 0.14 toolchain if the system one is newer +# (DOOM-fire-zig pins 0.14; the build API changed in 0.15+). macOS only. +set -e +SELF=${0:A} +HERE=${SELF:h} +REPO=${HERE:h:h} +WORK=${TTY7_BENCH_DIR:-$REPO/.bench} +mkdir -p $WORK + +# 1) 11 MB Shakespeare corpus — the plaintext-IO payload from the methodology +# source, moktavizen/terminal-benchmark. +if [ ! -f $WORK/shakespeare.txt ]; then + echo "fetching shakespeare.txt (11 MB)…" + curl -fsSL -o $WORK/shakespeare.txt \ + https://raw.githubusercontent.com/moktavizen/terminal-benchmark/main/test/shakespeare.txt +fi + +# 2) DOOM-fire-zig, with the fps-dump patch applied (doom-fire-fps.patch). +if [ ! -d $WORK/DOOM-fire-zig ]; then + git clone --depth 1 https://github.com/const-void/DOOM-fire-zig.git $WORK/DOOM-fire-zig + git -C $WORK/DOOM-fire-zig apply $HERE/doom-fire-fps.patch +fi + +# 3) zig 0.14.x. Use the system zig when it matches, else download 0.14.1. +ZIG=zig +if ! zig version 2>/dev/null | grep -q '^0\.14'; then + arch=$(uname -m) + [ "$arch" = arm64 ] && arch=aarch64 + zdir=$WORK/zig-$arch-macos-0.14.1 + if [ ! -x $zdir/zig ]; then + echo "downloading zig 0.14.1 ($arch)…" + curl -fsSL https://ziglang.org/download/0.14.1/zig-$arch-macos-0.14.1.tar.xz | tar -xJ -C $WORK + fi + ZIG=$zdir/zig +fi + +# 4) Build the fire. +if [ ! -x $WORK/DOOM-fire-zig/zig-out/bin/DOOM-fire ]; then + (cd $WORK/DOOM-fire-zig && $ZIG build -Doptimize=ReleaseFast) +fi + +echo "bench setup complete: $WORK" +```` + +## File: scripts/fig-convert/.gitignore +```` +# Downloaded Fig spec corpus — fetched on demand via `npm pack`/`npm install` +# (see README). The generated signatures land in ../../assets/completions/ and +# ARE committed. +package/ +*.tgz +node_modules/ +package.json +package-lock.json +```` + +## File: scripts/fig-convert/convert.mjs +````javascript +// Fig autocomplete spec -> tty7 signature JSON converter. +// +// Fig ships hundreds of community-maintained command specs (MIT), authored in +// TypeScript and distributed *pre-compiled* as ESM on npm +// (`@withfig/autocomplete`, `build/.js`). Each module `export default`s a +// spec object. We can't statically parse the TS (specs build options +// programmatically), so we *execute* each compiled module in Node and snapshot +// the resulting object graph — keeping only the static shape (subcommands, +// options, args, descriptions, static generator `script`s) and dropping every +// function (generator `postProcess`/`custom`, `filterTerm`, `generateSpec`, …). +// +// The output schema mirrors tty7's `terminal::completion::spec` serde model +// one-to-one. Runtime JS (postProcess) is intentionally lost: tty7 runs a +// generator's static `script` and falls back to one-suggestion-per-line. +// +// Usage: +// node convert.mjs --build-dir --out git docker ... +// +// Acquire the Fig build dir once with: +// npm pack @withfig/autocomplete && tar xzf withfig-autocomplete-*.tgz +// # -> package/build/ +⋮---- +function parseArgs(argv) +⋮---- +const arr = (x) +const strList = (x) +⋮---- +// Fig's per-entry `icon`: an emoji, a `fig://icon?type=…` template, or a +// `fig://template?color=…&badge=…`. Kept verbatim; the renderer interprets it. +// Omitted (→ absent from JSON) when not a string, so files stay lean. +const iconOf = (x) +⋮---- +function normSuggestion(s) +⋮---- +// Keep only generators whose command is a static string/array. Fig `script` +// may be `["git","branch"]`, `"git branch"`, or a function(tokens) — drop the +// last. `postProcess` (how to parse output) is a fn we always drop; tty7 +// defaults to splitting stdout on newlines. +function normGenerators(g) +⋮---- +function normArg(a) +⋮---- +template: strList(a.template), // "filepaths" | "folders" -> tty7 path completion +⋮---- +function normOption(o) +⋮---- +repeatable: o.isRepeatable === true, // number form (max count) -> treat as single for spike +⋮---- +async function importSpec(buildDir, specName) +⋮---- +if (typeof spec === 'function') return null; // versioned spec (fn) — skip for spike +⋮---- +async function normSubcommand(c, ctx, depth) +⋮---- +// Resolve loadSpec: a string reference to another top-level spec whose +// subcommands/options graft onto this node (e.g. `git flow` -> git-flow). +⋮---- +async function convertCommand(buildDir, name) +⋮---- +// A default export that isn't a spec object (e.g. the `index` re-export +// barrel) normalizes to null — skip it cleanly instead of dereferencing null. +⋮---- +function count(sig) +⋮---- +const walk = (n) => +⋮---- +async function main() +⋮---- +// Isolate each command: a spec that throws (a versioned function-default +// export, a bad import, a malformed graph) must not abort the batch and +// silently strand every command after it — a single crash near 'i' is +// exactly what once left kubectl/npm/node/python unconverted. +⋮---- +const json = JSON.stringify(sig); // minified — generated data, not hand-edited +```` + +## File: scripts/fig-convert/README.md +````markdown +# fig-convert — Fig autocomplete specs → tty7 completion signatures + +tty7's per-command completion (flags/subcommands/args with descriptions) is +driven by signature data generated from [Fig's autocomplete spec corpus][fig] +(MIT-licensed, community-maintained, hundreds of commands). This is the +**build-time converter**; the runtime consumer is `src/terminal/signature.rs`. + +## Why a converter (and not the JS engine) + +Fig specs are authored in TypeScript and shipped **pre-compiled** as ESM on npm. +Each spec `export default`s an object whose *static* shape (subcommands, options, +args, descriptions, static generator `script`s) is exactly what a completion +menu needs. The only truly dynamic parts are functions — generator `postProcess`, +`custom`, `filterTerm`, `generateSpec` — which need a JS runtime to evaluate. + +We convert ahead of time: run each spec once, snapshot the +static shape to JSON, and drop the functions. **No JS engine at runtime.** (An +embedded QuickJS evaluating the functions live would be the alternative; we don't need it.) Dynamic +value completion (`git checkout `) is recovered later by running a +generator's static `script` and splitting stdout on newlines — the `script`s are +already captured; only the executor is future work. + +## Regenerate + +```bash +# 1. Fetch the compiled spec corpus once (into this dir, gitignored). +npm pack @withfig/autocomplete +tar xzf withfig-autocomplete-*.tgz # -> package/build/.js + +# 2. Convert the commands tty7 embeds into assets/completions/. +node convert.mjs --build-dir package/build --out ../../assets/completions git docker +``` + +`convert.mjs` executes each `.js`, walks the object graph keeping only +static fields, resolves `loadSpec` references (e.g. `docker compose` grafts in +the `docker-compose` spec), and writes minified `assets/completions/.json`. +Each command is converted in isolation: one spec that throws is reported (`✗`) +and skipped, never aborting the batch — of Fig's ~716 specs, 715 convert and +only a non-command `index` barrel is skipped. Pass as many command names as you +like in one run. + +## Output schema + +Mirrors the serde model in `src/terminal/signature.rs` one-to-one: + +- root: `{ name, description, options[], args[], subcommands[] }` +- subcommand: `{ names[], description, hidden, options[], args[], subcommands[] }` +- option: `{ names[], description, args[], required, repeatable, hidden }` +- arg: `{ name, description, optional, variadic, template[], suggestions[], generators[] }` +- suggestion: `{ names[], description }` +- generator: `{ script[] }` (static shell command only; `postProcess` dropped) + +## Scope & rollout + +Signatures are **read from disk**, not embedded: `signature::spec_source` resolves +a `completions/` directory (inside the macOS `.app` bundle's `Resources`, beside +the executable on Linux/Windows, or the in-tree `assets/completions` for +`cargo run`/tests) and lazily loads `.json` by command name. So adding a +command is just dropping its JSON into `assets/completions/` — the packaging +scripts copy the whole directory into each bundle, and no recompile or binary +bloat is involved. A big spec is ~300–500 KiB; the full corpus is ~700 commands, +so ship whatever subset you want rather than all of it if bundle size matters. + +The in-tree set is ~95 everyday commands (version control, container/k8s, +language package managers, cloud CLIs, shell/sysadmin tools) — ~5.5 MB, curated +from the corpus. The two outliers `aws` (~51 MB) and `gcloud` (~18 MB) are +deliberately excluded; add them only if you also trim them. To add a command, +run `convert.mjs` with its name (step 2 above) and commit the resulting JSON. + +[fig]: https://github.com/withfig/autocomplete +```` + +## File: src/core/osc.rs +````rust +//! Streaming OSC (Operating System Command) extractor. +//! +⋮---- +//! +//! The one implementation of OSC wire framing, shared by both byte-stream +⋮---- +//! The one implementation of OSC wire framing, shared by both byte-stream +//! consumers: the daemon-side cwd/prompt sniffer (`daemon::pane`, OSC 7/133) +⋮---- +//! consumers: the daemon-side cwd/prompt sniffer (`daemon::pane`, OSC 7/133) +//! and the client-side notification scanner (`terminal::remote`, OSC 9/777). +⋮---- +//! and the client-side notification scanner (`terminal::remote`, OSC 9/777). +//! The framing rules — `ESC ]` opens, `BEL` or `ESC \` (ST) terminates, a bare +⋮---- +//! The framing rules — `ESC ]` opens, `BEL` or `ESC \` (ST) terminates, a bare +//! `ESC ]` inside an unterminated sequence re-opens a fresh one, oversized +⋮---- +//! `ESC ]` inside an unterminated sequence re-opens a fresh one, oversized +//! payloads are abandoned — are subtle enough that both sites needed the same +⋮---- +//! payloads are abandoned — are subtle enough that both sites needed the same +//! resync bugfix when each carried its own copy. Keeping the state machine +⋮---- +//! resync bugfix when each carried its own copy. Keeping the state machine +//! here means a framing change can't silently apply to one consumer and not +⋮---- +//! here means a framing change can't silently apply to one consumer and not +//! the other. +⋮---- +//! the other. +//! +⋮---- +//! +//! This is deliberately *not* a full VT parser (the grid has an +⋮---- +//! This is deliberately *not* a full VT parser (the grid has an +//! `ansi::Processor` for that). It tracks just enough state to hand complete +⋮---- +//! `ansi::Processor` for that). It tracks just enough state to hand complete +//! payloads of the OSC identifiers a consumer cares about to its callback, +⋮---- +//! payloads of the OSC identifiers a consumer cares about to its callback, +//! bailing out cheaply on any other OSC (e.g. a multi-megabyte OSC 52 +⋮---- +//! bailing out cheaply on any other OSC (e.g. a multi-megabyte OSC 52 +//! clipboard write) without buffering it. +⋮---- +//! clipboard write) without buffering it. +/// Cap on how many bytes of a single OSC payload we'll buffer before giving up +/// on it — a guard against an unterminated or absurdly long sequence growing +⋮---- +/// on it — a guard against an unterminated or absurdly long sequence growing +/// the buffer without bound. Real cwd/prompt/notification payloads are far +⋮---- +/// the buffer without bound. Real cwd/prompt/notification payloads are far +/// shorter. +⋮---- +/// shorter. +const MAX_PAYLOAD: usize = 8192; +⋮---- +/// A streaming tokenizer for the OSC sequences whose identifiers are listed in +/// `ids`. Feed it raw output bytes; it invokes a callback with each complete +⋮---- +/// `ids`. Feed it raw output bytes; it invokes a callback with each complete +/// payload. State persists across `feed` calls, so a sequence split over +⋮---- +/// payload. State persists across `feed` calls, so a sequence split over +/// multiple reads is still recognized. +⋮---- +/// multiple reads is still recognized. +pub struct OscTokenizer { +⋮---- +pub struct OscTokenizer { +/// OSC identifiers (the digits before the first `;`) the consumer wants + /// buffered and delivered; every other OSC is discarded unbuffered. +⋮---- +/// buffered and delivered; every other OSC is discarded unbuffered. + ids: &'static [&'static [u8]], +/// Payload bytes accumulated after `ESC ]` while the identifier can still + /// match `ids`. Cleared whenever a sequence finishes or is abandoned. +⋮---- +/// match `ids`. Cleared whenever a sequence finishes or is abandoned. + buf: Vec, +⋮---- +enum State { +/// Not inside an escape sequence. + #[default] +⋮---- +/// Saw `ESC` in ground state; a following `]` opens an OSC. + Esc, +/// Inside an OSC whose identifier still matches (a prefix of) `ids`; + /// buffering the payload. +⋮---- +/// buffering the payload. + Osc, +/// Saw `ESC` while buffering an OSC — a following `\` is the `ST` terminator. + OscEsc, +/// Inside an OSC we've decided to ignore; discard bytes until the terminator. + Ignore, +/// Saw `ESC` while ignoring an OSC — a following `\` is the `ST` terminator. + IgnoreEsc, +⋮---- +impl OscTokenizer { +pub fn new(ids: &'static [&'static [u8]]) -> Self { +⋮---- +/// Feed one chunk of output; invoke `on_payload` with the complete payload + /// (identifier included, terminator excluded — e.g. `7;file://…`) of every +⋮---- +/// (identifier included, terminator excluded — e.g. `7;file://…`) of every + /// interesting OSC that completes within the chunk. +⋮---- +/// interesting OSC that completes within the chunk. + /// +⋮---- +/// + /// The tokenizer sits on the full-throughput output stream (both the +⋮---- +/// The tokenizer sits on the full-throughput output stream (both the + /// daemon's PTY reader and the client's socket reader run it over every +⋮---- +/// daemon's PTY reader and the client's socket reader run it over every + /// byte), so the two states that dominate real streams — `Ground` between +⋮---- +/// byte), so the two states that dominate real streams — `Ground` between + /// sequences, `Ignore` inside a discarded OSC (e.g. a multi-MB OSC 52) — +⋮---- +/// sequences, `Ignore` inside a discarded OSC (e.g. a multi-MB OSC 52) — + /// skip ahead with SIMD `memchr` instead of stepping per byte. Everything +⋮---- +/// skip ahead with SIMD `memchr` instead of stepping per byte. Everything + /// else is rare enough to stay a plain per-byte state machine. +⋮---- +/// else is rare enough to stay a plain per-byte state machine. + pub fn feed(&mut self, bytes: &[u8], mut on_payload: impl FnMut(&[u8])) { +⋮---- +pub fn feed(&mut self, bytes: &[u8], mut on_payload: impl FnMut(&[u8])) { +⋮---- +while i < bytes.len() { +⋮---- +// Nothing before the next ESC can matter. +⋮---- +// Only BEL (terminates) or ESC (may terminate or fork) can +// end a discarded payload. +⋮---- +// Handled by the skip-ahead arms above. +State::Ground | State::Ignore => unreachable!(), +⋮---- +self.buf.clear(); +⋮---- +0x1b => {} // a run of ESCs; keep waiting for the next byte +⋮---- +0x07 => self.finish(&mut on_payload), // BEL terminator +⋮---- +self.buf.push(b); +// Abandon as soon as the identifier can't be one of +// `ids`, or the payload grows unreasonably large. +if self.buf.len() > MAX_PAYLOAD || !self.identifier_could_match() { +⋮---- +b'\\' => self.finish(&mut on_payload), // ST terminator +0x1b => {} // another ESC: stay poised for the `\` +// The ESC began a *new* OSC, aborting this unterminated one. +// Re-open a fresh OSC instead of dropping the `]` into +// Ground — otherwise a well-formed sequence directly +// following an unterminated one would be silently lost. +⋮---- +// ESC began some other (non-OSC) escape: abandon this OSC. +⋮---- +0x1b => {} // stay, another ESC +// Same resync as `OscEsc`: the ESC opened a new OSC — scan +// it rather than missing the sequence that follows an +// unterminated, ignored one (e.g. a title OSC). +⋮---- +/// Whether the identifier accumulated so far can still become one of `ids`. + /// Before the first `;` it is a prefix being built up; once the `;` arrives +⋮---- +/// Before the first `;` it is a prefix being built up; once the `;` arrives + /// it must match exactly. +⋮---- +/// it must match exactly. + fn identifier_could_match(&self) -> bool { +⋮---- +fn identifier_could_match(&self) -> bool { +match self.buf.iter().position(|&b| b == b';') { +Some(pos) => self.ids.iter().any(|&id| id == &self.buf[..pos]), +None => self.ids.iter().any(|id| id.starts_with(&self.buf)), +⋮---- +/// A complete, interesting OSC payload arrived: hand it to the consumer. + fn finish(&mut self, on_payload: &mut impl FnMut(&[u8])) { +⋮---- +fn finish(&mut self, on_payload: &mut impl FnMut(&[u8])) { +on_payload(&self.buf); +⋮---- +mod tests { +⋮---- +/// Run a tokenizer for `ids` over the chunks and collect delivered payloads. + fn collect(ids: &'static [&'static [u8]], chunks: &[&[u8]]) -> Vec> { +⋮---- +fn collect(ids: &'static [&'static [u8]], chunks: &[&[u8]]) -> Vec> { +⋮---- +tok.feed(c, |payload| out.push(payload.to_vec())); +⋮---- +fn bel_and_st_terminators_both_complete_a_payload() { +assert_eq!( +⋮---- +fn sequence_split_across_reads_is_reassembled() { +// Torn mid-payload and between the ESC and its ST backslash. +⋮---- +fn uninteresting_identifiers_are_skipped_and_state_recovers() { +// OSC 0 (title) and OSC 52 (clipboard) are not in `ids`: nothing is +// delivered, and an interesting OSC right after is still caught. +⋮---- +fn resyncs_on_new_osc_after_an_unterminated_one() { +// Regression (fixed independently in both pre-extraction copies): the +// ESC that aborts an unterminated OSC may itself open the next one; the +// `]` must re-open a fresh OSC rather than fall into Ground. Covers +// both the buffering path and the ignore path. +⋮---- +fn identifier_prefix_matching_buffers_only_possible_ids() { +// `77` is a prefix of `777` but `78` can no longer match: only the +// former's completed sequence is delivered. +⋮---- +// After the `;` the identifier must match exactly: `77;` is not `777`. +assert_eq!(collect(ids, &[b"\x1b]77;x\x07"]), Vec::>::new()); +⋮---- +fn oversized_payload_is_abandoned_not_truncated() { +// A payload past the cap is dropped entirely (delivering a truncated +// cwd or notification would be worse than delivering none), and the +// stream recovers for the next sequence. +let mut big = b"\x1b]9;".to_vec(); +big.extend(std::iter::repeat_n(b'x', MAX_PAYLOAD + 1)); +big.extend_from_slice(b"\x07\x1b]9;next\x07"); +assert_eq!(collect(&[b"9"], &[&big]), vec![b"9;next".to_vec()]); +⋮---- +fn byte_at_a_time_delivery_reassembles_every_state_transition() { +// The harshest tearing: one byte per `feed` call, crossing every state +// boundary (ESC/], identifier, payload, ESC/\ terminator) between reads. +⋮---- +let chunks: Vec<&[u8]> = stream.chunks(1).collect(); +⋮---- +fn ignored_sequence_split_across_reads_still_recovers() { +// An uninteresting OSC torn across chunks must keep being discarded +// (state persists across `feed`s), and the next interesting one lands. +⋮---- +fn esc_runs_and_non_osc_escapes_do_not_confuse_the_scanner() { +// ESC ESC ] still opens an OSC (the last ESC wins). +⋮---- +// An ESC inside an OSC followed by a non-OSC escape abandons cleanly. +```` + +## File: src/core/threads.rs +````rust +//! Thread-scheduling helpers shared by the daemon and the GUI client. +/// Ask the OS to schedule the calling thread at user-interactive QoS. +/// +⋮---- +/// +/// macOS assigns unclassified threads a default QoS the scheduler is free to +⋮---- +/// macOS assigns unclassified threads a default QoS the scheduler is free to +/// park on efficiency cores under load. Measured on an M1 Pro mid-benchmark: +⋮---- +/// park on efficiency cores under load. Measured on an M1 Pro mid-benchmark: +/// whole seconds where the PTY drain drops from ~96 MB/s to 50–70 MB/s — an +⋮---- +/// whole seconds where the PTY drain drops from ~96 MB/s to 50–70 MB/s — an +/// E-core's pace — then recovers. The threads on the interactive output path +⋮---- +/// E-core's pace — then recovers. The threads on the interactive output path +/// (daemon PTY reader, connection writer/reader, client socket reader) carry +⋮---- +/// (daemon PTY reader, connection writer/reader, client socket reader) carry +/// keystroke echo and the visible output stream, which is exactly the workload +⋮---- +/// keystroke echo and the visible output stream, which is exactly the workload +/// `QOS_CLASS_USER_INTERACTIVE` names. Best effort; a refused hint just keeps +⋮---- +/// `QOS_CLASS_USER_INTERACTIVE` names. Best effort; a refused hint just keeps +/// the default class. No-op elsewhere: Linux/Windows schedulers don't demote +⋮---- +/// the default class. No-op elsewhere: Linux/Windows schedulers don't demote +/// by QoS class. +⋮---- +/// by QoS class. +pub fn promote_to_user_interactive() { +⋮---- +pub fn promote_to_user_interactive() { +// Escape hatch for benchmarking the promotion itself (and for users whose +// workload fares better under default scheduling): any non-empty value +// other than "0" disables it. +if std::env::var("TTY7_NO_QOS").is_ok_and(|v| !v.is_empty() && v != "0") { +⋮---- +// SAFETY: a plain scheduling hint for the current thread; no pointers, no +// preconditions. +```` + +## File: src/daemon/pidfile.rs +````rust +//! The daemon's pid marker: `/daemon.pid`, written after a successful +//! `bind` and removed on shutdown. +⋮---- +//! `bind` and removed on shutdown. +//! +⋮---- +//! +//! The endpoint marker (socket / port file) answers "is something listening +⋮---- +//! The endpoint marker (socket / port file) answers "is something listening +//! *here*?", but says nothing about *which process* — and that gap is exactly +⋮---- +//! *here*?", but says nothing about *which process* — and that gap is exactly +//! how daemons got stranded (see the takeover paths in `spawn`): a client that +⋮---- +//! how daemons got stranded (see the takeover paths in `spawn`): a client that +//! couldn't talk to the old daemon would unlink its endpoint and start a fresh +⋮---- +//! couldn't talk to the old daemon would unlink its endpoint and start a fresh +//! one, leaving the old process alive, unreachable, and still holding every +⋮---- +//! one, leaving the old process alive, unreachable, and still holding every +//! pane's PTY + children. The pidfile closes the gap: takeover paths read it +⋮---- +//! pane's PTY + children. The pidfile closes the gap: takeover paths read it +//! and reap the recorded process before claiming the endpoint. +⋮---- +//! and reap the recorded process before claiming the endpoint. +//! +⋮---- +//! +//! A pidfile can outlive its daemon (crash, SIGKILL), and pids get recycled — +⋮---- +//! A pidfile can outlive its daemon (crash, SIGKILL), and pids get recycled — +//! so readers must never trust it blindly. `spawn::reap_recorded_daemon` +⋮---- +//! so readers must never trust it blindly. `spawn::reap_recorded_daemon` +//! verifies the pid's executable basename matches our own before signalling. +⋮---- +//! verifies the pid's executable basename matches our own before signalling. +use std::path::PathBuf; +⋮---- +use crate::core::config; +⋮---- +/// Path of the pidfile for this process's config dir. `None` only when the +/// config dir can't be resolved (no `$HOME`). +⋮---- +/// config dir can't be resolved (no `$HOME`). +pub fn path() -> Option { +⋮---- +pub fn path() -> Option { +⋮---- +/// Record the current process as the daemon serving this config dir. Best +/// effort: the pidfile is a rescue marker, not a correctness requirement, so a +⋮---- +/// effort: the pidfile is a rescue marker, not a correctness requirement, so a +/// failed write must not take the daemon down — it just means a future +⋮---- +/// failed write must not take the daemon down — it just means a future +/// takeover can't reap us and falls back to today's behavior. +⋮---- +/// takeover can't reap us and falls back to today's behavior. +pub fn write_current() { +⋮---- +pub fn write_current() { +let Some(path) = path() else { return }; +if let Some(parent) = path.parent() { +⋮---- +if let Err(e) = std::fs::write(&path, std::process::id().to_string()) { +⋮---- +/// The recorded daemon pid, if the pidfile exists and parses. Says nothing +/// about whether that process is still alive or still a tty7 daemon. +⋮---- +/// about whether that process is still alive or still a tty7 daemon. +pub fn read() -> Option { +⋮---- +pub fn read() -> Option { +let contents = std::fs::read_to_string(path()?).ok()?; +contents.trim().parse::().ok() +⋮---- +/// Remove the pidfile. Best effort: a missing file is fine. +pub fn remove() { +⋮---- +pub fn remove() { +if let Some(path) = path() { +⋮---- +mod tests { +⋮---- +/// Pin the process config dir so the pidfile lives under a temp dir, never + /// the real `~/.config`. First-call-wins across the whole test binary, so +⋮---- +/// the real `~/.config`. First-call-wins across the whole test binary, so + /// use the same directory the other IO tests pin. +⋮---- +/// use the same directory the other IO tests pin. + fn pin_config_dir() { +⋮---- +fn pin_config_dir() { +let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); +std::fs::create_dir_all(&dir).ok(); +⋮---- +/// One test drives the whole lifecycle — write → read → remove → reject + /// garbage — so the shared `daemon.pid` file isn't raced by parallel tests +⋮---- +/// garbage — so the shared `daemon.pid` file isn't raced by parallel tests + /// (same reason transport's endpoint test is a single lifecycle). +⋮---- +/// (same reason transport's endpoint test is a single lifecycle). + #[test] +fn pidfile_lifecycle_round_trips_clears_and_rejects_garbage() { +pin_config_dir(); +write_current(); +assert_eq!(read(), Some(std::process::id())); +remove(); +assert_eq!(read(), None, "no pid after removal"); +// Removing again is harmless. +⋮---- +// A corrupt file (partial write, hand-edited) must read as "no pid", +// never panic or misparse. +std::fs::write(path().unwrap(), "not-a-pid\n").unwrap(); +assert_eq!(read(), None); +```` + +## File: src/daemon/transport.rs +````rust +//! Cross-platform IPC transport for the GUI ⇄ daemon connection. +//! +⋮---- +//! +//! The daemon and the GUI talk over a local, machine-private byte stream. Which +⋮---- +//! The daemon and the GUI talk over a local, machine-private byte stream. Which +//! kind of stream depends on the platform, but both sides only ever see a type +⋮---- +//! kind of stream depends on the platform, but both sides only ever see a type +//! that is `Read + Write + try_clone` — so `server`, `spawn`, and +⋮---- +//! that is `Read + Write + try_clone` — so `server`, `spawn`, and +//! `terminal::remote` share one code path and never mention the concrete type. +⋮---- +//! `terminal::remote` share one code path and never mention the concrete type. +//! +⋮---- +//! +//! - **Unix**: a Unix-domain socket at `/daemon.sock`. This is the +⋮---- +//! - **Unix**: a Unix-domain socket at `/daemon.sock`. This is the +//! original design, kept verbatim — the socket file's presence on disk doubles +⋮---- +//! original design, kept verbatim — the socket file's presence on disk doubles +//! as the "is a daemon here?" marker, and `bind` recreates it. +⋮---- +//! as the "is a daemon here?" marker, and `bind` recreates it. +//! - **Windows**: a loopback `TcpListener` on `127.0.0.1:` (an OS-assigned +⋮---- +//! - **Windows**: a loopback `TcpListener` on `127.0.0.1:` (an OS-assigned +//! ephemeral port). Windows has no first-class Unix sockets, and the +⋮---- +//! ephemeral port). Windows has no first-class Unix sockets, and the +//! `interprocess` named-pipe route can't cleanly `try_clone` a blocking duplex +⋮---- +//! `interprocess` named-pipe route can't cleanly `try_clone` a blocking duplex +//! handle, which our thread-per-connection model needs. Loopback TCP has the +⋮---- +//! handle, which our thread-per-connection model needs. Loopback TCP has the +//! exact `try_clone` + blocking semantics of `UnixStream`, so the rest of the +⋮---- +//! exact `try_clone` + blocking semantics of `UnixStream`, so the rest of the +//! daemon is unchanged. The chosen port is written to `/daemon.port` +⋮---- +//! daemon is unchanged. The chosen port is written to `/daemon.port` +//! so the GUI can find a daemon it didn't spawn; that file is the Windows +⋮---- +//! so the GUI can find a daemon it didn't spawn; that file is the Windows +//! analogue of the socket file (its presence is the "endpoint exists" marker). +⋮---- +//! analogue of the socket file (its presence is the "endpoint exists" marker). +//! Loopback is reachable by *any* local process, not just the same user — so, +⋮---- +//! Loopback is reachable by *any* local process, not just the same user — so, +//! unlike a Unix socket, the port alone isn't an access boundary. The daemon +⋮---- +//! unlike a Unix socket, the port alone isn't an access boundary. The daemon +//! closes that gap with a token: `bind` writes a random 256-bit token into the +⋮---- +//! closes that gap with a token: `bind` writes a random 256-bit token into the +//! (user-private) port file, `connect` presents it as a preamble, and +⋮---- +//! (user-private) port file, `connect` presents it as a preamble, and +//! `authenticate` rejects any connection that doesn't match — so only a process +⋮---- +//! `authenticate` rejects any connection that doesn't match — so only a process +//! that could read the user-private file gets in. See [`imp_windows`]. +⋮---- +//! that could read the user-private file gets in. See [`imp_windows`]. +//! +⋮---- +//! +//! All endpoint state lives under the (config-dir-aware) config directory, so +⋮---- +//! All endpoint state lives under the (config-dir-aware) config directory, so +//! `--config-dir` / `cargo dev` isolation reaches the daemon on every platform. +⋮---- +//! `--config-dir` / `cargo dev` isolation reaches the daemon on every platform. +use std::io; +⋮---- +use crate::core::config; +⋮---- +mod imp_unix { +⋮---- +/// The connection stream both sides read/write framed messages over. + pub type Stream = UnixStream; +⋮---- +pub type Stream = UnixStream; +/// The daemon's accept side. + pub type Listener = UnixListener; +⋮---- +pub type Listener = UnixListener; +⋮---- +/// `sockaddr_un.sun_path` caps socket paths at 104 bytes on macOS (108 on + /// Linux), NUL included — `bind`/`connect` reject anything longer, so stay +⋮---- +/// Linux), NUL included — `bind`/`connect` reject anything longer, so stay + /// safely below the smaller limit. +⋮---- +/// safely below the smaller limit. + pub(super) const MAX_SOCKET_PATH_BYTES: usize = 100; +⋮---- +/// Deterministic 64-bit FNV-1a. Not `DefaultHasher`: the GUI and the daemon + /// can be different builds of tty7 (daemon survives app upgrades), so the +⋮---- +/// can be different builds of tty7 (daemon survives app upgrades), so the + /// fallback socket path must hash identically across compiler/std versions +⋮---- +/// fallback socket path must hash identically across compiler/std versions + /// or an upgraded GUI would lose a live daemon. +⋮---- +/// or an upgraded GUI would lose a live daemon. + fn fnv1a64(bytes: &[u8]) -> u64 { +⋮---- +fn fnv1a64(bytes: &[u8]) -> u64 { +⋮---- +h = h.wrapping_mul(0x100_0000_01b3); +⋮---- +/// The socket path serving `config_dir`: `/daemon.sock` whenever + /// that fits in `sun_path`, else a short per-user path keyed by a stable +⋮---- +/// that fits in `sun_path`, else a short per-user path keyed by a stable + /// hash of the config dir. Without the fallback, a long `--config-dir` made +⋮---- +/// hash of the config dir. Without the fallback, a long `--config-dir` made + /// bind/connect fail with "path must be shorter than SUN_LEN" and the GUI +⋮---- +/// bind/connect fail with "path must be shorter than SUN_LEN" and the GUI + /// died at startup. Distinct config dirs still get distinct daemons (the +⋮---- +/// died at startup. Distinct config dirs still get distinct daemons (the + /// hash keys the endpoint), and both processes derive the same path because +⋮---- +/// hash keys the endpoint), and both processes derive the same path because + /// the GUI forwards its *resolved* config dir to the daemon it spawns. +⋮---- +/// the GUI forwards its *resolved* config dir to the daemon it spawns. + pub(super) fn socket_path_for(config_dir: &Path) -> PathBuf { +⋮---- +pub(super) fn socket_path_for(config_dir: &Path) -> PathBuf { +⋮---- +let inline = config_dir.join("daemon.sock"); +if inline.as_os_str().as_bytes().len() <= MAX_SOCKET_PATH_BYTES { +⋮---- +// Prefer $XDG_RUNTIME_DIR (user-private, 0700 — the norm on Linux); +// otherwise the OS temp dir, which is per-user on macOS. +⋮---- +.filter(|d| !d.is_empty()) +.map(PathBuf::from) +.unwrap_or_else(std::env::temp_dir); +let hash = fnv1a64(config_dir.as_os_str().as_bytes()); +base.join(format!("tty7-{hash:016x}.sock")) +⋮---- +/// Path of the Unix-domain socket for this process's config dir. `None` only + /// when the config dir can't be resolved (no `$HOME`). +⋮---- +/// when the config dir can't be resolved (no `$HOME`). + fn socket_path() -> Option { +⋮---- +fn socket_path() -> Option { +Some(socket_path_for(&config::config_dir_path()?)) +⋮---- +/// Try to connect to the daemon. `Err` means "nobody home" (the caller treats + /// any error as "not running"). +⋮---- +/// any error as "not running"). + pub fn connect() -> io::Result { +⋮---- +pub fn connect() -> io::Result { +let path = socket_path().ok_or_else(|| { +⋮---- +tune(&stream); +Ok(stream) +⋮---- +/// Grow the kernel socket buffers to match the daemon writer's 256 KiB + /// coalesced Output frames. macOS defaults Unix-socket buffers to 8 KiB, +⋮---- +/// coalesced Output frames. macOS defaults Unix-socket buffers to 8 KiB, + /// which chops a full-drain stream (100+ MB/s) into ~8 KiB reads — tens of +⋮---- +/// which chops a full-drain stream (100+ MB/s) into ~8 KiB reads — tens of + /// thousands of extra syscalls and cross-process wakeups per second, and a +⋮---- +/// thousands of extra syscalls and cross-process wakeups per second, and a + /// stall point the PTY reader's backpressure gate then amplifies. Best +⋮---- +/// stall point the PTY reader's backpressure gate then amplifies. Best + /// effort: a refused size just keeps the platform default. +⋮---- +/// effort: a refused size just keeps the platform default. + pub fn tune(stream: &Stream) { +⋮---- +pub fn tune(stream: &Stream) { +⋮---- +// SAFETY: plain setsockopt on a valid owned fd with a c_int payload. +⋮---- +stream.as_raw_fd(), +⋮---- +(&raw const size).cast(), +⋮---- +/// Daemon-side connection authentication — a no-op on Unix. The socket lives in + /// the user-private config dir (or `$XDG_RUNTIME_DIR`, 0700), so filesystem +⋮---- +/// the user-private config dir (or `$XDG_RUNTIME_DIR`, 0700), so filesystem + /// permissions already restrict `connect` to the same user; there's nothing to +⋮---- +/// permissions already restrict `connect` to the same user; there's nothing to + /// verify. Mirrors the Windows signature so `server` calls it unconditionally. +⋮---- +/// verify. Mirrors the Windows signature so `server` calls it unconditionally. + #[inline] +pub fn authenticate(_stream: &mut Stream) -> io::Result<()> { +Ok(()) +⋮---- +/// Whether the endpoint marker exists on disk (a live *or* stale socket file). + pub fn endpoint_exists() -> bool { +⋮---- +pub fn endpoint_exists() -> bool { +socket_path().is_some_and(|p| p.exists()) +⋮---- +/// Remove a stale endpoint marker so a fresh `bind` can recreate it. Best + /// effort: a missing file is fine. +⋮---- +/// effort: a missing file is fine. + pub fn remove_stale_endpoint() { +⋮---- +pub fn remove_stale_endpoint() { +if let Some(path) = socket_path() { +⋮---- +/// Bind the listener (daemon side). Ensures the config dir exists first; the + /// caller is responsible for having cleared any stale endpoint. +⋮---- +/// caller is responsible for having cleared any stale endpoint. + pub fn bind() -> anyhow::Result { +⋮---- +pub fn bind() -> anyhow::Result { +⋮---- +if let Some(parent) = path.parent() { +⋮---- +.map_err(|e| anyhow::anyhow!("bind {} failed: {}", path.display(), e))?; +Ok(listener) +⋮---- +/// A human-readable description of the endpoint, for log messages. + pub fn endpoint_display() -> String { +⋮---- +pub fn endpoint_display() -> String { +socket_path() +.map(|p| p.display().to_string()) +.unwrap_or_else(|| "".to_string()) +⋮---- +mod tests { +⋮---- +/// Pin the process config dir so the socket lives under a temp dir, never the + /// real `~/.config`. First-call-wins; every IO test computes the same path. +⋮---- +/// real `~/.config`. First-call-wins; every IO test computes the same path. + fn pin_config_dir() { +⋮---- +fn pin_config_dir() { +let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); +std::fs::create_dir_all(&dir).ok(); +⋮---- +/// One test drives the whole endpoint lifecycle so the shared `daemon.sock` + /// file isn't raced by parallel tests: clean → bind → exists/connect → remove. +⋮---- +/// file isn't raced by parallel tests: clean → bind → exists/connect → remove. + #[test] +fn endpoint_lifecycle_bind_connect_and_clear() { +pin_config_dir(); +// Start from a clean slate (a prior run may have left a stale socket). +remove_stale_endpoint(); +assert!(!endpoint_exists(), "no endpoint before bind"); +⋮---- +let listener = bind().expect("bind should succeed under the temp config dir"); +assert!(endpoint_exists(), "the socket file marks the endpoint"); +assert!( +⋮---- +// A client can connect while the listener is alive. +let _client = connect().expect("connect to the live listener"); +⋮---- +drop(listener); +// The socket file lingers after the listener drops; clearing it makes the +// endpoint look absent again (the stale-takeover path in `run`). +⋮---- +assert!(!endpoint_exists(), "endpoint cleared after removal"); +⋮---- +/// A short config dir keeps the original `/daemon.sock` layout — + /// existing daemons must stay reachable across this change. +⋮---- +/// existing daemons must stay reachable across this change. + #[test] +fn socket_path_stays_in_config_dir_when_it_fits() { +⋮---- +assert_eq!(imp_unix::socket_path_for(&dir), dir.join("daemon.sock")); +⋮---- +/// An overlong config dir (the SUN_LEN panic regression) falls back to a + /// short path that is deterministic and still keyed to the config dir. +⋮---- +/// short path that is deterministic and still keyed to the config dir. + #[test] +fn socket_path_falls_back_when_config_dir_is_too_long() { +⋮---- +let long_a = std::path::PathBuf::from(format!("/tmp/{}", "a".repeat(150))); +let long_b = std::path::PathBuf::from(format!("/tmp/{}", "b".repeat(150))); +⋮---- +assert_eq!( +⋮---- +assert_ne!( +⋮---- +/// End-to-end on the OS: the fallback path actually binds and accepts a + /// connection (this is exactly what failed with SUN_LEN before). +⋮---- +/// connection (this is exactly what failed with SUN_LEN before). + #[test] +fn fallback_socket_binds_and_connects() { +⋮---- +// Pid-keyed so concurrent `cargo test` processes don't share a path. +⋮---- +std::env::temp_dir().join(format!("{}-{}", "x".repeat(120), std::process::id())); +⋮---- +let listener = UnixListener::bind(&path).expect("bind fallback socket"); +let _client = UnixStream::connect(&path).expect("connect fallback socket"); +⋮---- +mod imp_windows { +⋮---- +use std::path::PathBuf; +use std::sync::OnceLock; +⋮---- +/// The connection stream both sides read/write framed messages over. + pub type Stream = TcpStream; +⋮---- +pub type Stream = TcpStream; +/// The daemon's accept side. + pub type Listener = TcpListener; +⋮---- +pub type Listener = TcpListener; +⋮---- +/// Length of the per-daemon auth token, in bytes. 256 bits from the OS CSPRNG: + /// unguessable without reading the (user-private) port file, so possessing it +⋮---- +/// unguessable without reading the (user-private) port file, so possessing it + /// proves the connecting process runs as the same user. +⋮---- +/// proves the connecting process runs as the same user. + const TOKEN_LEN: usize = 32; +type Token = [u8; TOKEN_LEN]; +⋮---- +/// This daemon's auth token, minted once at [`bind`] and checked by + /// [`authenticate`] on every accepted connection. A process global because the +⋮---- +/// [`authenticate`] on every accepted connection. A process global because the + /// listener and the per-connection auth check live in the same daemon process +⋮---- +/// listener and the per-connection auth check live in the same daemon process + /// but don't share a handle; the client learns the token from the port file +⋮---- +/// but don't share a handle; the client learns the token from the port file + /// instead. Set exactly once per daemon lifetime. +⋮---- +/// instead. Set exactly once per daemon lifetime. + static DAEMON_TOKEN: OnceLock = OnceLock::new(); +⋮---- +/// Mint a fresh 256-bit token from the OS CSPRNG. Panics only if the OS RNG is + /// unavailable, which on Windows means the system is too broken to run. +⋮---- +/// unavailable, which on Windows means the system is too broken to run. + fn make_token() -> Token { +⋮---- +fn make_token() -> Token { +⋮---- +getrandom::fill(&mut token).expect("OS RNG (BCryptGenRandom) unavailable"); +⋮---- +/// Lowercase-hex encode a token for the (text) port file. + fn encode_token(token: &Token) -> String { +⋮---- +fn encode_token(token: &Token) -> String { +⋮---- +s.push(char::from_digit((b >> 4) as u32, 16).unwrap()); +s.push(char::from_digit((b & 0x0f) as u32, 16).unwrap()); +⋮---- +/// Decode a hex token; `None` unless it's exactly `TOKEN_LEN` bytes of valid hex. + fn decode_token(s: &str) -> Option { +⋮---- +fn decode_token(s: &str) -> Option { +let s = s.trim(); +if s.len() != TOKEN_LEN * 2 { +⋮---- +let bytes = s.as_bytes(); +⋮---- +for (i, slot) in token.iter_mut().enumerate() { +let hi = (bytes[i * 2] as char).to_digit(16)?; +let lo = (bytes[i * 2 + 1] as char).to_digit(16)?; +⋮---- +Some(token) +⋮---- +/// The port file records `\n`: the loopback port the GUI + /// connects to, plus the token it must present. Parse both back; `None` if the +⋮---- +/// connects to, plus the token it must present. Parse both back; `None` if the + /// file is malformed (a truncated write, or an old single-line file). +⋮---- +/// file is malformed (a truncated write, or an old single-line file). + fn parse_port_file(contents: &str) -> Option<(u16, Token)> { +⋮---- +fn parse_port_file(contents: &str) -> Option<(u16, Token)> { +let mut lines = contents.lines(); +let port = lines.next()?.trim().parse::().ok()?; +let token = decode_token(lines.next()?)?; +Some((port, token)) +⋮---- +/// Constant-time token comparison: fold every byte's difference into one + /// accumulator so the check can't leak how many leading bytes matched. A local +⋮---- +/// accumulator so the check can't leak how many leading bytes matched. A local + /// timing side-channel is far-fetched over loopback, but the guard is free. +⋮---- +/// timing side-channel is far-fetched over loopback, but the guard is free. + fn tokens_match(a: &Token, b: &Token) -> bool { +⋮---- +fn tokens_match(a: &Token, b: &Token) -> bool { +⋮---- +/// Path of the port file recording the daemon's chosen loopback port + token. + /// This is the Windows analogue of the Unix socket file: its presence is the +⋮---- +/// This is the Windows analogue of the Unix socket file: its presence is the + /// "endpoint exists" marker, and — being under the user-private config dir — +⋮---- +/// "endpoint exists" marker, and — being under the user-private config dir — + /// its contents (the token) are readable only by the same user. +⋮---- +/// its contents (the token) are readable only by the same user. + fn port_path() -> Option { +⋮---- +fn port_path() -> Option { +⋮---- +/// Read the recorded loopback port + token, if the port file exists and parses. + fn read_port_file() -> Option<(u16, Token)> { +⋮---- +fn read_port_file() -> Option<(u16, Token)> { +let path = port_path()?; +let contents = std::fs::read_to_string(path).ok()?; +parse_port_file(&contents) +⋮---- +fn loopback(port: u16) -> SocketAddr { +⋮---- +/// Try to connect to the daemon. `Err` (including a missing/zero port or a + /// malformed file) means "nobody home" — the caller treats any error as "not +⋮---- +/// malformed file) means "nobody home" — the caller treats any error as "not + /// running". On success we send the auth token as the connection preamble, +⋮---- +/// running". On success we send the auth token as the connection preamble, + /// before any `ClientMsg`, so the daemon accepts us. +⋮---- +/// before any `ClientMsg`, so the daemon accepts us. + pub fn connect() -> io::Result { +let (port, token) = read_port_file() +.filter(|(p, _)| *p != 0) +.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no daemon port file"))?; +let mut stream = TcpStream::connect(loopback(port))?; +⋮---- +// Present the token first thing; the daemon reads exactly these bytes in +// `authenticate` before it looks for a `ClientMsg`. +stream.write_all(&token)?; +⋮---- +/// Daemon side: read and verify the connection preamble against this daemon's + /// token before any message is processed. Any process on the machine can open +⋮---- +/// token before any message is processed. Any process on the machine can open + /// a loopback TCP connection, but only one that read the user-private port file +⋮---- +/// a loopback TCP connection, but only one that read the user-private port file + /// knows the token — so this is what makes the loopback endpoint per-user +⋮---- +/// knows the token — so this is what makes the loopback endpoint per-user + /// private, the property a Unix socket gets for free from filesystem perms. +⋮---- +/// private, the property a Unix socket gets for free from filesystem perms. + /// +⋮---- +/// + /// A short read (peer hung up), a mismatch, or an uninitialized token all fail +⋮---- +/// A short read (peer hung up), a mismatch, or an uninitialized token all fail + /// the connection; the caller drops it. +⋮---- +/// the connection; the caller drops it. + pub fn authenticate(stream: &mut Stream) -> io::Result<()> { +⋮---- +pub fn authenticate(stream: &mut Stream) -> io::Result<()> { +⋮---- +.get() +.ok_or_else(|| io::Error::other("daemon auth token not initialized"))?; +authenticate_with(stream, expected) +⋮---- +/// Pure core of [`authenticate`]: read a token off `reader` and compare it to + /// `expected`. Split out so the handshake is testable without a live daemon or +⋮---- +/// `expected`. Split out so the handshake is testable without a live daemon or + /// the process-global token. +⋮---- +/// the process-global token. + fn authenticate_with(reader: &mut impl Read, expected: &Token) -> io::Result<()> { +⋮---- +fn authenticate_with(reader: &mut impl Read, expected: &Token) -> io::Result<()> { +⋮---- +reader.read_exact(&mut got)?; +if tokens_match(&got, expected) { +⋮---- +Err(io::Error::new( +⋮---- +/// Loopback-TCP analogue of the Unix `tune`: disable Nagle so small framed + /// messages (keystrokes, resizes) aren't held back waiting for an ACK. +⋮---- +/// messages (keystrokes, resizes) aren't held back waiting for an ACK. + /// Buffer sizes are left at the Windows defaults (already 64 KiB). Best +⋮---- +/// Buffer sizes are left at the Windows defaults (already 64 KiB). Best + /// effort. +⋮---- +/// effort. + pub fn tune(stream: &Stream) { +let _ = stream.set_nodelay(true); +⋮---- +/// Whether the endpoint marker (port file) exists on disk. + pub fn endpoint_exists() -> bool { +port_path().is_some_and(|p| p.exists()) +⋮---- +/// Remove a stale endpoint marker (the port file). Best effort. + pub fn remove_stale_endpoint() { +if let Some(path) = port_path() { +⋮---- +/// Bind a loopback listener on an OS-assigned port and record that port — plus + /// this daemon's freshly-minted auth token — in the port file so the GUI can +⋮---- +/// this daemon's freshly-minted auth token — in the port file so the GUI can + /// find *and* authenticate to it. Ensures the config dir exists first. +⋮---- +/// find *and* authenticate to it. Ensures the config dir exists first. + pub fn bind() -> anyhow::Result { +let path = port_path() +.ok_or_else(|| anyhow::anyhow!("could not resolve daemon port path (no config dir)"))?; +⋮---- +// Port 0 lets the OS pick a free ephemeral port; we read it back so the +// GUI connects to the actual bound port. +let listener = TcpListener::bind(loopback(0)) +.map_err(|e| anyhow::anyhow!("bind 127.0.0.1:0 failed: {e}"))?; +⋮---- +.local_addr() +.map_err(|e| anyhow::anyhow!("could not read bound port: {e}"))? +.port(); +// Mint the token once for this daemon's lifetime; `authenticate` checks +// against the same value. Written to the port file so a client that can +// read it (same user) can present it back. +let token = DAEMON_TOKEN.get_or_init(make_token); +let contents = format!("{port}\n{}", encode_token(token)); +⋮---- +.map_err(|e| anyhow::anyhow!("could not write port file {}: {e}", path.display()))?; +⋮---- +match read_port_file() { +Some((port, _)) => format!("127.0.0.1:{port}"), +None => "127.0.0.1:".to_string(), +⋮---- +/// A token round-trips through hex encode → decode unchanged. + #[test] +fn token_hex_round_trips() { +let token = make_token(); +assert_eq!(decode_token(&encode_token(&token)), Some(token)); +⋮---- +/// `decode_token` rejects anything that isn't exactly 32 bytes of hex. + #[test] +fn decode_token_rejects_malformed() { +assert!(decode_token("").is_none()); +assert!(decode_token("zz").is_none()); +assert!(decode_token(&"a".repeat(63)).is_none()); // odd/short +assert!(decode_token(&"a".repeat(66)).is_none()); // too long +assert!(decode_token(&"g".repeat(64)).is_none()); // non-hex digit +assert!(decode_token(&"ab".repeat(32)).is_some()); // exactly right +⋮---- +/// The port file format is `\n`, and parsing recovers both. + #[test] +fn parse_port_file_recovers_port_and_token() { +⋮---- +let contents = format!("54321\n{}", encode_token(&token)); +assert_eq!(parse_port_file(&contents), Some((54321, token))); +⋮---- +/// A single-line (legacy / truncated) file has no token, so it must not + /// parse — a client can't authenticate without one. +⋮---- +/// parse — a client can't authenticate without one. + #[test] +fn parse_port_file_rejects_missing_token() { +assert!(parse_port_file("54321").is_none()); +assert!(parse_port_file("54321\n").is_none()); +assert!(parse_port_file("").is_none()); +assert!(parse_port_file("notaport\ndeadbeef").is_none()); +⋮---- +/// `tokens_match` is true only for identical tokens. + #[test] +fn tokens_match_is_exact() { +let a = make_token(); +⋮---- +assert!(tokens_match(&a, &b)); +b[TOKEN_LEN - 1] ^= 1; // flip the last bit +assert!(!tokens_match(&a, &b)); +⋮---- +/// The handshake core accepts the matching token and rejects a wrong one + /// (and a short read), driven over an in-memory reader — no live daemon. +⋮---- +/// (and a short read), driven over an in-memory reader — no live daemon. + #[test] +fn authenticate_with_accepts_only_the_matching_token() { +⋮---- +// Correct token → Ok. +let mut good = std::io::Cursor::new(token.to_vec()); +assert!(authenticate_with(&mut good, &token).is_ok()); +⋮---- +// Wrong token → PermissionDenied. +⋮---- +let mut wrong = std::io::Cursor::new(wrong_bytes.to_vec()); +let err = authenticate_with(&mut wrong, &token).unwrap_err(); +assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); +⋮---- +// Short preamble (peer hung up mid-token) → error, never a false accept. +let mut short = std::io::Cursor::new(vec![0u8; TOKEN_LEN - 1]); +assert!(authenticate_with(&mut short, &token).is_err()); +⋮---- +/// End-to-end over a real loopback socket: a client that presents the + /// token authenticates; one that presents garbage is rejected. This is the +⋮---- +/// token authenticates; one that presents garbage is rejected. This is the + /// exact property the whole change exists to enforce. +⋮---- +/// exact property the whole change exists to enforce. + #[test] +fn loopback_handshake_authenticates_real_connection() { +⋮---- +let listener = TcpListener::bind(loopback(0)).expect("bind loopback"); +let port = listener.local_addr().unwrap().port(); +⋮---- +// Good client: connect and present the correct token. +⋮---- +let mut s = TcpStream::connect(loopback(port)).unwrap(); +s.write_all(&token).unwrap(); +⋮---- +let (mut server_side, _) = listener.accept().unwrap(); +assert!(authenticate_with(&mut server_side, &token).is_ok()); +let _keep = good.join().unwrap(); +⋮---- +// Bad client: connect and present a wrong token. +⋮---- +let _ = s.write_all(&bad_token); +⋮---- +let (mut server_side2, _) = listener.accept().unwrap(); +assert!(authenticate_with(&mut server_side2, &token).is_err()); +bad.join().unwrap(); +⋮---- +/// Full wiring over the real config-dir path: `bind` writes a parseable + /// `\n` file and seeds the process token, and the public +⋮---- +/// `\n` file and seeds the process token, and the public + /// `authenticate` (which reads that process token) then accepts a client +⋮---- +/// `authenticate` (which reads that process token) then accepts a client + /// that presents the file's token. Exercises the `bind`→`connect`→ +⋮---- +/// that presents the file's token. Exercises the `bind`→`connect`→ + /// `authenticate` seam the daemon actually runs, not just the pure core. +⋮---- +/// `authenticate` seam the daemon actually runs, not just the pure core. + #[test] +fn bind_seeds_token_and_public_authenticate_accepts_a_file_token_client() { +// Pin the config dir under a temp dir so the port file never touches the +// real `%APPDATA%`. First-call-wins, matching the Unix IO tests. +let dir = std::env::temp_dir().join(format!("tty7-wintok-{}", std::process::id())); +⋮---- +let listener = bind().expect("bind under temp config dir"); +let bound_port = listener.local_addr().unwrap().port(); +⋮---- +// The port file parses and matches the bound port. +let contents = std::fs::read_to_string(port_path().unwrap()).unwrap(); +let (port, token) = parse_port_file(&contents).expect("port file parses"); +assert_eq!(port, bound_port, "file records the actually-bound port"); +⋮---- +// A client that read the file (has the token) authenticates via the +// public path, which checks against the token `bind` seeded. +⋮---- +assert!(authenticate(&mut server_side).is_ok()); +```` + +## File: src/daemon/winproc.rs +````rust +//! Windows-only process-table helpers. +//! +⋮---- +//! +//! Windows has no ConPTY analogue of a Unix "foreground process group", so the +⋮---- +//! Windows has no ConPTY analogue of a Unix "foreground process group", so the +//! daemon can't ask the pty who's in front (that's why `pane`'s macOS/Linux +⋮---- +//! daemon can't ask the pty who's in front (that's why `pane`'s macOS/Linux +//! foreground queries have no Windows counterpart). What it *can* do is walk the +⋮---- +//! foreground queries have no Windows counterpart). What it *can* do is walk the +//! process table from the shell's own pid. Two pane operations need that: +⋮---- +//! process table from the shell's own pid. Two pane operations need that: +//! +⋮---- +//! +//! - **titling** a pane by the command running under the shell +⋮---- +//! - **titling** a pane by the command running under the shell +//! ([`foreground_name`]), so Windows tabs show `git` / `node` / … instead of +⋮---- +//! ([`foreground_name`]), so Windows tabs show `git` / `node` / … instead of +//! staying blank; and +⋮---- +//! staying blank; and +//! - **hangup** ([`descendants`]), because `portable-pty`'s Windows `kill` +⋮---- +//! - **hangup** ([`descendants`]), because `portable-pty`'s Windows `kill` +//! terminates only the shell process — its children would otherwise be +⋮---- +//! terminates only the shell process — its children would otherwise be +//! reparented and linger, some still attached to the ConPTY, which keeps the +⋮---- +//! reparented and linger, some still attached to the ConPTY, which keeps the +//! pane reader's blocking read from ever hitting EOF. +⋮---- +//! pane reader's blocking read from ever hitting EOF. +//! +⋮---- +//! +//! The Win32 surface is a thin [`snapshot`]/[`terminate`] pair; all the tree +⋮---- +//! The Win32 surface is a thin [`snapshot`]/[`terminate`] pair; all the tree +//! logic is pure over a plain [`Proc`] list and unit-tested without a live +⋮---- +//! logic is pure over a plain [`Proc`] list and unit-tested without a live +//! process. Note that reading another process's *cwd* is deliberately not here: +⋮---- +//! process. Note that reading another process's *cwd* is deliberately not here: +//! it needs PEB traversal via `ReadProcessMemory`, which is undocumented and +⋮---- +//! it needs PEB traversal via `ReadProcessMemory`, which is undocumented and +//! fragile across bitness/elevation — so cwd on Windows stays sourced from OSC 7 +⋮---- +//! fragile across bitness/elevation — so cwd on Windows stays sourced from OSC 7 +//! (see `pane::foreground_cwd`). +⋮---- +//! (see `pane::foreground_cwd`). +⋮---- +/// One process-table row: a pid, its parent's pid, and the executable basename. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Proc { +⋮---- +/// BFS the table from `root` by parent link, returning `(depth, pid, name)` for +/// every reachable descendant (root excluded), shallowest-first. A `seen` set +⋮---- +/// every reachable descendant (root excluded), shallowest-first. A `seen` set +/// makes the walk robust to Windows pid reuse: a stale parent link that points +⋮---- +/// makes the walk robust to Windows pid reuse: a stale parent link that points +/// back into the tree (or a process that lists itself as its own parent) can't +⋮---- +/// back into the tree (or a process that lists itself as its own parent) can't +/// create a cycle, because each pid is expanded at most once. +⋮---- +/// create a cycle, because each pid is expanded at most once. +fn walk(procs: &[Proc], root: u32) -> Vec<(u32, u32, &str)> { +⋮---- +fn walk(procs: &[Proc], root: u32) -> Vec<(u32, u32, &str)> { +⋮---- +seen.insert(root); +⋮---- +frontier.push_back((root, 0u32)); +⋮---- +while let Some((parent, depth)) = frontier.pop_front() { +⋮---- +if p.parent == parent && p.pid != parent && seen.insert(p.pid) { +out.push((depth + 1, p.pid, p.name.as_str())); +frontier.push_back((p.pid, depth + 1)); +⋮---- +/// Descendants of `root` (children, grandchildren, …), each listed once and +/// ordered deepest-first — so a caller terminating them tears down leaf commands +⋮---- +/// ordered deepest-first — so a caller terminating them tears down leaf commands +/// before the shells that spawned them. `root` itself is never included. +⋮---- +/// before the shells that spawned them. `root` itself is never included. +pub(crate) fn descendants(procs: &[Proc], root: u32) -> Vec { +⋮---- +pub(crate) fn descendants(procs: &[Proc], root: u32) -> Vec { +let mut walked = walk(procs, root); +// Deepest depth first; stable within a depth, so ordering is deterministic. +walked.sort_by_key(|&(depth, ..)| std::cmp::Reverse(depth)); +walked.into_iter().map(|(_, pid, _)| pid).collect() +⋮---- +/// The foreground command's exe name for a shell rooted at `shell_pid`: the +/// deepest descendant (the thing actually running under the shell), or `None` +⋮---- +/// deepest descendant (the thing actually running under the shell), or `None` +/// when the shell has no descendants at all — i.e. it's idle at its prompt, in +⋮---- +/// when the shell has no descendants at all — i.e. it's idle at its prompt, in +/// which case the caller keeps the pane's existing title. Ties at equal depth +⋮---- +/// which case the caller keeps the pane's existing title. Ties at equal depth +/// break toward the largest pid (roughly the most recently created) so the pick +⋮---- +/// break toward the largest pid (roughly the most recently created) so the pick +/// is stable frame to frame. +⋮---- +/// is stable frame to frame. +pub(crate) fn foreground_name(procs: &[Proc], shell_pid: u32) -> Option { +⋮---- +pub(crate) fn foreground_name(procs: &[Proc], shell_pid: u32) -> Option { +walk(procs, shell_pid) +.into_iter() +.max_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))) +.map(|(_, _, name)| name.to_string()) +⋮---- +/// Snapshot every process on the system as a [`Proc`] list, via a Toolhelp +/// snapshot. Best effort: any failure yields an empty list (the callers then +⋮---- +/// snapshot. Best effort: any failure yields an empty list (the callers then +/// simply do nothing — no title, no extra kills). +⋮---- +/// simply do nothing — no title, no extra kills). +pub(crate) fn snapshot() -> Vec { +⋮---- +pub(crate) fn snapshot() -> Vec { +⋮---- +// SAFETY: a textbook Toolhelp enumeration. The snapshot handle is closed on +// every exit path; `PROCESSENTRY32W` is zeroed and its `dwSize` set before the +// first call, exactly as the API requires; each `szExeFile` is a NUL-terminated +// UTF-16 buffer we read within its fixed length. +⋮---- +let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); +⋮---- +if Process32FirstW(snap, &mut entry) != 0 { +⋮---- +out.push(Proc { +⋮---- +name: exe_name(&entry.szExeFile), +⋮---- +if Process32NextW(snap, &mut entry) == 0 { +⋮---- +CloseHandle(snap); +⋮---- +/// Force-terminate `pid`. Best effort: a process we can't open (already gone, or +/// access denied) is simply skipped. +⋮---- +/// access denied) is simply skipped. +pub(crate) fn terminate(pid: u32) { +⋮---- +pub(crate) fn terminate(pid: u32) { +use windows_sys::Win32::Foundation::CloseHandle; +⋮---- +// SAFETY: open → terminate → close on a single pid. A null handle (the process +// exited or we lack rights) is checked before use; the handle is always closed. +⋮---- +let handle = OpenProcess(PROCESS_TERMINATE, 0, pid); +if !handle.is_null() { +TerminateProcess(handle, 1); +CloseHandle(handle); +⋮---- +/// The executable basename from a NUL-terminated UTF-16 `szExeFile` field. +fn exe_name(raw: &[u16]) -> String { +⋮---- +fn exe_name(raw: &[u16]) -> String { +let len = raw.iter().position(|&c| c == 0).unwrap_or(raw.len()); +⋮---- +mod tests { +⋮---- +fn p(pid: u32, parent: u32, name: &str) -> Proc { +⋮---- +name: name.to_string(), +⋮---- +/// A realistic tree: only the shell's own descendants come back, unrelated + /// processes (and the shell's ancestors) are excluded. +⋮---- +/// processes (and the shell's ancestors) are excluded. + #[test] +fn descendants_collects_only_the_shell_subtree() { +let procs = vec![ +⋮---- +p(100, 1, "powershell.exe"), // the shell +p(200, 100, "git.exe"), // child +p(300, 200, "less.exe"), // grandchild +p(201, 100, "node.exe"), // another child +p(999, 1, "explorer.exe"), // unrelated +⋮---- +let mut got = descendants(&procs, 100); +got.sort(); +assert_eq!(got, vec![200, 201, 300]); +⋮---- +/// Descendants come back deepest-first, so a terminator hits leaves before + /// the parents that spawned them. +⋮---- +/// the parents that spawned them. + #[test] +fn descendants_are_ordered_deepest_first() { +let procs = vec![p(100, 1, "sh"), p(200, 100, "a"), p(300, 200, "b")]; +assert_eq!(descendants(&procs, 100), vec![300, 200]); +⋮---- +/// Pid reuse can make a parent link point back into the tree; the walk must + /// not loop forever on that. +⋮---- +/// not loop forever on that. + #[test] +fn descendants_survive_a_pid_reuse_cycle() { +// 200's parent is 100 (real child); 100 *also* claims 200 as its parent +// (a reused pid). 100 is the root, so it's never re-expanded. +let procs = vec![p(100, 200, "a"), p(200, 100, "b")]; +assert_eq!(descendants(&procs, 100), vec![200]); +⋮---- +/// A self-parenting row (pid == parent, as some system pids report) can't + /// wedge the walk either. +⋮---- +/// wedge the walk either. + #[test] +fn descendants_survive_self_parenting() { +let procs = vec![p(100, 1, "sh"), p(100, 100, "self")]; +// The only row whose parent is 100 is the self-referential one, which is +// rejected (pid == parent), so nothing descends. +assert!(descendants(&procs, 100).is_empty()); +⋮---- +/// A shell sitting idle at its prompt (no children) has no descendants. + #[test] +fn descendants_empty_without_children() { +let procs = vec![p(100, 1, "sh"), p(999, 1, "other")]; +⋮---- +/// The pane title is the deepest running command, not the shell. + #[test] +fn foreground_name_is_the_deepest_command() { +⋮---- +assert_eq!(foreground_name(&procs, 100).as_deref(), Some("less.exe")); +⋮---- +/// Idle at the prompt → no foreground command, so the caller keeps the + /// existing title rather than blanking it. +⋮---- +/// existing title rather than blanking it. + #[test] +fn foreground_name_is_none_at_idle_prompt() { +let procs = vec![p(100, 1, "powershell.exe"), p(999, 1, "explorer.exe")]; +assert_eq!(foreground_name(&procs, 100), None); +⋮---- +/// Two equally-deep children resolve deterministically (largest pid wins) so + /// the title doesn't flicker between them. +⋮---- +/// the title doesn't flicker between them. + #[test] +fn foreground_name_breaks_depth_ties_by_pid() { +let procs = vec![p(100, 1, "sh"), p(200, 100, "a"), p(201, 100, "b")]; +assert_eq!(foreground_name(&procs, 100).as_deref(), Some("b")); +⋮---- +/// UTF-16 `szExeFile` decoding stops at the NUL terminator. + #[test] +fn exe_name_reads_up_to_the_nul() { +⋮---- +for (i, c) in "cmd.exe".encode_utf16().enumerate() { +⋮---- +assert_eq!(exe_name(&raw), "cmd.exe"); +```` + +## File: src/terminal/cmd_editor.rs +````rust +//! A small, self-contained command-line editor buffer for the prompt. +//! +⋮---- +//! +//! Why not reuse `gpui_component::InputState`? Because it claims `tab`, `up`, +⋮---- +//! Why not reuse `gpui_component::InputState`? Because it claims `tab`, `up`, +//! `down`, and other keys in its `"Input"` key context, and gpui dispatches +⋮---- +//! `down`, and other keys in its `"Input"` key context, and gpui dispatches +//! keybinding actions *before* `on_key_down` listeners — so an ancestor can't +⋮---- +//! keybinding actions *before* `on_key_down` listeners — so an ancestor can't +//! intercept those keys to drive Tab completion / history recall. To own every +⋮---- +//! intercept those keys to drive Tab completion / history recall. To own every +//! key at the prompt (the prerequisite for completion, history, syntax +⋮---- +//! key at the prompt (the prerequisite for completion, history, syntax +//! highlighting and ghost suggestions) we keep keyboard focus on the terminal and +⋮---- +//! highlighting and ghost suggestions) we keep keyboard focus on the terminal and +//! run our own line editor here. +⋮---- +//! run our own line editor here. +//! +⋮---- +//! +//! The buffer is a `Vec` with a char-index cursor, so cursor arithmetic and +⋮---- +//! The buffer is a `Vec` with a char-index cursor, so cursor arithmetic and +//! word motion never split a multi-byte UTF-8 scalar. It is deliberately +⋮---- +//! word motion never split a multi-byte UTF-8 scalar. It is deliberately +//! editing-only (no rendering, no key mapping); the view owns those. +⋮---- +//! editing-only (no rendering, no key mapping); the view owns those. +/// An editable single line plus a cursor position (a char index in `0..=len`), +/// and an optional selection anchor (the selection spans `anchor..cursor`). +⋮---- +/// and an optional selection anchor (the selection spans `anchor..cursor`). +#[derive(Default)] +pub struct CmdEditor { +⋮---- +/// Undo / redo stacks of `(chars, cursor)` snapshots. Each mutating edit + /// records the pre-edit state (deduplicated by content) onto `undo`; undo/redo +⋮---- +/// records the pre-edit state (deduplicated by content) onto `undo`; undo/redo + /// shuttle states between the two. +⋮---- +/// shuttle states between the two. + undo: Vec<(Vec, usize)>, +⋮---- +/// Cap on undo history, so a long editing session can't grow it without bound. +const UNDO_LIMIT: usize = 200; +⋮---- +impl CmdEditor { +pub fn new() -> Self { +⋮---- +/// The current line as a `String`. + pub fn text(&self) -> String { +⋮---- +pub fn text(&self) -> String { +self.chars.iter().collect() +⋮---- +pub fn is_empty(&self) -> bool { +self.chars.is_empty() +⋮---- +/// Number of chars in the line (cursor is in `0..=len`). + pub fn len(&self) -> usize { +⋮---- +pub fn len(&self) -> usize { +self.chars.len() +⋮---- +/// Cursor position as a char index (`0..=len`). Used by tests and the + /// upcoming completion increment. +⋮---- +/// upcoming completion increment. + #[allow(dead_code)] +pub fn cursor(&self) -> usize { +⋮---- +/// Cursor position as a byte offset into `text()`, for callers that need to + /// slice the rendered string (e.g. to split it at the caret). +⋮---- +/// slice the rendered string (e.g. to split it at the caret). + #[allow(dead_code)] +pub fn cursor_byte(&self) -> usize { +self.chars[..self.cursor].iter().map(|c| c.len_utf8()).sum() +⋮---- +// ---- Undo / redo ---- +⋮---- +/// Record the current state onto the undo stack (deduplicated by content) and + /// clear redo. Called at the start of every mutating edit; nested calls within +⋮---- +/// clear redo. Called at the start of every mutating edit; nested calls within + /// one edit collapse to a single entry via the content check. +⋮---- +/// one edit collapse to a single entry via the content check. + fn checkpoint(&mut self) { +⋮---- +fn checkpoint(&mut self) { +if self.undo.last().map(|(c, _)| c.as_slice()) != Some(self.chars.as_slice()) { +self.undo.push((self.chars.clone(), self.cursor)); +if self.undo.len() > UNDO_LIMIT { +self.undo.remove(0); +⋮---- +self.redo.clear(); +⋮---- +pub fn undo(&mut self) { +// Skip "phantom" checkpoints whose text already equals the current buffer. +// A no-op edit (Backspace at column 0, Ctrl-K at line end, Ctrl-W at +// column 0, …) still calls `checkpoint()`, recording the pre-edit state — +// which for a no-op is identical to the current one. Undoing that entry +// would be a dead keypress that "restores" the same text instead of the +// real edit before it. Drop past any such entries to the first checkpoint +// that actually changes the text. Comparing text alone is enough: plain +// caret motion never checkpoints, so a top entry matching the current text +// can only be a no-op's phantom, never a cursor-only undo target. +⋮---- +.last() +.is_some_and(|(chars, _)| chars.as_slice() == self.chars.as_slice()) +⋮---- +self.undo.pop(); +⋮---- +if let Some((chars, cursor)) = self.undo.pop() { +self.redo.push((self.chars.clone(), self.cursor)); +⋮---- +self.cursor = cursor.min(self.chars.len()); +⋮---- +pub fn redo(&mut self) { +if let Some((chars, cursor)) = self.redo.pop() { +⋮---- +/// Insert a string at the cursor, advancing past it. Replaces the selection + /// first if there is one. Used for typed text and IME-committed text alike. +⋮---- +/// first if there is one. Used for typed text and IME-committed text alike. + pub fn insert_str(&mut self, s: &str) { +⋮---- +pub fn insert_str(&mut self, s: &str) { +self.checkpoint(); +self.delete_selection(); +for c in s.chars() { +self.chars.insert(self.cursor, c); +⋮---- +/// Insert a string at the start of the line, leaving the caret (and any + /// selection) on the characters they were on — their indices shift by the +⋮---- +/// selection) on the characters they were on — their indices shift by the + /// inserted length. Used to adopt gap typeahead, which was typed +⋮---- +/// inserted length. Used to adopt gap typeahead, which was typed + /// chronologically before the editor's current content. +⋮---- +/// chronologically before the editor's current content. + pub fn prepend_str(&mut self, s: &str) { +⋮---- +pub fn prepend_str(&mut self, s: &str) { +if s.is_empty() { +⋮---- +let n = s.chars().count(); +for (i, c) in s.chars().enumerate() { +self.chars.insert(i, c); +⋮---- +self.anchor = self.anchor.map(|a| a + n); +⋮---- +/// Delete the char before the cursor (Backspace), or the selection if any. + pub fn backspace(&mut self) { +⋮---- +pub fn backspace(&mut self) { +⋮---- +if self.delete_selection() { +⋮---- +self.chars.remove(self.cursor); +⋮---- +/// Delete the char at the cursor (Delete), or the selection if any. + pub fn delete(&mut self) { +⋮---- +pub fn delete(&mut self) { +⋮---- +if self.cursor < self.chars.len() { +⋮---- +// ---- Selection ---- +⋮---- +/// The selected range as normalized `(start, end)` char indices, or `None` + /// when there's no (non-empty) selection. +⋮---- +/// when there's no (non-empty) selection. + pub fn selection(&self) -> Option<(usize, usize)> { +⋮---- +pub fn selection(&self) -> Option<(usize, usize)> { +// Clamp both endpoints to the current length. A delete that shrinks the +// buffer without touching the anchor (delete_word_left/right, +// delete_to_start/end never clear it) can leave the anchor past the new +// end; slicing `chars[a..cursor]` on that stale anchor then panics +// (reachable from real input: shift-select, Alt+Delete, then Cmd+C / Cmd+X, +// which read `selected_text()`). Clamping is a no-op for every valid state +// and collapses a deleted-region selection to `None`. +let n = self.chars.len(); +let a = self.anchor?.min(n); +let c = self.cursor.min(n); +⋮---- +Some((a.min(c), a.max(c))) +⋮---- +/// The selected text, if any. + pub fn selected_text(&self) -> Option { +⋮---- +pub fn selected_text(&self) -> Option { +let (s, e) = self.selection()?; +Some(self.chars[s..e].iter().collect()) +⋮---- +pub fn clear_selection(&mut self) { +⋮---- +/// Start a selection at the current cursor if none is active (used before an + /// extending, shift-modified motion). +⋮---- +/// extending, shift-modified motion). + pub fn begin_selection(&mut self) { +⋮---- +pub fn begin_selection(&mut self) { +if self.anchor.is_none() { +self.anchor = Some(self.cursor); +⋮---- +/// Delete the selection if there is one; returns whether anything was deleted. + pub fn delete_selection(&mut self) -> bool { +⋮---- +pub fn delete_selection(&mut self) -> bool { +if let Some((s, e)) = self.selection() { +⋮---- +self.chars.drain(s..e); +⋮---- +/// Select the whole line. + pub fn select_all(&mut self) { +⋮---- +pub fn select_all(&mut self) { +self.anchor = Some(0); +self.cursor = self.chars.len(); +⋮---- +/// Select the word (run of non-whitespace) containing char index `idx`. + pub fn select_word_at(&mut self, idx: usize) { +⋮---- +pub fn select_word_at(&mut self, idx: usize) { +let idx = idx.min(self.chars.len()); +⋮---- +while s > 0 && !self.chars[s - 1].is_whitespace() { +⋮---- +while e < self.chars.len() && !self.chars[e].is_whitespace() { +⋮---- +self.anchor = Some(s); +⋮---- +/// Move the cursor to char index `idx` (clamped), extending the selection from + /// the existing anchor (starting one at the old cursor if needed). For drags. +⋮---- +/// the existing anchor (starting one at the old cursor if needed). For drags. + pub fn extend_to(&mut self, idx: usize) { +⋮---- +pub fn extend_to(&mut self, idx: usize) { +self.begin_selection(); +self.cursor = idx.min(self.chars.len()); +⋮---- +pub fn move_left(&mut self) { +self.cursor = self.cursor.saturating_sub(1); +⋮---- +pub fn move_right(&mut self) { +⋮---- +pub fn move_home(&mut self) { +⋮---- +/// Place the cursor at char index `idx` (clamped to the line length). Used to + /// reposition the caret from a mouse click. +⋮---- +/// reposition the caret from a mouse click. + pub fn set_cursor(&mut self, idx: usize) { +⋮---- +pub fn set_cursor(&mut self, idx: usize) { +⋮---- +pub fn move_end(&mut self) { +⋮---- +/// Move left to the start of the previous word (skip trailing whitespace, then + /// the word). Word = run of non-whitespace. +⋮---- +/// the word). Word = run of non-whitespace. + pub fn move_word_left(&mut self) { +⋮---- +pub fn move_word_left(&mut self) { +while self.cursor > 0 && self.chars[self.cursor - 1].is_whitespace() { +⋮---- +while self.cursor > 0 && !self.chars[self.cursor - 1].is_whitespace() { +⋮---- +/// Move right to the end of the next word. + pub fn move_word_right(&mut self) { +⋮---- +pub fn move_word_right(&mut self) { +⋮---- +while self.cursor < n && self.chars[self.cursor].is_whitespace() { +⋮---- +while self.cursor < n && !self.chars[self.cursor].is_whitespace() { +⋮---- +/// Shift the selection anchor to account for the removal of chars `[s, e)`, + /// exactly as any editor adjusts marks across an edit: an anchor past the +⋮---- +/// exactly as any editor adjusts marks across an edit: an anchor past the + /// hole moves left by its width, one inside collapses to its start. Without +⋮---- +/// hole moves left by its width, one inside collapses to its start. Without + /// this, a range delete that doesn't reach the buffer end leaves the anchor +⋮---- +/// this, a range delete that doesn't reach the buffer end leaves the anchor + /// pointing at *shifted* text — `selection()`'s clamp then reports a +⋮---- +/// pointing at *shifted* text — `selection()`'s clamp then reports a + /// phantom selection over chars the user never selected (and ⌘C copies it). +⋮---- +/// phantom selection over chars the user never selected (and ⌘C copies it). + fn shift_anchor_for_removal(&mut self, s: usize, e: usize) { +⋮---- +fn shift_anchor_for_removal(&mut self, s: usize, e: usize) { +⋮---- +self.anchor = Some(if a <= s { a } else { a.max(e) - (e - s) }); +⋮---- +/// Delete the word after the cursor (Alt+Delete): skip following whitespace, + /// then the word. +⋮---- +/// then the word. + pub fn delete_word_right(&mut self) { +⋮---- +pub fn delete_word_right(&mut self) { +⋮---- +while e < n && self.chars[e].is_whitespace() { +⋮---- +while e < n && !self.chars[e].is_whitespace() { +⋮---- +self.chars.drain(self.cursor..e); +self.shift_anchor_for_removal(self.cursor, e); +⋮---- +/// Delete the word before the cursor (Ctrl+W / Alt+Backspace). + pub fn delete_word_left(&mut self) { +⋮---- +pub fn delete_word_left(&mut self) { +⋮---- +self.move_word_left(); +self.chars.drain(self.cursor..end); +self.shift_anchor_for_removal(self.cursor, end); +⋮---- +/// Delete from the cursor to the start of the line (Ctrl+U / Cmd+Backspace). + pub fn delete_to_start(&mut self) { +⋮---- +pub fn delete_to_start(&mut self) { +⋮---- +self.chars.drain(0..self.cursor); +⋮---- +self.shift_anchor_for_removal(0, end); +⋮---- +/// Delete from the cursor to the end of the line (Ctrl+K). + pub fn delete_to_end(&mut self) { +⋮---- +pub fn delete_to_end(&mut self) { +⋮---- +let end = self.chars.len(); +self.chars.drain(self.cursor..); +⋮---- +/// Clear the line and reset the cursor and undo history (after submit). + pub fn clear(&mut self) { +⋮---- +pub fn clear(&mut self) { +self.chars.clear(); +⋮---- +self.undo.clear(); +⋮---- +/// Replace the whole line, putting the cursor at the end. Used by history + /// recall and completion acceptance. +⋮---- +/// recall and completion acceptance. + pub fn set(&mut self, text: &str) { +⋮---- +pub fn set(&mut self, text: &str) { +⋮---- +self.chars = text.chars().collect(); +⋮---- +/// Replace the whole line with `text` and place the cursor at char index + /// `cursor` (clamped). Used to apply a completion built against a saved +⋮---- +/// `cursor` (clamped). Used to apply a completion built against a saved + /// original line, and to restore that original on cancel. +⋮---- +/// original line, and to restore that original on cancel. + pub fn set_with_cursor(&mut self, text: &str, cursor: usize) { +⋮---- +pub fn set_with_cursor(&mut self, text: &str, cursor: usize) { +⋮---- +mod tests { +use super::CmdEditor; +⋮---- +fn ed(text: &str, cursor: usize) -> CmdEditor { +⋮---- +e.insert_str(text); +⋮---- +fn prepend_str_keeps_caret_and_selection_on_their_chars() { +// Adopting gap typeahead: the seed was typed chronologically *before* +// whatever is already in the editor, so it lands at the start while +// the caret (and any selection) stays on the characters it was on. +let mut e = ed("etty", 2); // caret between "et" and "ty" +e.prepend_str("cd g"); +assert_eq!(e.text(), "cd getty"); +assert_eq!(e.cursor(), 6, "caret still between 'et' and 'ty'"); +// One undo removes the adopted seed again. +e.undo(); +assert_eq!(e.text(), "etty"); +⋮---- +let mut e = ed("tty", 3); +e.set_cursor(1); +e.begin_selection(); +e.extend_to(3); // selects "ty" +e.prepend_str("ge"); +assert_eq!(e.text(), "getty"); +assert_eq!(e.selection(), Some((3, 5)), "selection still covers 'ty'"); +⋮---- +fn insert_and_text() { +⋮---- +e.insert_str("git"); +e.insert_str(" "); +e.insert_str("push"); +assert_eq!(e.text(), "git push"); +assert_eq!(e.cursor(), 8); +⋮---- +fn insert_mid_line() { +let mut e = ed("gitpush", 3); +⋮---- +assert_eq!(e.cursor(), 4); +⋮---- +fn backspace_and_delete() { +let mut e = ed("abc", 2); +e.backspace(); +assert_eq!((e.text().as_str(), e.cursor()), ("ac", 1)); +e.delete(); +assert_eq!((e.text().as_str(), e.cursor()), ("a", 1)); +// Backspace at start is a no-op. +let mut s = ed("x", 0); +s.backspace(); +assert_eq!(s.text(), "x"); +⋮---- +fn cursor_motion_bounds() { +let mut e = ed("ab", 1); +e.move_left(); +assert_eq!(e.cursor(), 0); +⋮---- +assert_eq!(e.cursor(), 0); // clamped +e.move_end(); +assert_eq!(e.cursor(), 2); +e.move_right(); +assert_eq!(e.cursor(), 2); // clamped +e.move_home(); +⋮---- +fn word_motion_and_delete() { +let mut e = ed("git push origin", 15); +e.move_word_left(); +assert_eq!(e.cursor(), 9); // start of "origin" +⋮---- +assert_eq!(e.cursor(), 4); // start of "push" +let mut d = ed("git push origin", 15); +d.delete_word_left(); +assert_eq!(d.text(), "git push "); +assert_eq!(d.cursor(), 9); +⋮---- +fn delete_to_start_and_end() { +let mut s = ed("hello world", 6); +s.delete_to_start(); +assert_eq!((s.text().as_str(), s.cursor()), ("world", 0)); +let mut e = ed("hello world", 5); +e.delete_to_end(); +assert_eq!((e.text().as_str(), e.cursor()), ("hello", 5)); +⋮---- +fn multibyte_byte_offset() { +⋮---- +e.insert_str("你好"); // 2 chars, 6 bytes +⋮---- +assert_eq!(e.cursor_byte(), 6); +⋮---- +assert_eq!(e.cursor_byte(), 3); // after first char (3 bytes) +⋮---- +assert_eq!(e.text(), "好"); +⋮---- +fn set_replaces_and_puts_cursor_at_end() { +let mut e = ed("abc", 1); +e.set("git status"); +assert_eq!((e.text().as_str(), e.cursor()), ("git status", 10)); +⋮---- +fn set_cursor_clamps() { +let mut e = ed("hello", 5); +e.set_cursor(2); +⋮---- +e.set_cursor(99); +assert_eq!(e.cursor(), 5); // clamped to len +⋮---- +fn selection_basics_and_delete() { +let mut e = ed("hello world", 0); +⋮---- +e.set_cursor(5); // select "hello" +assert_eq!(e.selection(), Some((0, 5))); +assert_eq!(e.selected_text().as_deref(), Some("hello")); +assert!(e.delete_selection()); +assert_eq!((e.text().as_str(), e.cursor()), (" world", 0)); +assert_eq!(e.selection(), None); +⋮---- +fn typing_replaces_selection() { +let mut e = ed("abc def", 0); +⋮---- +e.set_cursor(3); // select "abc" +e.insert_str("XY"); +assert_eq!((e.text().as_str(), e.cursor()), ("XY def", 2)); +⋮---- +fn select_word_and_all() { +let mut e = ed("git push origin", 6); +e.select_word_at(6); // cursor on "push" +assert_eq!(e.selected_text().as_deref(), Some("push")); +e.select_all(); +assert_eq!(e.selection(), Some((0, 15))); +⋮---- +fn extend_to_keeps_anchor() { +let mut e = ed("abcdef", 2); +e.extend_to(5); +assert_eq!(e.selection(), Some((2, 5))); +e.extend_to(0); // drag back past the anchor +assert_eq!(e.selection(), Some((0, 2))); +⋮---- +fn undo_redo_steps_through_edits() { +⋮---- +e.insert_str("a"); +e.insert_str("b"); +e.insert_str("c"); +assert_eq!(e.text(), "abc"); +⋮---- +assert_eq!(e.text(), "ab"); +⋮---- +assert_eq!(e.text(), "a"); +e.redo(); +⋮---- +// A fresh edit clears the redo stack. +e.insert_str("X"); +⋮---- +assert_eq!(e.text(), "abX"); +⋮---- +fn no_op_edit_does_not_swallow_the_first_undo() { +// A no-op deletion (Backspace with the caret at column 0) used to push a +// checkpoint equal to the current buffer, so the next Undo was a dead press +// that "restored" the same text instead of undoing the real edit before it. +let mut e = ed("x", 0); // buffer "x"; one real edit sits on the undo stack +e.backspace(); // no-op: nothing before the caret +e.undo(); // must undo the real insert ("x" -> ""), not the phantom no-op +assert_eq!(e.text(), ""); +⋮---- +fn no_op_edit_between_real_edits_is_not_a_dead_undo_step() { +// Same defect via a different no-op path (Ctrl-K at end of line) sitting +// between two real edits: one Undo must still step back over a real edit. +⋮---- +e.insert_str("b"); // buffer "ab", caret at end +e.delete_to_end(); // no-op: the caret is already at the end +⋮---- +fn undo_restores_the_pre_edit_cursor_position() { +// A mid-line edit then Undo puts the caret back where the edit began, +// not at the end of the line. +let mut e = ed("git push", 3); +e.insert_str("XY"); // "gitXY push", caret 5 +assert_eq!((e.text().as_str(), e.cursor()), ("gitXY push", 5)); +⋮---- +assert_eq!((e.text().as_str(), e.cursor()), ("git push", 3)); +⋮---- +assert_eq!(e.text(), "gitXY push"); +⋮---- +fn select_word_at_snaps_left_from_whitespace_and_clamps() { +// A double-click on the gap right after a word snaps left and selects +// that word — the same left-scan that makes a double-click at the end +// of the line select the last word. +let mut e = ed("ab cd", 0); +e.select_word_at(2); // the space between the words +assert_eq!(e.selected_text().as_deref(), Some("ab")); +// Index at/past the end selects the trailing word, clamped. +e.select_word_at(99); +assert_eq!(e.selected_text().as_deref(), Some("cd")); +// On a gap wider than one cell there is no adjacent word to the left of +// the clicked cell: the empty range collapses to no selection. +let mut e = ed("ab cd", 0); +e.select_word_at(3); // second space: both neighbours are whitespace +⋮---- +fn clear_resets_line_cursor_and_undo_history() { +let mut e = ed("git push", 8); +e.clear(); +assert!(e.is_empty()); +⋮---- +// Undo after clear (post-submit) must not resurrect the shipped line. +⋮---- +fn delete_word_right_removes_following_word() { +let mut e = ed("git push", 0); +e.delete_word_right(); +assert_eq!(e.text(), " push"); +⋮---- +fn forward_word_delete_with_selection_leaves_no_out_of_range_slice() { +// Shift-select " cd" leftward (anchor=5, cursor=2), then Alt+Delete +// (delete_word_right) drains exactly that region, shrinking "ab cd" -> "ab" +// but historically leaving the anchor at 5. selected_text() (Cmd+C / Cmd+X) +// then sliced chars[2..5] on a length-2 Vec and panicked, crashing the app. +let mut e = ed("ab cd", 5); +e.extend_to(2); +⋮---- +// RED before the fix: selection() returns Some((2, 5)) and selected_text() +// panics slicing out of range. GREEN: the stale anchor is clamped away. +⋮---- +assert_eq!(e.selected_text(), None); +⋮---- +fn mid_buffer_word_delete_shifts_the_anchor_instead_of_faking_a_selection() { +// Regression: shift-select "def" leftward in "abc def x" (anchor=7, +// cursor=4), then Alt+Delete removes exactly that word *mid-buffer*. +// Clamping alone left anchor=7 → clamped to 6 → a phantom (4,6) +// selection over " x", text the user never selected (and ⌘C copied). +// Shifting the anchor across the removed range collapses it onto the +// cursor: no selection survives the deletion of its own text. +let mut e = ed("abc def x", 7); +e.extend_to(4); +assert_eq!(e.selected_text().as_deref(), Some("def")); +⋮---- +assert_eq!(e.text(), "abc x"); +⋮---- +fn deletions_before_a_selection_keep_it_on_the_same_text() { +// A range delete strictly before the selection shifts it left as a +// block, so it keeps covering the same characters. +let mut e = ed("one two THREE", 13); +e.extend_to(8); // select "THREE" (anchor=13, cursor=8) +assert_eq!(e.selected_text().as_deref(), Some("THREE")); +e.set_cursor(8); // collapse cursor at the selection start… keep anchor +e.delete_to_start(); // Ctrl+U wipes "one two " before it +assert_eq!(e.text(), "THREE"); +⋮---- +fn forward_word_delete_preserves_a_selection_it_did_not_touch() { +// Rightward selection "ab" (anchor=0, cursor=2); Alt+Delete removes the +// *following* word (" cd"), which doesn't overlap the selection, so the +// still-valid "ab" selection must survive (clamping is a no-op here). +⋮---- +fn set_with_cursor_sets_line_and_clamps() { +⋮---- +e.set_with_cursor("git status", 3); +assert_eq!((e.text().as_str(), e.cursor()), ("git status", 3)); +e.set_with_cursor("hi", 99); +assert_eq!((e.text().as_str(), e.cursor()), ("hi", 2)); // clamped +```` + +## File: src/terminal/completion.rs +````rust +//! A small, self-contained completion engine for the command editor — tty7's own +//! engine, not the shell's `compsys`. +⋮---- +//! engine, not the shell's `compsys`. +//! +⋮---- +//! +//! It offers two sources, each candidate carrying the exact char range it +⋮---- +//! It offers two sources, each candidate carrying the exact char range it +//! replaces: +⋮---- +//! replaces: +//! - **command** — builtins + `$PATH` executables, in command position; +⋮---- +//! - **command** — builtins + `$PATH` executables, in command position; +//! - **path** — files / directories, elsewhere (replace just the word). +⋮---- +//! - **path** — files / directories, elsewhere (replace just the word). +//! +⋮---- +//! +//! History deliberately does *not* feed the menu: +⋮---- +//! History deliberately does *not* feed the menu: +//! whole-line recall belongs to the inline ghost text (frecency-ranked, cwd +⋮---- +//! whole-line recall belongs to the inline ghost text (frecency-ranked, cwd +//! aware — accepted with → / Ctrl+F) and Ctrl+R search. Mixing recalled lines +⋮---- +//! aware — accepted with → / Ctrl+F) and Ctrl+R search. Mixing recalled lines +//! into the Tab menu buried the precise completions under near-duplicate path +⋮---- +//! into the Tab menu buried the precise completions under near-duplicate path +//! variants of past commands. +⋮---- +//! variants of past commands. +//! +⋮---- +//! +//! Pure and side-effect-free apart from reading the filesystem / `$PATH`, so the +⋮---- +//! Pure and side-effect-free apart from reading the filesystem / `$PATH`, so the +//! word-parsing and path logic are unit-tested directly. +⋮---- +//! word-parsing and path logic are unit-tested directly. +use std::collections::BTreeSet; +⋮---- +/// A word candidate before it's placed at a range. Signature-derived candidates +/// carry a `description` and possibly an `icon` (a raw Fig icon string — emoji or +⋮---- +/// carry a `description` and possibly an `icon` (a raw Fig icon string — emoji or +/// `fig://…`); `$PATH` and path candidates carry neither. +⋮---- +/// `fig://…`); `$PATH` and path candidates carry neither. +struct WordCand { +⋮---- +struct WordCand { +⋮---- +impl WordCand { +/// A candidate with no signature metadata — the command and path sources. + fn plain(text: String, kind: CandidateKind) -> Self { +⋮---- +fn plain(text: String, kind: CandidateKind) -> Self { +⋮---- +/// What a completion candidate refers to — drives both the trailing `/` for +/// directories and the menu's leading icon. +⋮---- +/// directories and the menu's leading icon. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CandidateKind { +/// A command name (builtin or `$PATH` executable). + Command, +/// A directory. + Dir, +/// A regular file. + File, +/// A command flag / option (e.g. `--message`), from a command signature. + Flag, +/// A subcommand or argument value, from a command signature. + Value, +⋮---- +/// A single completion candidate: the replacement text, its kind, and the char +/// range `[start, end)` in the original line that it replaces — just the word +⋮---- +/// range `[start, end)` in the original line that it replaces — just the word +/// under the cursor (`word_start..cursor`). +⋮---- +/// under the cursor (`word_start..cursor`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { +⋮---- +/// A one-line hint shown in a second column — the flag/subcommand's + /// description from its command signature; `None` for path/command candidates. +⋮---- +/// description from its command signature; `None` for path/command candidates. + pub description: Option, +/// Raw Fig icon string (emoji or `fig://…`) for signature candidates; the + /// view interprets it, falling back to a per-kind glyph. `None` otherwise. +⋮---- +/// view interprets it, falling back to a per-kind glyph. `None` otherwise. + pub icon: Option, +⋮---- +impl Candidate { +pub fn is_dir(&self) -> bool { +matches!(self.kind, CandidateKind::Dir) +⋮---- +/// The result of completing at a cursor: the word candidates, each with its own +/// replacement range. +⋮---- +/// replacement range. +#[derive(Debug)] +pub struct Completion { +⋮---- +/// Common shell builtins / keywords, offered in command position. Not exhaustive, +/// but covers what `$PATH` scanning misses (builtins aren't files). +⋮---- +/// but covers what `$PATH` scanning misses (builtins aren't files). +const BUILTINS: &[&str] = &[ +⋮---- +/// Cap on candidates returned, so a bare prefix that matches thousands of files +/// (or `$PATH` entries) can't blow up the UI or the cycle. +⋮---- +/// (or `$PATH` entries) can't blow up the UI or the cycle. +const MAX_CANDIDATES: usize = 400; +⋮---- +/// Compute completions for `line` at char position `cursor`, resolving relative +/// paths against `cwd`: command names in command position, filesystem paths +⋮---- +/// paths against `cwd`: command names in command position, filesystem paths +/// elsewhere. Returns `None` when there's nothing to offer. +⋮---- +/// elsewhere. Returns `None` when there's nothing to offer. +pub fn complete(line: &str, cursor: usize, cwd: &Path) -> Option { +⋮---- +pub fn complete(line: &str, cursor: usize, cwd: &Path) -> Option { +let chars: Vec = line.chars().collect(); +let cursor = cursor.min(chars.len()); +⋮---- +// The word under completion is the run of non-whitespace ending at the cursor. +⋮---- +while word_start > 0 && !chars[word_start - 1].is_whitespace() { +⋮---- +let word: String = chars[word_start..cursor].iter().collect(); +⋮---- +let is_command = chars[..word_start].iter().all(|c| c.is_whitespace()); +let word_cands = if is_command && !word.contains('/') { +complete_command(&word) +⋮---- +// In argument position, prefer a per-command signature (flags, +// subcommands, typed args) when the command has one; otherwise fall +// back to filesystem paths. +complete_signature(&chars, word_start, &word, cwd) +.unwrap_or_else(|| complete_path(&word, cwd)) +⋮---- +.into_iter() +.take(MAX_CANDIDATES) +.map(|wc| Candidate { +⋮---- +.collect(); +if candidates.is_empty() { +⋮---- +Some(Completion { candidates }) +⋮---- +/// Command-name completion: builtins plus `$PATH` executables starting with +/// `word`. An empty word returns nothing (we don't dump every command on a bare +⋮---- +/// `word`. An empty word returns nothing (we don't dump every command on a bare +/// Tab in command position). Ordered by closeness. +⋮---- +/// Tab in command position). Ordered by closeness. +fn complete_command(word: &str) -> Vec { +⋮---- +fn complete_command(word: &str) -> Vec { +if word.is_empty() { +⋮---- +if b.starts_with(word) { +set.insert((*b).to_string()); +⋮---- +for entry in rd.flatten() { +let name = entry.file_name().to_string_lossy().into_owned(); +if name.starts_with(word) { +set.insert(name); +if set.len() >= MAX_CANDIDATES { +⋮---- +let mut out: Vec = set.into_iter().take(MAX_CANDIDATES).collect(); +sort_by_closeness(&mut out); +out.into_iter() +.map(|t| WordCand::plain(t, CandidateKind::Command)) +.collect() +⋮---- +/// Order strings by closeness to what the user typed: since every candidate +/// shares the typed prefix, the edit distance is just the length still to fill +⋮---- +/// shares the typed prefix, the edit distance is just the length still to fill +/// in — so shorter completions come first, ties broken alphabetically. +⋮---- +/// in — so shorter completions come first, ties broken alphabetically. +fn sort_by_closeness(items: &mut [String]) { +⋮---- +fn sort_by_closeness(items: &mut [String]) { +items.sort_by(|a, b| { +a.chars() +.count() +.cmp(&b.chars().count()) +.then_with(|| a.cmp(b)) +⋮---- +/// Filesystem path completion. Splits `word` into the directory part (kept +/// verbatim in each candidate so the typed path prefix is preserved) and the +⋮---- +/// verbatim in each candidate so the typed path prefix is preserved) and the +/// final-segment prefix to match in that directory. Ordered by closeness. +⋮---- +/// final-segment prefix to match in that directory. Ordered by closeness. +fn complete_path(word: &str, cwd: &Path) -> Vec { +⋮---- +fn complete_path(word: &str, cwd: &Path) -> Vec { +// Split on the last path separator. `is_separator` is `/` on Unix and both +// `/` and `\` on Windows, so a `C:\Users\me\f`-style word splits correctly +// under the (future) Windows line editor; separators are ASCII so the byte +// slice boundaries are valid. +let (dir_part, prefix) = match word.rfind(std::path::is_separator) { +⋮---- +let base = resolve_dir(dir_part, cwd); +⋮---- +// Hidden entries only when the prefix explicitly starts with a dot. +if name.starts_with('.') && !prefix.starts_with('.') { +⋮---- +if !name.starts_with(prefix) { +⋮---- +let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); +⋮---- +out.push(WordCand::plain(format!("{dir_part}{name}"), kind)); +if out.len() >= MAX_CANDIDATES { +⋮---- +out.sort_by(|a, b| { +⋮---- +.chars() +⋮---- +.cmp(&b.text.chars().count()) +.then_with(|| a.text.cmp(&b.text)) +⋮---- +/// Signature-driven completion in argument position. Tokenizes the text before +/// the word into an argv, and — if the current command has a signature — offers +⋮---- +/// the word into an argv, and — if the current command has a signature — offers +/// flags, subcommands, or typed-argument suggestions for the cursor's position. +⋮---- +/// flags, subcommands, or typed-argument suggestions for the cursor's position. +/// +⋮---- +/// +/// Returns `None` (so the caller falls back to path completion) when the command +⋮---- +/// Returns `None` (so the caller falls back to path completion) when the command +/// has no signature, or when the position yields nothing useful and isn't a flag +⋮---- +/// has no signature, or when the position yields nothing useful and isn't a flag +/// or value slot (so a bare argument still lists files). +⋮---- +/// or value slot (so a bare argument still lists files). +fn complete_signature( +⋮---- +fn complete_signature( +⋮---- +// Only the current simple command matters: start after the last shell +// separator so `foo | git ` completes `git`, not `foo`. +let prefix: String = chars[..word_start].iter().collect(); +⋮---- +.rfind(['|', '&', ';', '\n', '(']) +.map(|i| i + 1) +.unwrap_or(0); +let tokens: Vec<&str> = prefix[seg_start..].split_whitespace().collect(); +let cmd = tokens.first()?; +⋮---- +let (node, pending_value) = walk_signature(&sig, &tokens[1..]); +⋮---- +// Flag position: options of the current node whose spelling extends `word`. +if word.starts_with('-') { +⋮---- +for opt in node.options() { +⋮---- +out.push(WordCand { +text: name.clone(), +⋮---- +description: opt.description.clone(), +icon: opt.icon.clone(), +⋮---- +return Some(finish(out)); +⋮---- +// Value position: the previous token was an option taking an argument. +⋮---- +push_arg_suggestions(&mut out, arg, word); +if arg.wants_paths() { +out.extend(complete_path(word, cwd)); +⋮---- +return if out.is_empty() { None } else { Some(out) }; +⋮---- +// Fresh token: subcommands of the current node plus its first positional arg. +⋮---- +for sub in node.subcommands() { +⋮---- +description: sub.description.clone(), +icon: sub.icon.clone(), +⋮---- +if let Some(arg) = node.args().first() { +⋮---- +if out.is_empty() { +⋮---- +Some(finish(out)) +⋮---- +/// Walk the argv after the command name, descending into matched subcommands and +/// skipping options (and the value tokens of value-taking ones). Returns the +⋮---- +/// skipping options (and the value tokens of value-taking ones). Returns the +/// deepest node reached, and — when the final prior token is a value-taking +⋮---- +/// deepest node reached, and — when the final prior token is a value-taking +/// option — the argument the cursor is now positioned to complete. +⋮---- +/// option — the argument the cursor is now positioned to complete. +fn walk_signature<'a>(sig: &'a Signature, rest: &[&str]) -> (&'a dyn CmdNode, Option<&'a Arg>) { +⋮---- +fn walk_signature<'a>(sig: &'a Signature, rest: &[&str]) -> (&'a dyn CmdNode, Option<&'a Arg>) { +⋮---- +while i < rest.len() { +⋮---- +if tok.starts_with('-') { +// Skip the flag, and its value token when it takes one inline-`=`-free. +if node.find_option(tok).is_some_and(|o| o.takes_arg()) && !tok.contains('=') { +⋮---- +if let Some(sub) = node.find_subcommand(tok) { +⋮---- +// A non-matching bare token is a positional arg; the node is unchanged. +⋮---- +// Is the cursor sitting on a value-taking option's value? +let pending = rest.last().and_then(|last| { +(last.starts_with('-') && !last.contains('=')) +.then(|| node.find_option(last)) +.flatten() +.filter(|o| o.takes_arg()) +.and_then(|o| o.args.first()) +⋮---- +/// Append an argument's static value suggestions matching `word`. +fn push_arg_suggestions(out: &mut Vec, arg: &Arg, word: &str) { +⋮---- +fn push_arg_suggestions(out: &mut Vec, arg: &Arg, word: &str) { +⋮---- +description: sug.description.clone(), +icon: sug.icon.clone(), +⋮---- +/// Dedupe by replacement text and order by closeness (shorter first, then +/// alphabetical) — the same ordering path completion uses. +⋮---- +/// alphabetical) — the same ordering path completion uses. +fn finish(mut out: Vec) -> Vec { +⋮---- +fn finish(mut out: Vec) -> Vec { +⋮---- +out.dedup_by(|a, b| a.text == b.text); +⋮---- +/// Resolve the directory portion of a path word to an absolute directory to list: +/// handles `~` expansion, absolute paths, and paths relative to `cwd`. +⋮---- +/// handles `~` expansion, absolute paths, and paths relative to `cwd`. +fn resolve_dir(dir_part: &str, cwd: &Path) -> PathBuf { +⋮---- +fn resolve_dir(dir_part: &str, cwd: &Path) -> PathBuf { +if dir_part.is_empty() { +return cwd.to_path_buf(); +⋮---- +if let Some(home) = home_dir() { +⋮---- +if let Some(rest) = dir_part.strip_prefix("~/") { +⋮---- +return home.join(rest); +⋮---- +if p.is_absolute() { p } else { cwd.join(p) } +⋮---- +/// The user's home directory: `$HOME` on Unix, falling back to `%USERPROFILE%` +/// on Windows (where `HOME` is usually unset). +⋮---- +/// on Windows (where `HOME` is usually unset). +fn home_dir() -> Option { +⋮---- +fn home_dir() -> Option { +⋮---- +.or_else(|| std::env::var_os("USERPROFILE")) +.map(PathBuf::from) +⋮---- +/// One open completion menu: a *picker* over the candidates gathered +/// when it opened. Moving the highlight (Tab / ↑ / ↓) never touches the editor +⋮---- +/// when it opened. Moving the highlight (Tab / ↑ / ↓) never touches the editor +/// line — the line changes only when a candidate is accepted (Enter) or when Tab +⋮---- +/// line — the line changes only when a candidate is accepted (Enter) or when Tab +/// fills the candidates' common prefix. Typing re-filters the same candidate set +⋮---- +/// fills the candidates' common prefix. Typing re-filters the same candidate set +/// via [`CompletionSession::refilter`]; the session ends once the word stops +⋮---- +/// via [`CompletionSession::refilter`]; the session ends once the word stops +/// extending the one it opened on. Fields are `pub(super)` so the terminal view +⋮---- +/// extending the one it opened on. Fields are `pub(super)` so the terminal view +/// can render the menu. +⋮---- +/// can render the menu. +pub(super) struct CompletionSession { +⋮---- +pub(super) struct CompletionSession { +/// Char index where the word under completion starts — the fixed left edge + /// of the range an accept replaces (the right edge is the live caret). +⋮---- +/// of the range an accept replaces (the right edge is the live caret). + pub(super) word_start: usize, +/// The word as typed when the menu opened (before any common-prefix fill). + /// Backspacing below it closes the menu. +⋮---- +/// Backspacing below it closes the menu. + pub(super) open_word: String, +/// Every candidate from open time; `filtered` holds indices into this. + pub(super) all: Vec, +/// Indices into `all` still prefix-matching the live word, in order. + pub(super) filtered: Vec, +/// Highlighted row (an index into `filtered`). + pub(super) index: Option, +⋮---- +/// A splice to apply to the command editor: replace chars `[start, end)` of +/// `orig` with `text`. Used by the view's accept / prefix-fill paths; kept +⋮---- +/// `orig` with `text`. Used by the view's accept / prefix-fill paths; kept +/// separate so the pure string edit is testable without a live editor. +⋮---- +/// separate so the pure string edit is testable without a live editor. +pub(super) struct Replacement { +⋮---- +pub(super) struct Replacement { +⋮---- +impl Replacement { +/// Perform the splice: returns the new line and the caret position (just after + /// the inserted text). Char-indexed and clamped, so out-of-range candidate +⋮---- +/// the inserted text). Char-indexed and clamped, so out-of-range candidate + /// offsets can never panic. +⋮---- +/// offsets can never panic. + pub(super) fn apply(&self) -> (String, usize) { +⋮---- +pub(super) fn apply(&self) -> (String, usize) { +let mut chars: Vec = self.orig.chars().collect(); +let start = self.start.min(chars.len()); +let end = self.end.min(chars.len()).max(start); +let ins: Vec = self.text.chars().collect(); +let new_cursor = start + ins.len(); +chars.splice(start..end, ins); +(chars.into_iter().collect(), new_cursor) +⋮---- +impl CompletionSession { +/// Open a menu over `all` with the first row highlighted (a default + /// preselection, so a bare Enter accepts the top pick). +⋮---- +/// preselection, so a bare Enter accepts the top pick). + pub(super) fn new(word_start: usize, open_word: String, all: Vec) -> Self { +⋮---- +pub(super) fn new(word_start: usize, open_word: String, all: Vec) -> Self { +let filtered = (0..all.len()).collect(); +⋮---- +index: Some(0), +⋮---- +/// The highlighted candidate, if any. + pub(super) fn selected(&self) -> Option<&Candidate> { +⋮---- +pub(super) fn selected(&self) -> Option<&Candidate> { +⋮---- +.and_then(|i| self.filtered.get(i)) +.map(|&i| &self.all[i]) +⋮---- +/// Move the highlight to the next (`forward`) or previous row, wrapping. + /// Selection is visual only — the editor line changes on accept. +⋮---- +/// Selection is visual only — the editor line changes on accept. + pub(super) fn select(&mut self, forward: bool) { +⋮---- +pub(super) fn select(&mut self, forward: bool) { +let n = self.filtered.len(); +⋮---- +self.index = Some(match self.index { +⋮---- +/// Re-filter for the live `word`. Returns `false` when the menu should + /// close: the word no longer extends the one it opened on (backspaced past +⋮---- +/// close: the word no longer extends the one it opened on (backspaced past + /// it) or nothing matches any more. A highlighted candidate that survives +⋮---- +/// it) or nothing matches any more. A highlighted candidate that survives + /// the filter keeps its highlight; one filtered away falls back to the top. +⋮---- +/// the filter keeps its highlight; one filtered away falls back to the top. + pub(super) fn refilter(&mut self, word: &str) -> bool { +⋮---- +pub(super) fn refilter(&mut self, word: &str) -> bool { +if !word.starts_with(self.open_word.as_str()) { +⋮---- +let selected_all = self.index.and_then(|i| self.filtered.get(i)).copied(); +self.filtered = (0..self.all.len()) +.filter(|&i| self.all[i].text.starts_with(word)) +⋮---- +if self.filtered.is_empty() { +⋮---- +let kept = selected_all.and_then(|a| self.filtered.iter().position(|&i| i == a)); +self.index = Some(kept.unwrap_or(0)); +⋮---- +/// Longest common prefix (in chars) of the filtered candidates — what Tab + /// fills before it starts moving the highlight. +⋮---- +/// fills before it starts moving the highlight. + pub(super) fn common_prefix(&self) -> Option { +⋮---- +pub(super) fn common_prefix(&self) -> Option { +let mut texts = self.filtered.iter().map(|&i| self.all[i].text.as_str()); +let mut lcp: Vec = texts.next()?.chars().collect(); +⋮---- +.iter() +.zip(t.chars()) +.take_while(|(a, b)| **a == *b) +.count(); +lcp.truncate(shared); +if lcp.is_empty() { +⋮---- +Some(lcp.into_iter().collect()) +⋮---- +mod tests { +⋮---- +fn cand(text: &str, kind: CandidateKind, start: usize, end: usize) -> Candidate { +⋮---- +text: text.into(), +⋮---- +/// The candidate texts `complete` returns for `line` with the cursor at the + /// end, or an empty vec when it offers nothing. +⋮---- +/// end, or an empty vec when it offers nothing. + fn texts(line: &str) -> Vec { +⋮---- +fn texts(line: &str) -> Vec { +complete(line, line.chars().count(), Path::new("/")) +.map(|c| c.candidates.into_iter().map(|c| c.text).collect()) +.unwrap_or_default() +⋮---- +fn signature_offers_subcommands_after_command() { +let t = texts("git "); +assert!(t.iter().any(|s| s == "commit"), "git subcommands: {t:?}"); +assert!(t.iter().any(|s| s == "status")); +// Descriptions ride along for the menu's second column. +let c = complete("git ", 4, Path::new("/")).unwrap(); +let commit = c.candidates.iter().find(|c| c.text == "commit").unwrap(); +assert_eq!(commit.kind, CandidateKind::Value); +assert!(commit.description.is_some()); +⋮---- +fn signature_narrows_subcommands_by_prefix() { +let t = texts("git comm"); +assert!(t.iter().any(|s| s == "commit")); +assert!(t.iter().all(|s| s.starts_with("comm")), "only comm*: {t:?}"); +⋮---- +fn signature_offers_flags_for_the_active_subcommand() { +let c = complete("git commit --", 13, Path::new("/")).unwrap(); +let msg = c.candidates.iter().find(|c| c.text == "--message").unwrap(); +assert_eq!(msg.kind, CandidateKind::Flag); +assert_eq!( +⋮---- +fn signature_resolves_nested_subcommands() { +// docker compose was grafted in via loadSpec; its subcommands complete. +let t = texts("docker compose "); +assert!( +⋮---- +fn unknown_command_falls_back_to_paths() { +// A command with no signature still path-completes (no panic, no menu here). +let dir = temp_tree("fallback", &[("readme.md", false)]); +let c = complete("frobnicate read", "frobnicate read".chars().count(), &dir).unwrap(); +assert_eq!(c.candidates[0].text, "readme.md"); +⋮---- +fn replacement_splices_over_the_char_range() { +⋮---- +orig: "cd sr".into(), +⋮---- +text: "src/".into(), +⋮---- +.apply(); +assert_eq!(line, "cd src/"); +assert_eq!(cursor, 7); +⋮---- +fn replacement_clamps_out_of_range_offsets_without_panicking() { +⋮---- +orig: "ab".into(), +⋮---- +text: "X".into(), +⋮---- +assert_eq!(line, "abX"); +assert_eq!(cursor, 3); +⋮---- +fn session(words: &[&str]) -> CompletionSession { +⋮---- +.map(|w| cand(w, CandidateKind::Command, 0, 1)) +⋮---- +CompletionSession::new(0, "a".into(), cands) +⋮---- +fn select_moves_the_highlight_and_wraps_without_touching_candidates() { +let mut s = session(&["aa", "ab", "ac"]); +assert_eq!(s.index, Some(0)); // first row preselected on open +s.select(true); +assert_eq!(s.index, Some(1)); +⋮---- +assert_eq!(s.index, Some(0)); // wraps forward +s.select(false); +assert_eq!(s.index, Some(2)); // wraps backward +assert_eq!(s.selected().unwrap().text, "ac"); +⋮---- +fn refilter_narrows_keeps_surviving_highlight_and_closes_when_stale() { +let mut s = session(&["aa", "ab", "abc"]); +s.select(true); // highlight "ab" +assert!(s.refilter("ab")); +// "aa" filtered out; the highlighted "ab" survives and keeps its highlight. +assert_eq!(s.filtered.len(), 2); +assert_eq!(s.selected().unwrap().text, "ab"); +// A word that no longer extends the open word closes the menu… +assert!(!s.refilter("")); +// …as does one nothing matches. +let mut s = session(&["aa", "ab"]); +assert!(!s.refilter("az")); +⋮---- +fn refilter_falls_back_to_the_top_when_the_highlight_is_filtered_away() { +⋮---- +// Highlight "aa", then type "ab" — "aa" drops out, top row takes over. +assert_eq!(s.selected().unwrap().text, "aa"); +⋮---- +fn common_prefix_spans_the_filtered_candidates() { +let mut s = session(&["apple.txt", "apply.sh", "apricot"]); +assert_eq!(s.common_prefix().unwrap(), "ap"); +assert!(s.refilter("app")); +assert_eq!(s.common_prefix().unwrap(), "appl"); +⋮---- +fn temp_tree(tag: &str, entries: &[(&str, bool)]) -> PathBuf { +let dir = std::env::temp_dir().join(format!("tty7-comp-{}-{}", std::process::id(), tag)); +⋮---- +std::fs::create_dir_all(&dir).unwrap(); +⋮---- +std::fs::create_dir_all(dir.join(name)).unwrap(); +⋮---- +std::fs::write(dir.join(name), b"").unwrap(); +⋮---- +fn command_position_offers_builtins_with_word_range() { +let c = complete("ech", 3, Path::new("/")).unwrap(); +let echo = c.candidates.iter().find(|c| c.text == "echo").unwrap(); +assert_eq!(echo.kind, CandidateKind::Command); +assert_eq!((echo.start, echo.end), (0, 3)); // replaces the word "ech" +⋮---- +fn path_completion_matches_prefix_and_flags_dirs() { +let dir = temp_tree( +⋮---- +let c = complete(line, line.chars().count(), &dir).unwrap(); +let names: Vec<&str> = c.candidates.iter().map(|c| c.text.as_str()).collect(); +// Closeness order: assets(6) < apply.sh(8) < apple.txt(9). +assert_eq!(names, vec!["assets", "apply.sh", "apple.txt"]); +let assets = c.candidates.iter().find(|c| c.text == "assets").unwrap(); +assert!(assets.is_dir()); +assert_eq!((assets.start, assets.end), (4, 5)); // the "a" word +⋮---- +fn path_completion_keeps_dir_prefix_in_candidate() { +let dir = temp_tree("nested", &[("sub", true)]); +std::fs::write(dir.join("sub/file.rs"), b"").unwrap(); +⋮---- +assert_eq!(c.candidates[0].text, "sub/file.rs"); +assert_eq!(c.candidates[0].start, 4); +⋮---- +fn hidden_files_only_with_dot_prefix() { +let dir = temp_tree("hidden", &[(".secret", false), ("visible", false)]); +let c = complete("ls v", 4, &dir).unwrap(); +assert!(c.candidates.iter().all(|c| !c.text.starts_with('.'))); +let c = complete("ls .", 4, &dir).unwrap(); +assert!(c.candidates.iter().any(|c| c.text == ".secret")); +⋮---- +fn candidates_ordered_by_closeness_then_alpha() { +⋮---- +assert_eq!(names, vec!["xa", "xy", "xyz", "xyzzy"]); +⋮---- +fn no_candidates_returns_none() { +let dir = temp_tree("empty", &[("zzz", false)]); +assert!(complete("cat q", 5, &dir).is_none()); +// A blank line offers nothing (no dump of every command on bare Tab). +assert!(complete("", 0, &dir).is_none()); +assert!(complete(" ", 3, &dir).is_none()); +⋮---- +fn mid_line_cursor_completes_only_the_word_before_it() { +// Caret sits right after "ap" with more text following; the candidate +// replaces only `word_start..cursor`, leaving the tail untouched. +let dir = temp_tree("midline", &[("apple.txt", false)]); +let c = complete("cat ap x.log", 6, &dir).unwrap(); +let apple = c.candidates.iter().find(|c| c.text == "apple.txt").unwrap(); +assert_eq!((apple.start, apple.end), (4, 6)); +// Applying it splices over just that range. +⋮---- +orig: "cat ap x.log".into(), +⋮---- +text: apple.text.clone(), +⋮---- +assert_eq!(line, "cat apple.txt x.log"); +assert_eq!(cursor, 13); +⋮---- +fn sort_by_closeness_orders_by_length_then_alpha() { +let mut items = vec![ +⋮---- +sort_by_closeness(&mut items); +// Shorter first; equal-length ties broken alphabetically. +assert_eq!(items, vec!["xa", "xb", "xyz", "xyzzy"]); +⋮---- +fn resolve_dir_handles_empty_absolute_and_relative() { +⋮---- +// Empty dir part → the cwd itself. +assert_eq!(resolve_dir("", cwd), PathBuf::from("/work/proj")); +// An absolute dir part is taken verbatim. +assert_eq!(resolve_dir("/etc/", cwd), PathBuf::from("/etc/")); +// A relative dir part is joined onto the cwd. +assert_eq!(resolve_dir("src/", cwd), PathBuf::from("/work/proj/src/")); +⋮---- +fn resolve_dir_expands_tilde_to_home() { +// Read the real home (no env mutation, so parallel tests aren't disturbed); +// the `~` branches must resolve against it. +⋮---- +assert_eq!(resolve_dir("~", cwd), home); +assert_eq!(resolve_dir("~/", cwd), home.clone()); +assert_eq!(resolve_dir("~/dev/", cwd), home.join("dev/")); +```` + +## File: src/terminal/fps.rs +````rust +//! Optional per-frame paint timing. Disabled unless `TTY7_FPS` is set to a +//! non-empty, non-`0` value (e.g. `TTY7_FPS=1 cargo run`). +⋮---- +//! non-empty, non-`0` value (e.g. `TTY7_FPS=1 cargo run`). +//! +⋮---- +//! +//! gpui repaints *on demand* — it only paints when something is marked dirty +⋮---- +//! gpui repaints *on demand* — it only paints when something is marked dirty +//! via `cx.notify()`. So this deliberately does NOT report a steady 120fps +⋮---- +//! via `cx.notify()`. So this deliberately does NOT report a steady 120fps +//! while the terminal is idle; idle frames are zero by design, and that's the +⋮---- +//! while the terminal is idle; idle frames are zero by design, and that's the +//! whole point of the architecture. What it measures is: +⋮---- +//! whole point of the architecture. What it measures is: +//! - how fast a single paint is on the CPU side (`paint avg/max`), and +⋮---- +//! - how fast a single paint is on the CPU side (`paint avg/max`), and +//! - the frame rate actually achieved during *continuous* output or +⋮---- +//! - the frame rate actually achieved during *continuous* output or +//! scrolling (e.g. `yes`, `cat bigfile`), which is where "do we hit the +⋮---- +//! scrolling (e.g. `yes`, `cat bigfile`), which is where "do we hit the +//! display's refresh rate?" is a meaningful question. +⋮---- +//! display's refresh rate?" is a meaningful question. +//! +⋮---- +//! +//! Note this is the CPU-side cost of building the frame and enqueuing draw +⋮---- +//! Note this is the CPU-side cost of building the frame and enqueuing draw +//! commands; it does not include GPU execution. For true end-to-end frame +⋮---- +//! commands; it does not include GPU execution. For true end-to-end frame +//! rate, pair this with Instruments → Core Animation FPS / Metal System Trace. +⋮---- +//! rate, pair this with Instruments → Core Animation FPS / Metal System Trace. +⋮---- +/// Whether timing is on. Read once from `TTY7_FPS` and cached. +pub fn enabled() -> bool { +⋮---- +pub fn enabled() -> bool { +⋮---- +*ON.get_or_init(|| flag_enables(std::env::var("TTY7_FPS").ok().as_deref())) +⋮---- +/// Whether a `TTY7_FPS` value (or its absence) turns timing on: any non-empty +/// value except `0`. Split from `enabled` so the semantics are testable without +⋮---- +/// value except `0`. Split from `enabled` so the semantics are testable without +/// depending on the ambient process environment. +⋮---- +/// depending on the ambient process environment. +fn flag_enables(value: Option<&str>) -> bool { +⋮---- +fn flag_enables(value: Option<&str>) -> bool { +value.is_some_and(|v| !v.is_empty() && v != "0") +⋮---- +/// Length of one aggregation window of wall-clock time *in which painting +/// happened* (an idle gap just stretches the reported window, so it reads +⋮---- +/// happened* (an idle gap just stretches the reported window, so it reads +/// honestly rather than as a low frame rate). +⋮---- +/// honestly rather than as a low frame rate). +const WINDOW: Duration = Duration::from_secs(1); +⋮---- +struct Meter { +⋮---- +impl Meter { +fn new(window_start: Instant) -> Self { +⋮---- +/// Fold one frame in; when `now` crosses the window boundary, return the + /// aggregate report line and start a fresh window anchored at `now`. The +⋮---- +/// aggregate report line and start a fresh window anchored at `now`. The + /// clock is injected so tests can cross windows without sleeping. +⋮---- +/// clock is injected so tests can cross windows without sleeping. + fn record(&mut self, now: Instant, paint: Duration) -> Option { +⋮---- +fn record(&mut self, now: Instant, paint: Duration) -> Option { +⋮---- +self.paint_max = self.paint_max.max(paint); +⋮---- +let elapsed = now.duration_since(self.window_start); +⋮---- +let secs = elapsed.as_secs_f64(); +⋮---- +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!( +⋮---- +Some(line) +⋮---- +fn meter() -> &'static Mutex> { +⋮---- +M.get_or_init(|| Mutex::new(None)) +⋮---- +/// Record one frame's CPU-side paint duration. Emits an aggregate stderr line +/// roughly once per `WINDOW` of painting time. +⋮---- +/// roughly once per `WINDOW` of painting time. +pub fn record(paint: Duration) { +⋮---- +pub fn record(paint: Duration) { +⋮---- +let mut guard = meter().lock().unwrap(); +let m = guard.get_or_insert_with(|| Meter::new(now)); +if let Some(line) = m.record(now, paint) { +// Direct to stderr: the app never initialises a `log` backend, so +// `log::info!` here would be silently dropped. +eprintln!("{line}"); +⋮---- +mod tests { +⋮---- +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"))); +⋮---- +fn meter_accumulates_silently_below_the_window() { +⋮---- +assert_eq!( +⋮---- +assert_eq!(m.frames, 2, "both frames folded into the open window"); +⋮---- +fn meter_flushes_and_resets_after_a_window() { +⋮---- +assert!( +⋮---- +// Crossing the window boundary flushes the aggregate: 3 frames over +// 1.5s = 2.0 fps, paint avg (2+6+4)/3 = 4ms, max 6ms. +⋮---- +.record(flush_at, Duration::from_millis(4)) +.expect("crossing the window emits the aggregate line"); +⋮---- +// The flush starts a fresh window anchored at the flush instant. +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); +⋮---- +fn meter_flushes_exactly_on_the_window_boundary() { +// `elapsed == WINDOW` counts as crossing (the check is `<`), so a frame +// landing exactly on the boundary flushes rather than being held over. +⋮---- +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)")); +```` + +## File: src/terminal/fuzzy.rs +````rust +//! Fuzzy subsequence matching for the Ctrl+R history search. +//! +⋮---- +//! +//! A small affine-gap aligner in the fzf/skim family: every query character +⋮---- +//! A small affine-gap aligner in the fzf/skim family: every query character +//! must appear in the haystack in order (a subsequence), and the returned score +⋮---- +//! must appear in the haystack in order (a subsequence), and the returned score +//! rewards runs of consecutive matches and matches at word boundaries while +⋮---- +//! rewards runs of consecutive matches and matches at word boundaries while +//! penalizing gaps — so `gst` prefers `git status` over `grep -rn "s" tests`. +⋮---- +//! penalizing gaps — so `gst` prefers `git status` over `grep -rn "s" tests`. +//! The matched character positions come back too, so the menu can highlight +⋮---- +//! The matched character positions come back too, so the menu can highlight +//! exactly which characters matched. +⋮---- +//! exactly which characters matched. +//! +⋮---- +//! +//! Whitespace in the query splits it into terms that must *all* match +⋮---- +//! Whitespace in the query splits it into terms that must *all* match +//! (anywhere, in any order) — `git push` finds `git push -f origin` but also +⋮---- +//! (anywhere, in any order) — `git push` finds `git push -f origin` but also +//! `push-all git-mirrors`. Matching is always case-insensitive, like the +⋮---- +//! `push-all git-mirrors`. Matching is always case-insensitive, like the +//! substring search this replaces. +⋮---- +//! substring search this replaces. +//! +⋮---- +//! +//! Kept dependency-free on purpose: command lines are short, so the O(m×n) +⋮---- +//! Kept dependency-free on purpose: command lines are short, so the O(m×n) +//! dynamic program is comfortably cheap even against thousands of history +⋮---- +//! dynamic program is comfortably cheap even against thousands of history +//! entries per keystroke. +⋮---- +//! entries per keystroke. +/// A successful match: the alignment score (higher is better; only comparable +/// between matches of the *same query*) and the matched char indices into the +⋮---- +/// between matches of the *same query*) and the matched char indices into the +/// haystack, ascending and deduplicated. +⋮---- +/// haystack, ascending and deduplicated. +pub(super) struct FuzzyMatch { +⋮---- +pub(super) struct FuzzyMatch { +⋮---- +/// Every matched character is worth this much before bonuses. +const SCORE_MATCH: i32 = 16; +/// Bonus for a match at a word boundary (start of the line, or right after a +/// separator) — `st` should land on the `status` in `git status`. +⋮---- +/// separator) — `st` should land on the `status` in `git status`. +const BONUS_BOUNDARY: i32 = 12; +/// Bonus for extending a run of consecutive matches — favours tight matches +/// over the same letters scattered across the line. Deliberately worth more +⋮---- +/// over the same letters scattered across the line. Deliberately worth more +/// than a boundary bonus reached across a gap (`BONUS_BOUNDARY + +⋮---- +/// than a boundary bonus reached across a gap (`BONUS_BOUNDARY + +/// PENALTY_GAP_START = 9`), so `ab` still prefers the literal `ab` over the +⋮---- +/// PENALTY_GAP_START = 9`), so `ab` still prefers the literal `ab` over the +/// two word heads of `a-b`. +⋮---- +/// two word heads of `a-b`. +const BONUS_CONSECUTIVE: i32 = 10; +/// Cost of opening a gap between two matched characters… +const PENALTY_GAP_START: i32 = -3; +/// …and of each further character that gap skips. +const PENALTY_GAP_EXTEND: i32 = -1; +⋮---- +/// "Impossible" sentinel. Kept far from `i32::MIN` so adding penalties/bonuses +/// to a sentinel value can never wrap around into a plausible score. +⋮---- +/// to a sentinel value can never wrap around into a plausible score. +const NEG: i32 = i32::MIN / 2; +⋮---- +/// Match `query` against `line`. Whitespace splits the query into terms which +/// must all match; scores add up and positions merge. `None` when the query is +⋮---- +/// must all match; scores add up and positions merge. `None` when the query is +/// blank or any term fails to match. +⋮---- +/// blank or any term fails to match. +pub(super) fn match_line(line: &str, query: &str) -> Option { +⋮---- +pub(super) fn match_line(line: &str, query: &str) -> Option { +let terms: Vec<&str> = query.split_whitespace().collect(); +if terms.is_empty() { +⋮---- +let hay: Vec = line.chars().collect(); +let hay_lc: Vec = hay.iter().map(|&c| lc(c)).collect(); +let bonus: Vec = (0..hay.len()) +.map(|j| char_bonus(if j == 0 { None } else { Some(hay[j - 1]) })) +.collect(); +⋮---- +let t: Vec = term.chars().map(lc).collect(); +let (s, pos) = match_term(&hay_lc, &bonus, &t)?; +⋮---- +positions.extend(pos); +⋮---- +Some(FuzzyMatch { +⋮---- +positions: positions.into_iter().collect(), +⋮---- +/// Lowercase a char for comparison (first mapping only — `ß`→`ss` expansions +/// don't matter for scoring command lines). +⋮---- +/// don't matter for scoring command lines). +fn lc(c: char) -> char { +⋮---- +fn lc(c: char) -> char { +c.to_lowercase().next().unwrap_or(c) +⋮---- +/// The word-boundary bonus a match at a position earns, given the preceding +/// character (`None` at the start of the line). +⋮---- +/// character (`None` at the start of the line). +fn char_bonus(prev: Option) -> i32 { +⋮---- +fn char_bonus(prev: Option) -> i32 { +⋮---- +if c.is_whitespace() || matches!(c, '/' | '-' | '_' | '.' | ':' | '=' | ',' | '\\') => +⋮---- +/// Align one lowercased `term` against the lowercased haystack, returning the +/// best score and the matched positions. Classic affine-gap DP: +⋮---- +/// best score and the matched positions. Classic affine-gap DP: +/// `m[i][j]` is the best score with `term[i]` matched at `hay[j]`, reachable +⋮---- +/// `m[i][j]` is the best score with `term[i]` matched at `hay[j]`, reachable +/// either consecutively from `m[i-1][j-1]` or across a gap (tracked by a +⋮---- +/// either consecutively from `m[i-1][j-1]` or across a gap (tracked by a +/// running per-row maximum so each cell is O(1)); `parent[i][j]` remembers the +⋮---- +/// running per-row maximum so each cell is O(1)); `parent[i][j]` remembers the +/// chosen predecessor for the backtrack that recovers the positions. +⋮---- +/// chosen predecessor for the backtrack that recovers the positions. +fn match_term(hay_lc: &[char], bonus: &[i32], term: &[char]) -> Option<(i32, Vec)> { +⋮---- +fn match_term(hay_lc: &[char], bonus: &[i32], term: &[char]) -> Option<(i32, Vec)> { +let (m, n) = (term.len(), hay_lc.len()); +⋮---- +let mut score = vec![NEG; m * n]; +let mut parent = vec![usize::MAX; m * n]; +⋮---- +// Best gapped predecessor for the current j: max over k ≤ j-2 of +// `score[i-1][k]` plus the affine penalty for the k→j gap. +⋮---- +(cons, j.wrapping_sub(1)) +⋮---- +// Best end position for the last term char; ties go to the earliest. +⋮---- +let mut positions = vec![0usize; m]; +⋮---- +for i in (0..m).rev() { +⋮---- +Some((best, positions)) +⋮---- +mod tests { +⋮---- +fn score(line: &str, query: &str) -> i32 { +match_line(line, query).expect("expected a match").score +⋮---- +fn positions(line: &str, query: &str) -> Vec { +match_line(line, query).expect("expected a match").positions +⋮---- +fn non_subsequence_is_no_match() { +assert!(match_line("git status", "xyz").is_none()); +assert!(match_line("ls", "lss").is_none()); // longer than the line +assert!(match_line("git status", "tg").is_none()); // out of order +⋮---- +fn blank_query_is_no_match() { +assert!(match_line("git status", "").is_none()); +assert!(match_line("git status", " ").is_none()); +⋮---- +fn matching_is_case_insensitive() { +assert_eq!(score("Git Status", "git"), score("git status", "GIT")); +assert!(match_line("MAKE ALL", "make").is_some()); +⋮---- +fn consecutive_run_beats_scattered_letters() { +// Both contain g,i,t as a subsequence; only one has them adjacent. +assert!(score("git log", "git") > score("going to lunch", "git")); +⋮---- +fn word_boundary_beats_mid_word() { +// `st` at the start of "status" (after a space) vs inside "faster". +assert!(score("git status", "st") > score("faster", "st")); +⋮---- +fn positions_pick_the_best_alignment() { +// `gs` should land on the `g` of git and the boundary `s` of status, +// not some later `s`. +assert_eq!(positions("git status", "gs"), vec![0, 4]); +// A consecutive alignment is recovered exactly. +assert_eq!(positions("cargo build", "build"), vec![6, 7, 8, 9, 10]); +⋮---- +fn multi_term_queries_must_all_match_and_merge_positions() { +// Terms match independently (order-free) and positions merge sorted. +let m = match_line("git push --force origin", "push git").unwrap(); +assert_eq!(m.positions, vec![0, 1, 2, 4, 5, 6, 7]); +// One term failing fails the whole query. +assert!(match_line("git push", "git nope").is_none()); +⋮---- +fn gaps_are_penalized_by_length() { +// Same letters, tighter gap scores higher. +assert!(score("ab", "ab") > score("a-b", "ab")); +assert!(score("a-b", "ab") > score("a---------b", "ab")); +⋮---- +fn unicode_haystacks_match_by_char() { +// Positions are char indices, not bytes: the CJK prefix occupies +// char cells 0..2, so `ls` lands at 3..=4. +assert_eq!(positions("构建 ls", "ls"), vec![3, 4]); +```` + +## File: src/terminal/highlight.rs +````rust +//! A small shell-command syntax highlighter for the command editor — tty7's own +//! highlighter, independent of any zsh highlighting plugin. +⋮---- +//! highlighter, independent of any zsh highlighting plugin. +//! +⋮---- +//! +//! It splits a line into contiguous spans whose concatenated text reproduces the +⋮---- +//! It splits a line into contiguous spans whose concatenated text reproduces the +//! input exactly (whitespace included), tagging each with a [`TokenKind`] the +⋮---- +//! input exactly (whitespace included), tagging each with a [`TokenKind`] the +//! renderer maps to a color. The grammar is deliberately shallow — enough to +⋮---- +//! renderer maps to a color. The grammar is deliberately shallow — enough to +//! color commands, arguments, flags, paths, quoted strings, operators and +⋮---- +//! color commands, arguments, flags, paths, quoted strings, operators and +//! comments — not a real shell parser. +⋮---- +//! comments — not a real shell parser. +/// What a span of the command line represents, for coloring. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenKind { +/// A command name: the first word, and the first word after a `|`/`&&`/`;`. + Command, +/// A plain argument. + Arg, +/// A `-f` / `--flag` option. + Flag, +/// A word containing `/` (treated as a path). + Path, +/// A single- or double-quoted string (quotes included). + StringLit, +/// A shell operator: `| & ; < >` (and runs like `&&`, `||`, `>>`). + Operator, +/// A `# …` comment to end of line. + Comment, +/// Inter-token whitespace (kept so spans tile the whole line). + Whitespace, +⋮---- +/// A contiguous run of the line with a single [`TokenKind`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Span { +⋮---- +fn is_operator(c: char) -> bool { +matches!(c, '|' | '&' | ';' | '<' | '>') +⋮---- +/// Split `line` into colored spans. Concatenating the spans' `text` yields `line`. +pub fn highlight(line: &str) -> Vec { +⋮---- +pub fn highlight(line: &str) -> Vec { +let chars: Vec = line.chars().collect(); +let n = chars.len(); +⋮---- +// The next bare word is a command at the start of the line and right after a +// pipe / list operator. +⋮---- +if c.is_whitespace() { +⋮---- +while i < n && chars[i].is_whitespace() { +⋮---- +spans.push(Span { +text: chars[start..i].iter().collect(), +⋮---- +// Comment to end of line. +⋮---- +text: chars[i..].iter().collect(), +⋮---- +if is_operator(c) { +⋮---- +while i < n && is_operator(chars[i]) { +⋮---- +expect_command = true; // a command follows the operator +⋮---- +i += 1; // include the closing quote +⋮---- +// A bare word: up to the next whitespace / operator / quote / comment. +⋮---- +&& !chars[i].is_whitespace() +&& !is_operator(chars[i]) +&& !matches!(chars[i], '\'' | '"' | '#') +⋮---- +let word: String = chars[start..i].iter().collect(); +⋮---- +} else if word.starts_with('-') { +⋮---- +} else if word.contains('/') { +⋮---- +spans.push(Span { text: word, kind }); +⋮---- +mod tests { +⋮---- +fn kinds(line: &str) -> Vec<(String, TokenKind)> { +highlight(line) +.into_iter() +.map(|s| (s.text, s.kind)) +.collect() +⋮---- +/// Spans must tile the line exactly. + fn assert_tiles(line: &str) { +⋮---- +fn assert_tiles(line: &str) { +let joined: String = highlight(line).into_iter().map(|s| s.text).collect(); +assert_eq!(joined, line); +⋮---- +fn command_args_flags_strings() { +assert_tiles("git commit -m \"a msg\""); +let k = kinds("git commit -m \"a msg\""); +assert_eq!(k[0], ("git".into(), TokenKind::Command)); +assert_eq!(k[2], ("commit".into(), TokenKind::Arg)); +assert_eq!(k[4], ("-m".into(), TokenKind::Flag)); +assert_eq!(k[6], ("\"a msg\"".into(), TokenKind::StringLit)); +⋮---- +fn command_resets_after_pipe_and_operators() { +let k = kinds("cat f | grep x"); +assert_eq!(k[0].1, TokenKind::Command); // cat +assert_eq!(k[2].1, TokenKind::Arg); // f +assert_eq!(k[4].1, TokenKind::Operator); // | +assert_eq!(k[6].1, TokenKind::Command); // grep (command after pipe) +assert_tiles("cat f | grep x"); +⋮---- +fn paths_and_comments() { +let k = kinds("ls src/main.rs # look"); +assert_eq!(k[0].1, TokenKind::Command); +assert_eq!(k[2].1, TokenKind::Path); // src/main.rs +assert!(k.iter().any(|(_, kind)| *kind == TokenKind::Comment)); +assert_tiles("ls src/main.rs # look"); +⋮---- +fn unterminated_quote_consumes_rest() { +assert_tiles("echo \"open"); +let k = kinds("echo \"open"); +assert_eq!(k.last().unwrap().1, TokenKind::StringLit); +⋮---- +fn double_operator_runs() { +let k = kinds("a && b"); +assert_eq!(k[2], ("&&".into(), TokenKind::Operator)); +assert_eq!(k[4].1, TokenKind::Command); +assert_tiles("a && b"); +⋮---- +fn command_position_wins_over_flag_and_path_shapes() { +// The first word is always a Command, even when it looks like a flag or +// a path — command position takes precedence in the classifier. +assert_eq!(kinds("-v")[0], ("-v".into(), TokenKind::Command)); +assert_eq!( +⋮---- +// Off command position the same shapes classify as Flag / Path. +let k = kinds("ls -v ./run.sh"); +assert_eq!(k[2].1, TokenKind::Flag); +assert_eq!(k[4].1, TokenKind::Path); +⋮---- +fn leading_operator_and_quoted_first_word() { +// An operator at the very start still tiles, and the word after it is a +// command. +let k = kinds("| grep x"); +assert_eq!(k[0], ("|".into(), TokenKind::Operator)); +assert_eq!(k[2].1, TokenKind::Command); +assert_tiles("| grep x"); +// A quoted string in command position stays a StringLit (quotes are not +// classified as commands), and the argument after it is a plain Arg. +let k = kinds("'./a b' c"); +assert_eq!(k[0], ("'./a b'".into(), TokenKind::StringLit)); +assert_eq!(k[2].1, TokenKind::Arg); +⋮---- +fn multibyte_text_tiles_exactly() { +// Span boundaries are char-based; CJK args must reassemble losslessly. +assert_tiles("echo 你好 世界 | grep 好"); +let k = kinds("echo 你好"); +assert_eq!(k[2], ("你好".into(), TokenKind::Arg)); +```` + +## File: src/terminal/hold.rs +````rust +//! Client-side hold for keystrokes typed into the prompt→prompt gap. +//! +⋮---- +//! +//! While a command runs (`at_prompt` false), typed bytes traditionally go +⋮---- +//! While a command runs (`at_prompt` false), typed bytes traditionally go +//! straight to the PTY, where the kernel echoes them immediately — leaving +⋮---- +//! straight to the PTY, where the kernel echoes them immediately — leaving +//! `ls%`-style debris in the scrollback when the user types ahead of a fast +⋮---- +//! `ls%`-style debris in the scrollback when the user types ahead of a fast +//! command (`cd`, `ls`…). But those bytes can't just be swallowed either: a +⋮---- +//! command (`cd`, `ls`…). But those bytes can't just be swallowed either: a +//! running command may be reading its stdin (a REPL, a password prompt). +⋮---- +//! running command may be reading its stdin (a REPL, a password prompt). +//! +⋮---- +//! +//! The compromise is a short hold: reconstructable gap input (printable text, +⋮---- +//! The compromise is a short hold: reconstructable gap input (printable text, +//! Backspace) is captured client-side for up to the caller's dump window +⋮---- +//! Backspace) is captured client-side for up to the caller's dump window +//! (~150 ms). If the editor engages first — the fast-command case — the held +⋮---- +//! (~150 ms). If the editor engages first — the fast-command case — the held +//! text is handed to it verbatim and the PTY never sees a byte: no echo, no +⋮---- +//! text is handed to it verbatim and the PTY never sees a byte: no echo, no +//! wipe, pristine scrollback. If the window lapses — a long command, or a +⋮---- +//! wipe, pristine scrollback. If the window lapses — a long command, or a +//! program actually reading stdin — the bytes are released to the PTY exactly +⋮---- +//! program actually reading stdin — the bytes are released to the PTY exactly +//! as typed, and the rest of the gap is raw passthrough so interactive +⋮---- +//! as typed, and the rest of the gap is raw passthrough so interactive +//! programs feel no further delay. Unreconstructable input (arrows, chords, +⋮---- +//! programs feel no further delay. Unreconstructable input (arrows, chords, +//! Enter, multi-line pastes) releases the hold immediately and passes +⋮---- +//! Enter, multi-line pastes) releases the hold immediately and passes +//! through, preserving byte order. +⋮---- +//! through, preserving byte order. +//! +⋮---- +//! +//! The struct is pure state — no timers, no PTY. The caller arms a timer when +⋮---- +//! The struct is pure state — no timers, no PTY. The caller arms a timer when +//! a hold window opens (`Verdict::Held(Some(epoch))`) and calls [`GapHold::timeout`] +⋮---- +//! a hold window opens (`Verdict::Held(Some(epoch))`) and calls [`GapHold::timeout`] +//! when it fires; the epoch makes a late timer firing after engage/release a +⋮---- +//! when it fires; the epoch makes a late timer firing after engage/release a +//! no-op. Two views of the held input are kept: `net`, the backspace-folded +⋮---- +//! no-op. Two views of the held input are kept: `net`, the backspace-folded +//! text the editor (or the typeahead record) adopts, and `bytes`, the raw +⋮---- +//! text the editor (or the typeahead record) adopts, and `bytes`, the raw +//! stream a dump writes — zle folds backspaces the same way, so both views +⋮---- +//! stream a dump writes — zle folds backspaces the same way, so both views +//! converge on the same line. +⋮---- +//! converge on the same line. +/// What the hold decided to do with one gap-input event. +pub enum Verdict { +⋮---- +pub enum Verdict { +/// Captured client-side; nothing reaches the PTY for now. `Some(epoch)` on + /// the event that opened the window — the caller starts the dump timer +⋮---- +/// the event that opened the window — the caller starts the dump timer + /// with it. +⋮---- +/// with it. + Held(Option), +/// The gap already went raw (a dump or release happened); the caller + /// writes the event to the PTY itself, as before holds existed. +⋮---- +/// writes the event to the PTY itself, as before holds existed. + Passthrough, +⋮---- +enum State { +/// No gap input seen since the last engage. + #[default] +⋮---- +/// Input is being held, dump timer running. + Holding, +/// The hold was dumped/released this gap; further input goes raw. + Passthrough, +⋮---- +/// Held gap input. One per pane view; reset by [`GapHold::engage`] whenever +/// the line editor takes over. +⋮---- +/// the line editor takes over. +#[derive(Default)] +pub struct GapHold { +⋮---- +/// Backspace-folded text, as the editor would end up showing it. + net: String, +/// The raw byte stream exactly as typed — what a dump writes to the PTY. + bytes: Vec, +/// Bumped when a window opens; a dump timer carries its window's epoch so + /// firing after engage (or after an earlier dump) is a no-op. +⋮---- +/// firing after engage (or after an earlier dump) is a no-op. + epoch: u64, +⋮---- +impl GapHold { +pub fn new() -> Self { +⋮---- +/// Offer printable text (IME commit, single-line paste) to the hold. + pub fn hold_text(&mut self, s: &str, bytes: &[u8]) -> Verdict { +⋮---- +pub fn hold_text(&mut self, s: &str, bytes: &[u8]) -> Verdict { +self.hold(bytes, |net| net.push_str(s)) +⋮---- +/// Offer a plain Backspace to the hold. Folds the last held char off + /// `net`; on an empty hold there is nothing shell-side to erase either +⋮---- +/// `net`; on an empty hold there is nothing shell-side to erase either + /// (nothing was dumped), so the fold simply stays empty. +⋮---- +/// (nothing was dumped), so the fold simply stays empty. + pub fn hold_backspace(&mut self, bytes: &[u8]) -> Verdict { +⋮---- +pub fn hold_backspace(&mut self, bytes: &[u8]) -> Verdict { +self.hold(bytes, |net| { +net.pop(); +⋮---- +fn hold(&mut self, bytes: &[u8], fold: impl FnOnce(&mut String)) -> Verdict { +⋮---- +let arm = matches!(s, State::Idle).then(|| { +⋮---- +fold(&mut self.net); +self.bytes.extend_from_slice(bytes); +⋮---- +/// An unreconstructable event (arrow, chord, Enter, multi-line paste) is + /// about to be written raw: release whatever is held so it precedes that +⋮---- +/// about to be written raw: release whatever is held so it precedes that + /// event on the wire, and switch the rest of the gap to passthrough. +⋮---- +/// event on the wire, and switch the rest of the gap to passthrough. + /// Returns `(folded_text, raw_bytes)` for the caller to write and record. +⋮---- +/// Returns `(folded_text, raw_bytes)` for the caller to write and record. + pub fn release(&mut self) -> Option<(String, Vec)> { +⋮---- +pub fn release(&mut self) -> Option<(String, Vec)> { +let held = matches!(self.state, State::Holding); +⋮---- +held.then(|| { +⋮---- +/// The dump timer for `epoch` fired: if that window is still open, release + /// it (the command is taking long / reading stdin — the bytes must flow). +⋮---- +/// it (the command is taking long / reading stdin — the bytes must flow). + pub fn timeout(&mut self, epoch: u64) -> Option<(String, Vec)> { +⋮---- +pub fn timeout(&mut self, epoch: u64) -> Option<(String, Vec)> { +if matches!(self.state, State::Holding) && epoch == self.epoch { +self.release() +⋮---- +/// The line editor engaged: whatever is still held goes to it (the PTY + /// never saw those bytes, so there is nothing to wipe), and the next gap +⋮---- +/// never saw those bytes, so there is nothing to wipe), and the next gap + /// starts from a clean slate. +⋮---- +/// starts from a clean slate. + pub fn engage(&mut self) -> Option { +⋮---- +pub fn engage(&mut self) -> Option { +⋮---- +self.bytes.clear(); +⋮---- +(!net.is_empty()).then_some(net) +⋮---- +mod tests { +⋮---- +fn fast_command_gap_replays_into_the_editor_and_never_touches_the_pty() { +⋮---- +// The first held key opens the window (the caller arms the timer)... +assert!(matches!(h.hold_text("l", b"l"), Verdict::Held(Some(_)))); +// ...later keys ride the same window. +assert!(matches!(h.hold_text("s", b"s"), Verdict::Held(None))); +// The command finished inside the window: everything goes to the +// editor; the PTY never saw a byte, so nothing echoes, nothing needs +// a wipe. +assert_eq!(h.engage(), Some("ls".to_string())); +// The gap is over; the next one starts from a clean slate. +assert_eq!(h.engage(), None); +⋮---- +fn timeout_dumps_typed_bytes_once_and_goes_passthrough() { +⋮---- +let Verdict::Held(Some(epoch)) = h.hold_text("l", b"l") else { +panic!("first key should open a window"); +⋮---- +// The window lapsed (long command / stdin reader): the raw bytes are +// released for the PTY, with the folded text for the typeahead record. +assert_eq!(h.timeout(epoch), Some(("ls".to_string(), b"ls".to_vec()))); +// The same timer can't fire twice… +assert_eq!(h.timeout(epoch), None); +// …and the rest of the gap is raw passthrough — no added latency for +// whatever is reading stdin now. +assert!(matches!(h.hold_text("x", b"x"), Verdict::Passthrough)); +// Nothing left for the editor; the next gap opens a fresh window with +// a fresh epoch. +⋮---- +let Verdict::Held(Some(e2)) = h.hold_text("a", b"a") else { +panic!("fresh gap should hold again"); +⋮---- +assert_ne!(e2, epoch, "each window carries its own timer epoch"); +⋮---- +fn engage_inside_the_window_cancels_the_pending_dump() { +⋮---- +assert_eq!(h.engage(), Some("l".to_string())); +// The timer fires late, after the editor already adopted the text — +// dumping now would type a stray "l" at the prompt. +⋮---- +fn unreconstructable_input_releases_the_hold_in_typed_order() { +⋮---- +h.hold_text("ls", b"ls"); +// An arrow / chord / Enter can't be replayed into the editor: what's +// held is released first (the caller writes it, then the event's own +// bytes — FIFO preserved), and the gap goes raw. +assert_eq!(h.release(), Some(("ls".to_string(), b"ls".to_vec()))); +⋮---- +// With nothing held, release still switches to passthrough, silently. +⋮---- +assert_eq!(h.release(), None); +⋮---- +fn backspace_folds_for_the_editor_but_dumps_verbatim() { +// Editor path: the fold applies, exactly like zle would. +⋮---- +h.hold_text("lss", b"lss"); +assert!(matches!(h.hold_backspace(b"\x7f"), Verdict::Held(None))); +⋮---- +// Dump path: the PTY gets the stream exactly as typed (text + 0x7f); +// the record seed uses the folded text — zle folds the same way, so +// both views converge on the same line. +⋮---- +let Verdict::Held(Some(e)) = h.hold_text("lss", b"lss") else { +⋮---- +h.hold_backspace(b"\x7f"); +assert_eq!(h.timeout(e), Some(("ls".to_string(), b"lss\x7f".to_vec()))); +⋮---- +// A backspace with nothing held folds to nothing, and there is +// nothing shell-side to erase either (nothing was dumped): it simply +// vanishes instead of reaching the PTY. +⋮---- +assert!(matches!(h.hold_backspace(b"\x7f"), Verdict::Held(Some(_)))); +```` + +## File: src/terminal/palette.rs +````rust +//! tty7 terminal color scheme. +//! +⋮---- +//! +//! A self-contained, hand-tuned palette (not derived from any other terminal +⋮---- +//! A self-contained, hand-tuned palette (not derived from any other terminal +//! theme) covering the ANSI 16 colors for both dark and light backgrounds, the +⋮---- +//! theme) covering the ANSI 16 colors for both dark and light backgrounds, the +//! 256-color xterm fallback cube, and the text-selection colors. The goal is a +⋮---- +//! 256-color xterm fallback cube, and the text-selection colors. The goal is a +//! calm, slightly cool-neutral look where every accent stays legible on its +⋮---- +//! calm, slightly cool-neutral look where every accent stays legible on its +//! intended background and the bright variants are clearly lifted from the +⋮---- +//! intended background and the bright variants are clearly lifted from the +//! normal ones without becoming neon. +⋮---- +//! normal ones without becoming neon. +use alacritty_terminal::vte::ansi::Rgb; +use gpui::Global; +⋮---- +/// The terminal-facing slice of the active color scheme: the ANSI-16 set and +/// the selection surface for the current (preset, mode) — the base the search +⋮---- +/// the selection surface for the current (preset, mode) — the base the search +/// match washes derive from (the selection itself paints as a translucent +⋮---- +/// match washes derive from (the selection itself paints as a translucent +/// foreground wash; see `element::PaintColors`). Published as a GPUI global by +⋮---- +/// foreground wash; see `element::PaintColors`). Published as a GPUI global by +/// the UI layer's `apply_theme` so the renderer always paints the active +⋮---- +/// the UI layer's `apply_theme` so the renderer always paints the active +/// scheme without the terminal layer depending on `ui`. +⋮---- +/// scheme without the terminal layer depending on `ui`. +#[derive(Debug, Clone)] +pub struct ActivePalette { +⋮---- +impl Global for ActivePalette {} +⋮---- +/// Convert a GPUI `Hsla` to an alacritty `Rgb` (8-bit per channel, rounded and +/// clamped). Shared by the renderer and the OSC color-query replies. +⋮---- +/// clamped). Shared by the renderer and the OSC color-query replies. +pub fn hsla_to_rgb(c: gpui::Hsla) -> Rgb { +⋮---- +pub fn hsla_to_rgb(c: gpui::Hsla) -> Rgb { +⋮---- +r: (rgba.r * 255.0).round().clamp(0.0, 255.0) as u8, +g: (rgba.g * 255.0).round().clamp(0.0, 255.0) as u8, +b: (rgba.b * 255.0).round().clamp(0.0, 255.0) as u8, +⋮---- +/// Dark-theme ANSI 16 set, tuned for the warm "soft charcoal" background +/// (~#232220 — see ui/theme.rs). The neutral slots (0/7/8/15) carry the same +⋮---- +/// (~#232220 — see ui/theme.rs). The neutral slots (0/7/8/15) carry the same +/// faint warm cast as the shell so grays don't read cool-and-dirty against the +⋮---- +/// faint warm cast as the shell so grays don't read cool-and-dirty against the +/// warm base; the colored accents stay slightly desaturated for long sessions. +⋮---- +/// warm base; the colored accents stay slightly desaturated for long sessions. +const DARK_ANSI16: [(u8, u8, u8); 16] = [ +(0x2c, 0x2a, 0x26), // 0 black (warm, lifted off the bg so it's not invisible) +(0xec, 0x6a, 0x78), // 1 red +(0x8f, 0xbf, 0x6e), // 2 green +(0xe0, 0xb0, 0x72), // 3 yellow +(0x6f, 0xa8, 0xe6), // 4 blue +(0xc0, 0x8a, 0xdf), // 5 magenta +(0x5f, 0xc2, 0xc9), // 6 cyan +(0xd2, 0xcf, 0xc8), // 7 white (warm light gray — matches default foreground) +(0x6b, 0x66, 0x5d), // 8 bright black (warm comment gray) +(0xf5, 0x86, 0x8f), // 9 bright red +(0xa8, 0xd9, 0x8a), // 10 bright green +(0xef, 0xc7, 0x8a), // 11 bright yellow +(0x8f, 0xc0, 0xf5), // 12 bright blue +(0xd2, 0xa6, 0xec), // 13 bright magenta +(0x84, 0xd6, 0xdc), // 14 bright cyan +(0xf6, 0xf3, 0xec), // 15 bright white (warm) +⋮---- +/// Build the full 256-entry xterm palette (dark-theme ANSI 16 in slots 0-15). +/// +⋮---- +/// +/// Slots 0-15 are a sensible default only: the renderer overwrites them every +⋮---- +/// Slots 0-15 are a sensible default only: the renderer overwrites them every +/// paint with the active preset's ANSI set (see `ui::presets::ActivePalette`). +⋮---- +/// paint with the active preset's ANSI set (see `ui::presets::ActivePalette`). +pub fn build() -> [Rgb; 256] { +⋮---- +pub fn build() -> [Rgb; 256] { +⋮---- +// 0-15: ANSI 16. +for (i, (r, g, b)) in DARK_ANSI16.iter().enumerate() { +⋮---- +// 16-231: 6×6×6 color cube. +⋮---- +// 232-255: grayscale ramp. +⋮---- +mod tests { +⋮---- +fn hsla_to_rgb_round_trips_a_known_color() { +// A `#rrggbb` literal → Hsla → Rgb should recover the byte channels. +let rgb = hsla_to_rgb(gpui::rgb(0x123456).into()); +assert_eq!((rgb.r, rgb.g, rgb.b), (0x12, 0x34, 0x56)); +// Pure black and white clamp cleanly. +let black = hsla_to_rgb(gpui::rgb(0x000000).into()); +assert_eq!((black.r, black.g, black.b), (0, 0, 0)); +let white = hsla_to_rgb(gpui::rgb(0xffffff).into()); +assert_eq!((white.r, white.g, white.b), (255, 255, 255)); +⋮---- +fn build_lays_out_the_256_color_cube_and_ramp() { +let p = build(); +// Slots 0-15 are the dark ANSI set. +⋮---- +assert_eq!((p[i].r, p[i].g, p[i].b), (*r, *g, *b)); +⋮---- +// The 6×6×6 cube runs 16..=231: first is black, last is white. +assert_eq!((p[16].r, p[16].g, p[16].b), (0, 0, 0)); +assert_eq!((p[231].r, p[231].g, p[231].b), (255, 255, 255)); +// The grayscale ramp is 232..=255, starting at 8 and stepping by 10. +assert_eq!(p[232].r, 8); +assert_eq!(p[255].r, 8 + 23 * 10); +// Ramp entries are true grays. +assert_eq!(p[240].r, p[240].g); +assert_eq!(p[240].g, p[240].b); +```` + +## File: src/terminal/signature.rs +````rust +//! Per-command completion signatures — tty7's take on rich command +//! signatures (built on Fig's autocomplete specs). +⋮---- +//! signatures (built on Fig's autocomplete specs). +//! +⋮---- +//! +//! The data is generated offline from Fig's MIT-licensed spec corpus by +⋮---- +//! The data is generated offline from Fig's MIT-licensed spec corpus by +//! `scripts/fig-convert/convert.mjs`, which executes each compiled spec and +⋮---- +//! `scripts/fig-convert/convert.mjs`, which executes each compiled spec and +//! snapshots its *static* shape (subcommands, options, args, descriptions, +⋮---- +//! snapshots its *static* shape (subcommands, options, args, descriptions, +//! static generator `script`s) into `assets/completions/.json`. This module +⋮---- +//! static generator `script`s) into `assets/completions/.json`. This module +//! is only the runtime consumer: a serde model plus a per-command **lazy, +⋮---- +//! is only the runtime consumer: a serde model plus a per-command **lazy, +//! memoized registry** — a command's JSON is parsed the first time it's typed +⋮---- +//! memoized registry** — a command's JSON is parsed the first time it's typed +//! and cached for the session. +⋮---- +//! and cached for the session. +//! +⋮---- +//! +//! Specs are read from an on-disk `completions/` directory rather than embedded +⋮---- +//! Specs are read from an on-disk `completions/` directory rather than embedded +//! in the binary, so the corpus can grow (or a user can drop in their own specs) +⋮---- +//! in the binary, so the corpus can grow (or a user can drop in their own specs) +//! without a recompile and without bloating the executable. [`spec_source`] +⋮---- +//! without a recompile and without bloating the executable. [`spec_source`] +//! resolves that directory across the shapes tty7 runs in — a packaged bundle, +⋮---- +//! resolves that directory across the shapes tty7 runs in — a packaged bundle, +//! an unpackaged binary, `cargo run`, and tests — plus an optional user override +⋮---- +//! an unpackaged binary, `cargo run`, and tests — plus an optional user override +//! under the config dir; see its docs for the search order. The lookup only ever +⋮---- +//! under the config dir; see its docs for the search order. The lookup only ever +//! maps a bare command name to `/.json`, so a typed token can't escape +⋮---- +//! maps a bare command name to `/.json`, so a typed token can't escape +//! the completions dir. +⋮---- +//! the completions dir. +use std::collections::HashMap; +use std::path::PathBuf; +⋮---- +use serde::Deserialize; +⋮---- +/// A command's completion signature (the JSON root). Shares the `options` / +/// `args` / `subcommands` shape with [`Subcommand`] via the [`CmdNode`] trait so +⋮---- +/// `args` / `subcommands` shape with [`Subcommand`] via the [`CmdNode`] trait so +/// the argv walk can treat the root and any nested subcommand uniformly. +⋮---- +/// the argv walk can treat the root and any nested subcommand uniformly. +#[derive(Debug, Deserialize)] +pub struct Signature { +⋮---- +/// A subcommand node — the same fields as [`Signature`] but carrying its own +/// aliases (`names`) and a `hidden` flag we keep out of the menu. +⋮---- +/// aliases (`names`) and a `hidden` flag we keep out of the menu. +#[derive(Debug, Deserialize)] +pub struct Subcommand { +⋮---- +/// A per-entry icon from the Fig spec: an emoji, a `fig://icon?type=…` + /// template, or a `fig://template?…`. The menu renderer interprets it. +⋮---- +/// template, or a `fig://template?…`. The menu renderer interprets it. + #[serde(default)] +⋮---- +/// A flag / option. `names` holds every spelling (`["-m", "--message"]`); a +/// non-empty `args` means the option takes a value. +⋮---- +/// non-empty `args` means the option takes a value. +#[derive(Debug, Deserialize)] +pub struct Opt { +⋮---- +/// Per-option icon from the Fig spec (see [`Subcommand::icon`]). + #[serde(default)] +⋮---- +impl Opt { +/// Whether this option consumes a following value token. + pub fn takes_arg(&self) -> bool { +⋮---- +pub fn takes_arg(&self) -> bool { +!self.args.is_empty() +⋮---- +/// A positional / value argument. `template` mirrors Fig's `"filepaths"` / +/// `"folders"` (→ tty7's path completion); `suggestions` is a static candidate +⋮---- +/// `"folders"` (→ tty7's path completion); `suggestions` is a static candidate +/// list; `generators` holds only the *static* `script`s (dynamic value +⋮---- +/// list; `generators` holds only the *static* `script`s (dynamic value +/// completion — running them — is a later step, so they're unused for now). +⋮---- +/// completion — running them — is a later step, so they're unused for now). +#[derive(Debug, Deserialize)] +pub struct Arg { +⋮---- +impl Arg { +/// Whether this arg wants filesystem completion (Fig `filepaths`/`folders`). + pub fn wants_paths(&self) -> bool { +⋮---- +pub fn wants_paths(&self) -> bool { +⋮---- +.iter() +.any(|t| t == "filepaths" || t == "folders") +⋮---- +/// A static value suggestion for an argument. +#[derive(Debug, Deserialize)] +pub struct Suggestion { +⋮---- +/// Per-suggestion icon from the Fig spec (see [`Subcommand::icon`]). + #[serde(default)] +⋮---- +/// A dynamic-value generator, reduced to its static shell `script` (the JS +/// `postProcess` is dropped at conversion time; tty7 would default to +⋮---- +/// `postProcess` is dropped at conversion time; tty7 would default to +/// one-suggestion-per-line). Not executed yet — kept so the data is ready. +⋮---- +/// one-suggestion-per-line). Not executed yet — kept so the data is ready. +#[derive(Debug, Deserialize)] +pub struct Generator { +⋮---- +/// Uniform read access to a command node's children, so the argv walk in +/// `completion` can start at the [`Signature`] root and descend into +⋮---- +/// `completion` can start at the [`Signature`] root and descend into +/// [`Subcommand`]s without special-casing. +⋮---- +/// [`Subcommand`]s without special-casing. +pub trait CmdNode { +⋮---- +pub trait CmdNode { +⋮---- +/// The subcommand whose name/alias equals `token`, if any. + fn find_subcommand(&self, token: &str) -> Option<&Subcommand> { +⋮---- +fn find_subcommand(&self, token: &str) -> Option<&Subcommand> { +self.subcommands() +⋮---- +.find(|s| s.names.iter().any(|n| n == token)) +⋮---- +/// The option matching a flag token (`--message`, `-m`); the token is + /// compared after stripping any `=value` suffix. +⋮---- +/// compared after stripping any `=value` suffix. + fn find_option(&self, token: &str) -> Option<&Opt> { +⋮---- +fn find_option(&self, token: &str) -> Option<&Opt> { +let flag = token.split('=').next().unwrap_or(token); +self.options() +⋮---- +.find(|o| o.names.iter().any(|n| n == flag)) +⋮---- +impl CmdNode for Signature { +fn subcommands(&self) -> &[Subcommand] { +⋮---- +fn options(&self) -> &[Opt] { +⋮---- +fn args(&self) -> &[Arg] { +⋮---- +impl CmdNode for Subcommand { +⋮---- +/// The directories searched for `.json`, most-specific first, resolved once. +/// +⋮---- +/// +/// Order (first hit wins, so earlier entries override later ones): +⋮---- +/// Order (first hit wins, so earlier entries override later ones): +/// 1. `$TTY7_COMPLETIONS_DIR` — explicit override for dev / testing. +⋮---- +/// 1. `$TTY7_COMPLETIONS_DIR` — explicit override for dev / testing. +/// 2. `/completions` — user-supplied specs (mirrors how the rest of +⋮---- +/// 2. `/completions` — user-supplied specs (mirrors how the rest of +/// tty7 lets `~/.config/tty7` override built-ins). +⋮---- +/// tty7 lets `~/.config/tty7` override built-ins). +/// 3. bundle/executable-relative — where each packaging script installs the +⋮---- +/// 3. bundle/executable-relative — where each packaging script installs the +/// specs: `../Resources/completions` inside a macOS `.app`, or a +⋮---- +/// specs: `../Resources/completions` inside a macOS `.app`, or a +/// `completions/` dir beside the executable on Linux/Windows. +⋮---- +/// `completions/` dir beside the executable on Linux/Windows. +/// 4. the in-tree `assets/completions` — the `cargo run` / test fallback, +⋮---- +/// 4. the in-tree `assets/completions` — the `cargo run` / test fallback, +/// baked in via `CARGO_MANIFEST_DIR` so an unpackaged run still finds specs. +⋮---- +/// baked in via `CARGO_MANIFEST_DIR` so an unpackaged run still finds specs. +fn spec_source() -> &'static [PathBuf] { +⋮---- +fn spec_source() -> &'static [PathBuf] { +⋮---- +DIRS.get_or_init(|| { +⋮---- +dirs.push(PathBuf::from(over)); +⋮---- +dirs.push(cfg.join("completions")); +⋮---- +if let Some(dir) = exe.parent() { +dirs.push(dir.join("../Resources/completions")); // macOS .app +dirs.push(dir.join("completions")); // Linux / Windows sibling +⋮---- +dirs.push(PathBuf::from(concat!( +⋮---- +/// Read the raw JSON for `cmd` from the first [`spec_source`] dir that has it. +/// +⋮---- +/// +/// `cmd` maps to the bare filename `.json`; anything that isn't a plain +⋮---- +/// `cmd` maps to the bare filename `.json`; anything that isn't a plain +/// command token (letters, digits, and `._+-`) is rejected up front so a typed +⋮---- +/// command token (letters, digits, and `._+-`) is rejected up front so a typed +/// token can never contain a path separator or `..` and read outside the dir. +⋮---- +/// token can never contain a path separator or `..` and read outside the dir. +fn raw_spec(cmd: &str) -> Option { +⋮---- +fn raw_spec(cmd: &str) -> Option { +if cmd.is_empty() +⋮---- +.bytes() +.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'+' | b'-')) +⋮---- +let file = format!("{cmd}.json"); +spec_source() +⋮---- +.find_map(|dir| std::fs::read_to_string(dir.join(&file)).ok()) +⋮---- +/// The parse cache: `None` marks a command we've looked up and have no (or +/// unparseable) signature for, so a miss is memoized too. +⋮---- +/// unparseable) signature for, so a miss is memoized too. +type Registry = Mutex>>>; +⋮---- +type Registry = Mutex>>>; +⋮---- +fn registry() -> &'static Registry { +⋮---- +REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +⋮---- +/// The signature for `cmd`, parsed lazily on first use and memoized (hit or +/// miss). Returns `None` for commands outside the embedded corpus or whose JSON +⋮---- +/// miss). Returns `None` for commands outside the embedded corpus or whose JSON +/// fails to parse — callers fall back to generic completion. +⋮---- +/// fails to parse — callers fall back to generic completion. +pub fn signature(cmd: &str) -> Option> { +⋮---- +pub fn signature(cmd: &str) -> Option> { +// Fast path: return the memoized result (hit or miss) without touching disk. +if let Some(cached) = registry().lock().unwrap().get(cmd) { +return cached.clone(); +⋮---- +// Read + parse off-lock so filesystem IO never blocks another lookup. A +// concurrent miss may load the same spec twice; that's idempotent, and the +// insert below just re-publishes the same value. +let parsed = raw_spec(cmd).and_then(|raw| match serde_json::from_str::(&raw) { +Ok(sig) => Some(Arc::new(sig)), +⋮---- +registry() +.lock() +.unwrap() +.insert(cmd.to_string(), parsed.clone()); +⋮---- +mod tests { +⋮---- +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"); +// Second call returns the same cached Arc. +let again = signature("git").unwrap(); +assert!(Arc::ptr_eq(&sig, &again)); +⋮---- +fn docker_loadspec_grafted_compose() { +let sig = signature("docker").expect("docker spec on disk"); +// `docker compose` was grafted from the docker-compose spec via loadSpec. +⋮---- +.find_subcommand("compose") +.expect("compose subcommand present"); +assert!( +⋮---- +fn git_commit_message_option_takes_arg() { +let sig = signature("git").unwrap(); +let commit = sig.find_subcommand("commit").unwrap(); +let message = commit.find_option("--message").unwrap(); +assert!(message.names.iter().any(|n| n == "-m")); +assert!(message.takes_arg()); +⋮---- +fn unknown_command_has_no_signature() { +assert!(signature("definitely-not-a-real-cmd-xyz").is_none()); +⋮---- +/// Every spec that ships in-tree must parse into the serde model — a + /// malformed one should fail here (at CI time) rather than silently +⋮---- +/// malformed one should fail here (at CI time) rather than silently + /// degrading to generic completion on a user's machine. +⋮---- +/// degrading to generic completion on a user's machine. + #[test] +fn every_shipped_spec_parses() { +let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/completions"); +⋮---- +for entry in std::fs::read_dir(dir).expect("completions dir exists") { +let path = entry.unwrap().path(); +if path.extension().and_then(|e| e.to_str()) != Some("json") { +⋮---- +let raw = std::fs::read_to_string(&path).unwrap(); +⋮---- +.unwrap_or_else(|e| panic!("{} failed to parse: {e}", path.display())); +⋮---- +/// A typed token that isn't a bare command name must never read a file — + /// path separators and `..` are rejected before touching the filesystem. +⋮---- +/// path separators and `..` are rejected before touching the filesystem. + #[test] +fn raw_spec_rejects_path_traversal() { +assert!(raw_spec("git").is_some()); +assert!(raw_spec("../git").is_none()); +assert!(raw_spec("a/b").is_none()); +assert!(raw_spec("../../etc/passwd").is_none()); +assert!(raw_spec("").is_none()); +```` + +## File: src/terminal/size.rs +````rust +//! `TermSize`: the fixed grid dimensions handed to the VT emulator and the PTY. +//! +⋮---- +//! +//! This used to live alongside an in-process PTY-backed `Terminal` here, but the +⋮---- +//! This used to live alongside an in-process PTY-backed `Terminal` here, but the +//! PTY now lives in the daemon (`daemon::pane`) and the GUI talks to it through +⋮---- +//! PTY now lives in the daemon (`daemon::pane`) and the GUI talks to it through +//! `terminal::remote::RemoteTerminal`. All that survives on the client side is +⋮---- +//! `terminal::remote::RemoteTerminal`. All that survives on the client side is +//! this size type, shared by the remote terminal and the view. +⋮---- +//! this size type, shared by the remote terminal and the view. +use alacritty_terminal::grid::Dimensions; +⋮---- +/// Fixed dimensions handed to `Term` / `Term::resize`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TermSize { +⋮---- +impl TermSize { +pub fn new(cols: usize, rows: usize) -> Self { +⋮---- +cols: cols.max(1), +rows: rows.max(1), +⋮---- +impl Dimensions for TermSize { +fn total_lines(&self) -> usize { +⋮---- +fn screen_lines(&self) -> usize { +⋮---- +fn columns(&self) -> usize { +```` + +## File: src/terminal/typeahead.rs +````rust +//! Tracks what the user types into a pane while the local line editor is +//! *disengaged*, so the editor can adopt it on engage instead of stranding it. +⋮---- +//! *disengaged*, so the editor can adopt it on engage instead of stranding it. +//! +⋮---- +//! +//! Two windows behave identically: a freshly spawned shell sourcing rc files +⋮---- +//! Two windows behave identically: a freshly spawned shell sourcing rc files +//! (often a second or more before the first OSC 133), and the gap every +⋮---- +//! (often a second or more before the first OSC 133), and the gap every +//! submitted command opens between `133;C` and the next prompt. In both, +⋮---- +//! submitted command opens between `133;C` and the next prompt. In both, +//! `at_prompt` is false and keystrokes go raw to the PTY. The shell isn't +⋮---- +//! `at_prompt` is false and keystrokes go raw to the PTY. The shell isn't +//! reading them — the bytes queue in the kernel TTY buffer — and when zle +⋮---- +//! reading them — the bytes queue in the kernel TTY buffer — and when zle +//! (re)starts at the next prompt it consumes them as type-ahead: they appear +⋮---- +//! (re)starts at the next prompt it consumes them as type-ahead: they appear +//! on the *shell's* command line. At that same moment the editor engages with +⋮---- +//! on the *shell's* command line. At that same moment the editor engages with +//! an empty buffer and swallows every key, so the strays can be neither +⋮---- +//! an empty buffer and swallows every key, so the strays can be neither +//! edited nor deleted — and the editor overlay (transparent, anchored at the +⋮---- +//! edited nor deleted — and the editor overlay (transparent, anchored at the +//! cursor) double-draws its own line over their echo. +⋮---- +//! cursor) double-draws its own line over their echo. +//! +⋮---- +//! +//! The fix: record a best-effort reconstruction of the gap typing here; when +⋮---- +//! The fix: record a best-effort reconstruction of the gap typing here; when +//! the editor engages, send one `^U` (kill-whole-line) to the PTY and seed +⋮---- +//! the editor engages, send one `^U` (kill-whole-line) to the PTY and seed +//! the editor with the reconstruction. Ordering makes the `^U` safe with no +⋮---- +//! the editor with the reconstruction. Ordering makes the `^U` safe with no +//! timing assumptions: it is written *after* every stray byte, and the TTY +⋮---- +//! timing assumptions: it is written *after* every stray byte, and the TTY +//! queue is FIFO, so zle always consumes the strays first and then the `^U` +⋮---- +//! queue is FIFO, so zle always consumes the strays first and then the `^U` +//! that wipes them — wherever prompt boundaries fall. In the common case +⋮---- +//! that wipes them — wherever prompt boundaries fall. In the common case +//! nothing was typed in the gap, `drain` returns `None`, and no byte is sent. +⋮---- +//! nothing was typed in the gap, `drain` returns `None`, and no byte is sent. +//! +⋮---- +//! +//! A command that *reads* its stdin (a REPL, a password prompt) consumes gap +⋮---- +//! A command that *reads* its stdin (a REPL, a password prompt) consumes gap +//! bytes itself; they never reach zle. The `^U` still only lands in zle (the +⋮---- +//! bytes itself; they never reach zle. The `^U` still only lands in zle (the +//! editor engages at a prompt, after the command exited), where killing an +⋮---- +//! editor engages at a prompt, after the command exited), where killing an +//! empty line is a no-op, and Enter-terminated input seeds nothing thanks to +⋮---- +//! empty line is a no-op, and Enter-terminated input seeds nothing thanks to +//! the submit-boundary rule — so the wipe stays safe there too. Full-screen +⋮---- +//! the submit-boundary rule — so the wipe stays safe there too. Full-screen +//! TUI input (alt screen) is not reconstructable typing at all: it taints the +⋮---- +//! TUI input (alt screen) is not reconstructable typing at all: it taints the +//! record instead of recording. +⋮---- +//! record instead of recording. +/// Cap on the recorded reconstruction. Typing that overflows it (nobody types +/// 4 KiB into a prompt gap — this is a paste or a stuck key) taints the +⋮---- +/// 4 KiB into a prompt gap — this is a paste or a stuck key) taints the +/// record instead of silently truncating to a wrong line. +⋮---- +/// record instead of silently truncating to a wrong line. +const RECORD_CAP: usize = 4096; +⋮---- +/// Best-effort reconstruction of user input sent raw to the PTY while the +/// line editor was disengaged. `tainted` means bytes we can't reconstruct +⋮---- +/// line editor was disengaged. `tainted` means bytes we can't reconstruct +/// (arrows, tab, control chords, multi-line pastes) went through: the wipe +⋮---- +/// (arrows, tab, control chords, multi-line pastes) went through: the wipe +/// still happens, but nothing is seeded — a wrong guess in the editor is +⋮---- +/// still happens, but nothing is seeded — a wrong guess in the editor is +/// worse than an empty line. +⋮---- +/// worse than an empty line. +#[derive(Default)] +pub struct Typeahead { +⋮---- +/// One raw PTY-bound user-input event, as far as reconstruction cares. +pub enum RawInput<'a> { +⋮---- +pub enum RawInput<'a> { +/// Committed printable text (the IME commit and paste paths). + Text(&'a str), +/// A non-text keystroke that produced PTY bytes. `key` is the GPUI key + /// name; `plain` means no control/alt/platform modifier was held. +⋮---- +/// name; `plain` means no control/alt/platform modifier was held. + Key { key: &'a str, plain: bool }, +⋮---- +impl Typeahead { +pub fn new() -> Self { +⋮---- +/// Fold one raw PTY-bound input event into the reconstruction. Call this + /// wherever user input is written to the PTY while the line editor is +⋮---- +/// wherever user input is written to the PTY while the line editor is + /// disengaged — the record mirrors exactly what the shell will later +⋮---- +/// disengaged — the record mirrors exactly what the shell will later + /// consume as type-ahead. `alt_screen` input belongs to a full-screen TUI, +⋮---- +/// consume as type-ahead. `alt_screen` input belongs to a full-screen TUI, + /// not the shell's next line: it taints the record instead of recording. +⋮---- +/// not the shell's next line: it taints the record instead of recording. + pub fn observe(&mut self, input: RawInput, alt_screen: bool) { +⋮---- +pub fn observe(&mut self, input: RawInput, alt_screen: bool) { +⋮---- +self.taint(); +⋮---- +RawInput::Text(s) => self.record_text(s), +⋮---- +} => self.record_enter(), +⋮---- +} => self.record_backspace(), +RawInput::Key { .. } => self.taint(), +⋮---- +/// Take the reconstruction accumulated since the last drain, resetting the + /// record so the next gap starts clean. `None` → nothing was typed, send +⋮---- +/// record so the next gap starts clean. `None` → nothing was typed, send + /// nothing. `Some(seed)` → send `^U` to wipe the shell's line, then put +⋮---- +/// nothing. `Some(seed)` → send `^U` to wipe the shell's line, then put + /// `seed` (possibly empty, if tainted or everything was already submitted) +⋮---- +/// `seed` (possibly empty, if tainted or everything was already submitted) + /// into the editor. +⋮---- +/// into the editor. + pub fn drain(&mut self) -> Option { +⋮---- +pub fn drain(&mut self) -> Option { +std::mem::take(self).flush() +⋮---- +/// Record committed printable text (the IME path). Control characters mean + /// this wasn't plain typing (e.g. a multi-line paste) — taint instead. +⋮---- +/// this wasn't plain typing (e.g. a multi-line paste) — taint instead. + fn record_text(&mut self, s: &str) { +⋮---- +fn record_text(&mut self, s: &str) { +if s.chars().any(char::is_control) { +⋮---- +if self.text.len() + s.len() > RECORD_CAP { +⋮---- +self.text.push_str(s); +⋮---- +/// Record Enter. `\r` marks a submit boundary: everything before it will + /// have been accepted (and run) by zle, so only the tail after the *last* +⋮---- +/// have been accepted (and run) by zle, so only the tail after the *last* + /// `\r` is still sitting on the line when we flush. +⋮---- +/// `\r` is still sitting on the line when we flush. + fn record_enter(&mut self) { +⋮---- +fn record_enter(&mut self) { +if self.text.len() + 1 > RECORD_CAP { +⋮---- +self.text.push('\r'); +⋮---- +/// Record Backspace. Pops the last recorded char — except across a submit + /// boundary (or on an empty record), where zle itself would have had +⋮---- +/// boundary (or on an empty record), where zle itself would have had + /// nothing to erase, so the record must not shrink either. +⋮---- +/// nothing to erase, so the record must not shrink either. + fn record_backspace(&mut self) { +⋮---- +fn record_backspace(&mut self) { +if !self.text.ends_with('\r') { +self.text.pop(); +⋮---- +/// Record a byte sequence we can't reconstruct (arrows, tab, control + /// chords…). The eventual wipe neutralizes whatever zle makes of it; we +⋮---- +/// chords…). The eventual wipe neutralizes whatever zle makes of it; we + /// just stop pretending to know the line's content. +⋮---- +/// just stop pretending to know the line's content. + fn taint(&mut self) { +⋮---- +fn taint(&mut self) { +⋮---- +/// Consume the record into the seed decision — the by-value core of + /// [`Typeahead::drain`], see there for the contract. +⋮---- +/// [`Typeahead::drain`], see there for the contract. + fn flush(self) -> Option { +⋮---- +fn flush(self) -> Option { +if self.text.is_empty() && !self.tainted { +⋮---- +return Some(String::new()); +⋮---- +// Only the tail after the last submit boundary is still on zle's line. +let seed = self.text.rsplit('\r').next().unwrap_or(""); +Some(seed.to_string()) +⋮---- +mod tests { +⋮---- +fn drained_record_reconstructs_each_gap_independently() { +// Mid-session, every submitted command opens a prompt→prompt gap where +// typing goes raw to the PTY. The record must reset on `drain` so each +// gap seeds only its own typing. +⋮---- +t.observe(RawInput::Text("cd getty"), false); +assert_eq!(t.drain(), Some("cd getty".to_string())); +// The idle prompt drains once per render — nothing typed since, so +// nothing is wiped or seeded. +assert_eq!(t.drain(), None); +// The next gap starts clean, unpolluted by the drained one. +t.observe(RawInput::Text("ls"), false); +assert_eq!(t.drain(), Some("ls".to_string())); +⋮---- +fn raw_keys_map_to_boundary_erase_or_taint() { +// Plain Enter is a submit boundary (zle ran what precedes it); plain +// Backspace erases the last recorded char, exactly like zle will. +⋮---- +t.observe( +⋮---- +t.observe(RawInput::Text("git st"), false); +⋮---- +assert_eq!(t.drain(), Some("git s".to_string())); +⋮---- +// Any other key that produced PTY bytes (arrows, tab, chords) makes +// the line unknowable — wipe, seed nothing. +⋮---- +assert_eq!(t.drain(), Some(String::new())); +⋮---- +// A chorded Enter isn't accept-line; it must not fake a boundary. +⋮---- +t.observe(RawInput::Text("a"), false); +⋮---- +fn alt_screen_input_taints_instead_of_seeding() { +// Keys typed into a full-screen TUI (vim, less…) are that program's +// input, not command typing — resurrecting them as an editor seed +// would turn a habitual `q` into a pending command. They make the +// line unknowable: wipe at the next prompt, seed nothing. +⋮---- +t.observe(RawInput::Text("q"), true); +⋮---- +fn untouched_record_flushes_to_none() { +// The overwhelmingly common case — nothing typed during startup — must +// send nothing: no ^U, no seed, zero behavior change. +assert_eq!(Typeahead::new().drain(), None); +⋮---- +fn typed_text_is_wiped_and_seeded() { +⋮---- +p.record_text("git sta"); +assert_eq!(p.drain(), Some("git sta".to_string())); +⋮---- +fn backspace_edits_the_record() { +⋮---- +p.record_text("lsx"); +p.record_backspace(); +assert_eq!(p.drain(), Some("ls".to_string())); +⋮---- +fn backspace_on_empty_record_is_noop_but_still_flushes_nothing() { +// zle would have nothing to erase either; the record stays empty and +// the flush stays silent. +⋮---- +assert_eq!(p.drain(), None); +⋮---- +fn enter_marks_a_submit_boundary() { +// "ls\r" was accepted and executed by zle at the first prompt; nothing +// of it remains on the line. Seeding "ls" again would duplicate the +// command — the seed must be only the tail after the last \r. +⋮---- +p.record_text("ls"); +p.record_enter(); +⋮---- +fn fully_submitted_input_wipes_but_seeds_nothing() { +⋮---- +// ^U still goes out (an empty next line is wiped harmlessly; a partial +// leak is cleaned), but the executed command is not resurrected. +assert_eq!(p.drain(), Some(String::new())); +⋮---- +fn backspace_does_not_cross_a_submit_boundary() { +// After "ls\r", zle's next line is empty: a Backspace typed then erases +// nothing in the shell, so it must not eat our \r marker either — +// otherwise the seed would become "ls" and duplicate the executed command. +⋮---- +fn unreconstructable_input_taints_wipe_without_seed() { +// An arrow key (history recall!) makes the line's real content +// unknowable. Wipe it, seed nothing. +⋮---- +p.taint(); +⋮---- +fn control_chars_in_committed_text_taint() { +// A multi-line paste reaches the raw path as one commit; its embedded +// newlines already ran as commands zle-side. Don't guess. +⋮---- +p.record_text("echo a\necho b"); +⋮---- +fn overflowing_the_cap_taints_instead_of_truncating() { +⋮---- +let chunk = "x".repeat(1000); +⋮---- +p.record_text(&chunk); +⋮---- +// 5000 > RECORD_CAP: a truncated seed would be a wrong line; taint. +⋮---- +fn exactly_at_the_cap_still_reconstructs() { +// Filling the record to exactly RECORD_CAP is not an overflow; the full +// reconstruction survives. One more char would tip it into taint. +⋮---- +let full = "x".repeat(RECORD_CAP); +p.record_text(&full); +assert_eq!(p.drain(), Some(full.clone())); +⋮---- +p.record_text("y"); // cap + 1 → taint (wipe, no seed) +⋮---- +fn taint_survives_later_clean_typing() { +// Once the record is unknowable it stays unknowable — later reconstructable +// keys must not "wash" the taint into a half-right seed. +```` + +## File: src/ui/home.rs +````rust +//! The home page: what the window shows when zero tabs are open. +//! +⋮---- +//! +//! Zero tabs is a legitimate state, not an error — closing the last tab lands +⋮---- +//! Zero tabs is a legitimate state, not an error — closing the last tab lands +//! here (and quitting from here restores here). The body renders the tty7 +⋮---- +//! here (and quitting from here restores here). The body renders the tty7 +//! logotype drawn in half-block characters plus a keyboard-shortcut watermark +⋮---- +//! logotype drawn in half-block characters plus a keyboard-shortcut watermark +//! in the VS Code empty-workspace tradition. The logo uses the terminal's own +⋮---- +//! in the VS Code empty-workspace tradition. The logo uses the terminal's own +//! font and theme colors, so it re-skins with everything else; the shortcuts +⋮---- +//! font and theme colors, so it re-skins with everything else; the shortcuts +//! resolve through the live keymap (`effective_key`), so a user remap shows up +⋮---- +//! resolve through the live keymap (`effective_key`), so a user remap shows up +//! here automatically. Enter, a click, or ⌘T spawns a fresh terminal. +⋮---- +//! here automatically. Enter, a click, or ⌘T spawns a fresh terminal. +use std::time::Duration; +⋮---- +use gpui_component::kbd::Kbd; +⋮---- +use crate::ui::app::Tty7App; +⋮---- +/// The "tty7" logotype in half-block characters. Rendered line-by-line in the +/// terminal font with a 1.0 line height so the blocks stack seamlessly; the +⋮---- +/// terminal font with a 1.0 line height so the blocks stack seamlessly; the +/// trailing blinking cursor is appended to the last line at render time. +⋮---- +/// trailing blinking cursor is appended to the last line at render time. +const LOGO: [&str; 4] = [ +⋮---- +/// Logo cell size (px). Text size == line height so half-blocks join vertically. +const LOGO_PX: f32 = 20.0; +⋮---- +/// The curated shortcuts taught on the home page: (action name, label). A +/// deliberate subset — the full table lives in Settings → Keybindings; this is +⋮---- +/// deliberate subset — the full table lives in Settings → Keybindings; this is +/// a watermark, not documentation. +⋮---- +/// a watermark, not documentation. +const HOME_SHORTCUTS: [(&str, &str); 6] = [ +⋮---- +/// Longest label shown for a recently-closed tab before ellipsizing, matching +/// the tab strip's clamp spirit (a runaway title must not stretch the page). +⋮---- +/// the tab strip's clamp spirit (a runaway title must not stretch the page). +const CLOSED_LABEL_MAX: usize = 20; +⋮---- +/// Display label for a recently-closed tab: the user-set name if present, +/// otherwise the directory name of its first leaf's saved cwd. `None` when +⋮---- +/// otherwise the directory name of its first leaf's saved cwd. `None` when +/// neither is known (an unnamed tab that never reported a cwd). +⋮---- +/// neither is known (an unnamed tab that never reported a cwd). +fn closed_tab_label(tab: &SessionTab) -> Option { +⋮---- +fn closed_tab_label(tab: &SessionTab) -> Option { +if let Some(name) = tab.name.as_ref() { +let name = name.trim(); +if !name.is_empty() { +return Some(clamp_label(name)); +⋮---- +first_leaf_cwd(&tab.pane) +.and_then(|p| p.file_name()) +.map(|s| clamp_label(&s.to_string_lossy())) +⋮---- +/// The first leaf (in layout order) that saved a cwd, depth-first. +fn first_leaf_cwd(pane: &SessionPane) -> Option<&std::path::PathBuf> { +⋮---- +fn first_leaf_cwd(pane: &SessionPane) -> Option<&std::path::PathBuf> { +⋮---- +SessionPane::Leaf { cwd, .. } => cwd.as_ref(), +SessionPane::Split { a, b, .. } => first_leaf_cwd(a).or_else(|| first_leaf_cwd(b)), +⋮---- +fn clamp_label(s: &str) -> String { +if s.chars().count() > CLOSED_LABEL_MAX { +format!("{}…", s.chars().take(CLOSED_LABEL_MAX).collect::()) +⋮---- +s.to_string() +⋮---- +/// The display string ("⌘T") for an action's effective (default or +/// user-remapped) binding. Formatted by gpui-component's `Kbd` so platform +⋮---- +/// user-remapped) binding. Formatted by gpui-component's `Kbd` so platform +/// conventions stay consistent app-wide — but rendered as bare text, not the +⋮---- +/// conventions stay consistent app-wide — but rendered as bare text, not the +/// `Kbd` element: its keycap chrome (filled box + border) reads far heavier +⋮---- +/// `Kbd` element: its keycap chrome (filled box + border) reads far heavier +/// than this watermark page on dark themes. Multi-chord specs show their +⋮---- +/// than this watermark page on dark themes. Multi-chord specs show their +/// first chord — enough for a hint. +⋮---- +/// first chord — enough for a hint. +fn key_hint(action: &str, cx: &App) -> Option { +⋮---- +fn key_hint(action: &str, cx: &App) -> Option { +⋮---- +let first = spec.split_whitespace().next()?; +let stroke = Keystroke::parse(first).ok()?; +Some(Kbd::format(&stroke)) +⋮---- +impl Tty7App { +/// Render the home page (called by `render` when `tabs` is empty). + pub(crate) fn render_home(&self, cx: &mut Context) -> impl IntoElement + use<> { +⋮---- +pub(crate) fn render_home(&self, cx: &mut Context) -> impl IntoElement + use<> { +let theme = cx.theme(); +⋮---- +// The logotype: quiet muted lines in the terminal's own font, with a +// blinking block cursor after the last line — the page's only motion +// and only accent color, as a terminal's resting state should be. +let mut logo = v_flex() +.font_family(self.font_family.clone()) +.text_size(px(LOGO_PX)) +.line_height(px(LOGO_PX)) +.text_color(muted); +let (last, head) = LOGO.split_last().expect("LOGO is non-empty"); +⋮---- +logo = logo.child(*line); +⋮---- +logo = logo.child(h_flex().child(*last).child( +div().text_color(accent).child("▌").with_animation( +⋮---- +Animation::new(Duration::from_millis(1200)).repeat(), +// A terminal cursor snaps, it doesn't fade: hard on/off. +|cursor, delta| cursor.opacity(if delta < 0.5 { 1.0 } else { 0.0 }), +⋮---- +// Shortcut watermark. The Reopen row doubles as the undo affordance: +// when something was just closed it names it and brightens, so an +// accidental ⌘W on the last tab reads its own rescue on arrival. +let closed_hint = self.closed.last().and_then(closed_tab_label); +let mut list = v_flex().gap_2().w(px(300.)).text_sm().text_color(muted); +⋮---- +(Some(name), "ReopenClosedTab") => (format!("Reopen \u{201c}{name}\u{201d}"), true), +_ => (label.to_string(), false), +⋮---- +list = list.child( +h_flex() +.items_center() +.justify_between() +.when(emphasized, |row| row.text_color(foreground)) +.child(label) +// Bare key glyphs in the terminal's own mono font: quiet, +// and visibly "of the terminal" rather than UI chrome. +.children( +key_hint(action, cx) +.map(|keys| div().font_family(self.font_family.clone()).child(keys)), +⋮---- +v_flex() +.id("home-page") +.track_focus(&self.home_focus) +.size_full() +⋮---- +.justify_center() +.gap(px(48.)) +// The empty window's whole job is to hand out a shell: a bare click +// or Enter spawns one, no target to aim for. +.on_mouse_down( +⋮---- +cx.listener(|this, _: &MouseDownEvent, window, cx| this.new_tab(window, cx)), +⋮---- +.on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| { +if ev.keystroke.key == "enter" && !ev.keystroke.modifiers.modified() { +this.new_tab(window, cx); +⋮---- +.child(logo) +.child(list) +// Ease the page in rather than popping it — closing the last tab +// should feel like arriving somewhere, not like a glitch. +.with_animation( +⋮---- +|page, delta| page.opacity(delta), +⋮---- +mod tests { +⋮---- +use std::path::PathBuf; +⋮---- +fn leaf(cwd: Option<&str>) -> SessionPane { +⋮---- +cwd: cwd.map(PathBuf::from), +⋮---- +fn closed_tab_label_prefers_the_user_set_name() { +⋮---- +name: Some("build".into()), +pane: leaf(Some("/work/getty")), +⋮---- +assert_eq!(closed_tab_label(&tab).as_deref(), Some("build")); +⋮---- +fn closed_tab_label_falls_back_to_the_first_leaf_cwd_dir_name() { +⋮---- +assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty")); +⋮---- +// Whitespace-only names don't count as names. +⋮---- +name: Some(" ".into()), +⋮---- +fn closed_tab_label_searches_splits_for_the_first_cwd() { +⋮---- +a: Box::new(leaf(None)), +b: Box::new(leaf(Some("/tmp/demo"))), +⋮---- +assert_eq!(closed_tab_label(&tab).as_deref(), Some("demo")); +⋮---- +fn closed_tab_label_is_none_when_nothing_is_known() { +// No name, no cwd — and "/" has no file name either. +⋮---- +pane: leaf(None), +⋮---- +assert_eq!(closed_tab_label(&unnamed), None); +⋮---- +pane: leaf(Some("/")), +⋮---- +assert_eq!(closed_tab_label(&root), None); +⋮---- +fn closed_tab_label_clamps_runaway_names() { +⋮---- +name: Some("a".repeat(40)), +⋮---- +let label = closed_tab_label(&tab).unwrap(); +assert_eq!(label.chars().count(), CLOSED_LABEL_MAX + 1); +assert!(label.ends_with('…')); +⋮---- +fn logo_rows_never_exceed_the_first_row_width() { +// The logotype renders as stacked left-aligned text lines; the first +// row spans the full logotype, so a longer row below it would poke out +// of the block and skew the art. +let width = LOGO[0].chars().count(); +⋮---- +assert!(row.chars().count() <= width, "row {row:?} exceeds {width}"); +```` + +## File: src/ui/mod.rs +````rust +//! The GPUI view layer: the window shell (`app`), the split-pane tree (`pane`), +//! the command palette (`palette`), the settings panel (`settings`), and the +⋮---- +//! the command palette (`palette`), the settings panel (`settings`), and the +//! menu-bar / keymap / theme wiring (`keymap`, `theme`). +⋮---- +//! menu-bar / keymap / theme wiring (`keymap`, `theme`). +//! +⋮---- +//! +//! Everything here may depend on `core` and `terminal`; nothing in those layers +⋮---- +//! Everything here may depend on `core` and `terminal`; nothing in those layers +//! depends back on `ui`. +⋮---- +//! depends back on `ui`. +pub mod app; +pub mod hints; +pub mod home; +pub mod keymap; +pub mod palette; +pub mod pane; +pub mod presets; +pub mod settings; +pub mod tab_strip; +pub mod theme; +```` + +## File: src/ui/presets.rs +````rust +//! Built-in color themes. Each theme is a +//! single, self-contained palette — there is no separate "dark" and "light" +⋮---- +//! single, self-contained palette — there is no separate "dark" and "light" +//! variant to toggle between; a theme simply *is* dark or light, and that flag +⋮---- +//! variant to toggle between; a theme simply *is* dark or light, and that flag +//! drives gpui-component's mode plus how the shell-chrome neutrals are derived. +⋮---- +//! drives gpui-component's mode plus how the shell-chrome neutrals are derived. +//! +⋮---- +//! +//! A theme specifies only its essentials — background, foreground, one accent, +⋮---- +//! A theme specifies only its essentials — background, foreground, one accent, +//! and the ANSI-16 terminal set. Every other shell surface (borders, hover +⋮---- +//! and the ANSI-16 terminal set. Every other shell surface (borders, hover +//! chips, sidebar, command-palette list, selections) is *derived* from those by +⋮---- +//! chips, sidebar, command-palette list, selections) is *derived* from those by +//! blending toward the foreground, so all six themes stay internally consistent +⋮---- +//! blending toward the foreground, so all six themes stay internally consistent +//! without hand-tuning a dozen greys apiece. +⋮---- +//! without hand-tuning a dozen greys apiece. +use alacritty_terminal::vte::ansi::Rgb; +⋮---- +use crate::terminal::palette::ActivePalette; +⋮---- +/// A single color theme. `dark` is the theme's inherent brightness (not a +/// user choice) — it selects gpui-component's `ThemeMode` and flips the +⋮---- +/// user choice) — it selects gpui-component's `ThemeMode` and flips the +/// direction the derived neutrals blend. +⋮---- +/// direction the derived neutrals blend. +#[derive(Debug, Clone)] +pub struct Preset { +⋮---- +/// Optional caret color. `None` derives it from `accent` (the default); a + /// theme sets this only when it wants a cursor color distinct from its accent. +⋮---- +/// theme sets this only when it wants a cursor color distinct from its accent. + pub caret: Option, +⋮---- +/// The shell-chrome palette derived from a theme's essentials. Consumed by +/// `apply_theme` to paint gpui-component's `Theme`. +⋮---- +/// `apply_theme` to paint gpui-component's `Theme`. +#[derive(Debug, Clone)] +pub struct Neutrals { +⋮---- +impl Preset { +/// Derive the full shell palette by blending `background` toward + /// `foreground` (chips, borders, surfaces) and `foreground` toward +⋮---- +/// `foreground` (chips, borders, surfaces) and `foreground` toward + /// `background` (dimmed text). One ruleset gives every theme a coherent set +⋮---- +/// `background` (dimmed text). One ruleset gives every theme a coherent set + /// of greys regardless of its base colors. +⋮---- +/// of greys regardless of its base colors. + pub fn neutrals(&self) -> Neutrals { +⋮---- +pub fn neutrals(&self) -> Neutrals { +⋮---- +border: mix(bg, fg, 0.16), +secondary: mix(bg, fg, 0.09), +muted: mix(bg, fg, 0.06), +muted_foreground: mix(fg, bg, 0.42), +popover: mix(bg, fg, 0.05), +caret: self.caret.unwrap_or(self.accent), +selection: mix(bg, fg, 0.20), +sidebar: mix(bg, fg, 0.03), +sidebar_sel: mix(bg, fg, 0.12), +sidebar_fg: mix(fg, bg, 0.28), +list_active: mix(bg, fg, 0.17), +list_hover: mix(bg, fg, 0.09), +⋮---- +/// The terminal-facing slice of the palette: ANSI-16 plus the selection + /// surface (`mix(bg, fg, 0.24)`), which the renderer's search-match washes +⋮---- +/// surface (`mix(bg, fg, 0.24)`), which the renderer's search-match washes + /// derive from. The selection itself paints as a translucent foreground +⋮---- +/// derive from. The selection itself paints as a translucent foreground + /// wash tuned to composite to this same surface on default-background +⋮---- +/// wash tuned to composite to this same surface on default-background + /// cells (see `element::PaintColors::resolve`), so cells keep their own +⋮---- +/// cells (see `element::PaintColors::resolve`), so cells keep their own + /// colors while selected. +⋮---- +/// colors while selected. + pub fn active_palette(&self) -> ActivePalette { +⋮---- +pub fn active_palette(&self) -> ActivePalette { +⋮---- +for (i, (r, g, b)) in self.ansi16.iter().enumerate() { +⋮---- +sel_bg: rgb_bytes(mix(self.background, self.foreground, 0.24)), +⋮---- +/// Blend `a` toward `b` by `t` (0.0 = all `a`, 1.0 = all `b`), per channel. +fn mix(a: u32, b: u32, t: f32) -> u32 { +⋮---- +fn mix(a: u32, b: u32, t: f32) -> u32 { +⋮---- +let ch = |x: u32, y: u32| (x as f32 + (y as f32 - x as f32) * t).round() as u32; +(ch(ar, br) << 16) | (ch(ag, bg) << 8) | ch(ab, bb) +⋮---- +/// Split a `0xRRGGBB` literal into an alacritty `Rgb`. +fn rgb_bytes(n: u32) -> Rgb { +⋮---- +fn rgb_bytes(n: u32) -> Rgb { +⋮---- +/// All built-in themes, in display order (light themes first). The behavioral +/// default is [`DEFAULT_ID`], not the first entry. +⋮---- +/// default is [`DEFAULT_ID`], not the first entry. +pub fn all() -> &'static [Preset] { +⋮---- +pub fn all() -> &'static [Preset] { +⋮---- +/// The id of the app's default theme. Mirrors `Config`'s default `theme_preset` +/// (which can't reference this module — `core` doesn't depend on `ui`). This is +⋮---- +/// (which can't reference this module — `core` doesn't depend on `ui`). This is +/// the *behavioral* default; it is independent of `PRESETS`' display order, which +⋮---- +/// the *behavioral* default; it is independent of `PRESETS`' display order, which +/// lists the light themes first. +⋮---- +/// lists the light themes first. +pub const DEFAULT_ID: &str = "light"; +⋮---- +/// Look a theme up by id, falling back to the default theme ([`DEFAULT_ID`]) for +/// an unknown id so a stale/typo'd config entry never breaks startup. +⋮---- +/// an unknown id so a stale/typo'd config entry never breaks startup. +pub fn by_id(id: &str) -> &'static Preset { +⋮---- +pub fn by_id(id: &str) -> &'static Preset { +⋮---- +.iter() +.find(|p| p.id == id) +.or_else(|| PRESETS.iter().find(|p| p.id == DEFAULT_ID)) +.unwrap_or(&PRESETS[0]) +⋮---- +/// A hand-picked set of familiar terminal palettes. +static PRESETS: [Preset; 8] = [ +⋮---- +// A warm orange caret, distinct from the cyan accent (which also tints the +// active-line highlight and links). +caret: Some(0xf5a15c), +// True-hue, high-contrast set tuned for a white ground (GitHub Light-ish): +// red reads red (not the old magenta-pink), green is a forest green, and +// "yellow" is a dark gold so it stays legible instead of washing out. +⋮---- +(0x24, 0x29, 0x2e), // black +(0xd1, 0x24, 0x2f), // red +(0x1a, 0x7f, 0x37), // green +(0x9a, 0x67, 0x00), // yellow (dark gold — readable on white) +(0x09, 0x69, 0xda), // blue +(0x82, 0x50, 0xdf), // magenta +(0x1b, 0x7c, 0x83), // cyan (teal) +(0x6e, 0x77, 0x81), // white (grey) +(0x57, 0x60, 0x6a), // bright black +(0xcf, 0x22, 0x2e), // bright red +(0x1f, 0x88, 0x3d), // bright green +(0xbf, 0x87, 0x00), // bright yellow (amber) +(0x21, 0x8b, 0xff), // bright blue +(0xa4, 0x75, 0xf9), // bright magenta +(0x31, 0x92, 0xaa), // bright cyan +(0x8c, 0x95, 0x9f), // bright white +⋮---- +// Atom's "One Light" — the light counterpart to the ubiquitous One Dark; +// a soft off-white (#fafafa) ground with the signature One blue accent. +// Clean and widely loved as an editor/terminal light scheme. +⋮---- +// Catppuccin "Latte" — the light flavor of the immensely popular pastel +// Catppuccin family; a developer favorite across editors and terminals. +⋮---- +// Rosé Pine "Dawn" — the light variant of the beloved Rosé Pine family +// (soho vibes, muted rose/gold/iris on a warm off-white). Distinctive and +// widely adored for its soft, tasteful palette. Official terminal mapping: +// pine→green, foam→blue, rose→cyan, love→red, gold→yellow, iris→magenta. +⋮---- +background: 0xfaf4ed, // base +foreground: 0x575279, // text +accent: 0x907aa9, // iris +⋮---- +(0xf2, 0xe9, 0xe1), // black (overlay) +(0xb4, 0x63, 0x7a), // red (love) +(0x28, 0x69, 0x83), // green (pine) +(0xea, 0x9d, 0x34), // yellow (gold) +(0x56, 0x94, 0x9f), // blue (foam) +(0x90, 0x7a, 0xa9), // magenta (iris) +(0xd7, 0x82, 0x7e), // cyan (rose) +(0x57, 0x52, 0x79), // white (text) +(0x98, 0x93, 0xa5), // bright black (muted) +(0xb4, 0x63, 0x7a), // bright red +(0x28, 0x69, 0x83), // bright green +(0xea, 0x9d, 0x34), // bright yellow +(0x56, 0x94, 0x9f), // bright blue +(0x90, 0x7a, 0xa9), // bright magenta +(0xd7, 0x82, 0x7e), // bright cyan +(0x57, 0x52, 0x79), // bright white +⋮---- +// Rosé Pine (main) — the dark counterpart to Dawn: a deep muted-purple base +// (#191724) with the signature rose/gold/foam/iris accents. One of the most +// starred and adored schemes across editors and terminals. +⋮---- +background: 0x191724, // base +foreground: 0xe0def4, // text +accent: 0xc4a7e7, // iris +⋮---- +(0x26, 0x23, 0x3a), // black (overlay) +(0xeb, 0x6f, 0x92), // red (love) +(0x31, 0x74, 0x8f), // green (pine) +(0xf6, 0xc1, 0x77), // yellow (gold) +(0x9c, 0xcf, 0xd8), // blue (foam) +(0xc4, 0xa7, 0xe7), // magenta (iris) +(0xeb, 0xbc, 0xba), // cyan (rose) +(0xe0, 0xde, 0xf4), // white (text) +(0x6e, 0x6a, 0x86), // bright black (muted) +(0xeb, 0x6f, 0x92), // bright red +(0x31, 0x74, 0x8f), // bright green +(0xf6, 0xc1, 0x77), // bright yellow +(0x9c, 0xcf, 0xd8), // bright blue +(0xc4, 0xa7, 0xe7), // bright magenta +(0xeb, 0xbc, 0xba), // bright cyan +(0xe0, 0xde, 0xf4), // bright white +⋮---- +mod tests { +⋮---- +fn luminance(c: Rgb) -> f32 { +fn chan(v: u8) -> f32 { +⋮---- +((s + 0.055) / 1.055).powf(2.4) +⋮---- +0.2126 * chan(c.r) + 0.7152 * chan(c.g) + 0.0722 * chan(c.b) +⋮---- +fn contrast(a: Rgb, b: Rgb) -> f32 { +let (l1, l2) = (luminance(a), luminance(b)); +⋮---- +/// Default foreground must stay readable on the background in every theme. + #[test] +fn foreground_is_legible_on_background() { +for p in all() { +let ratio = contrast(rgb_bytes(p.background), rgb_bytes(p.foreground)); +assert!( +⋮---- +/// The selection surface must stay a *tint* — decisively on the + /// background's side of the fg↔bg axis. The renderer keeps each selected +⋮---- +/// background's side of the fg↔bg axis. The renderer keeps each selected + /// cell's own foreground and lays this tone over the cell (nothing +⋮---- +/// cell's own foreground and lays this tone over the cell (nothing + /// re-colors the glyphs for contrast), so a surface that drifted toward +⋮---- +/// re-colors the glyphs for contrast), so a surface that drifted toward + /// the foreground would wash out the very text it highlights. +⋮---- +/// the foreground would wash out the very text it highlights. + #[test] +fn selection_surface_stays_on_the_background_side() { +⋮---- +let ap = p.active_palette(); +let to_bg = contrast(ap.sel_bg, rgb_bytes(p.background)); +let to_fg = contrast(ap.sel_bg, rgb_bytes(p.foreground)); +⋮---- +fn by_id_falls_back_to_default() { +assert_eq!(by_id("nope").id, "light"); +assert_eq!(by_id("dracula").id, "dracula"); +⋮---- +/// `mix` endpoints and midpoint behave. + #[test] +fn mix_blends_channels() { +assert_eq!(mix(0x000000, 0xffffff, 0.0), 0x000000); +assert_eq!(mix(0x000000, 0xffffff, 1.0), 0xffffff); +assert_eq!(mix(0x000000, 0xffffff, 0.5), 0x808080); +```` + +## File: build.rs +````rust +//! Build script — Windows-only: embed the app icon into the `.exe`. +//! +⋮---- +//! +//! On Windows the taskbar / window / Explorer icon comes from an icon *resource* +⋮---- +//! On Windows the taskbar / window / Explorer icon comes from an icon *resource* +//! compiled into the executable; there's no equivalent of macOS's `.app` bundle +⋮---- +//! compiled into the executable; there's no equivalent of macOS's `.app` bundle +//! (which gets its icon from `tty7.icns` via `.github/scripts/bundle.sh`). So we +⋮---- +//! (which gets its icon from `tty7.icns` via `.github/scripts/bundle.sh`). So we +//! compile `assets/favicon.ico` (a multi-res 16–256px ICO) into the binary here. +⋮---- +//! compile `assets/favicon.ico` (a multi-res 16–256px ICO) into the binary here. +//! +⋮---- +//! +//! On every other platform this is a no-op. +⋮---- +//! On every other platform this is a no-op. +fn main() { +⋮---- +println!("cargo:rerun-if-changed=assets/favicon.ico"); +⋮---- +res.set_icon("assets/favicon.ico"); +if let Err(e) = res.compile() { +// Don't fail the build just because the resource compiler is missing; +// the app still runs, it just falls back to the default Windows icon. +println!("cargo:warning=failed to embed Windows icon: {e}"); +```` + +## File: LICENSE +```` +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 l0ng-ai + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +```` + +## File: repomix.config.json +````json +{ + "$schema": "https://repomix.com/schemas/latest/schema.json", + "output": { + "filePath": "repomix-output.xml", + "style": "xml", + "compress": false, + "fileSummary": true, + "directoryStructure": true, + "removeComments": false, + "removeEmptyLines": false, + "showLineNumbers": false, + "git": { + "sortByChanges": true, + "sortByChangesMaxCommits": 100 + } + }, + "include": [], + "ignore": { + "useGitignore": true, + "useDefaultPatterns": true, + "customPatterns": [ + "target/**", + "dist/**", + "Cargo.lock", + "assets/**", + "repomix-output.*" + ] + }, + "security": { + "enableSecurityCheck": true + } +} +```` + +## File: .github/scripts/bundle-windows.ps1 +````powershell +# Usage: bundle-windows.ps1 +# Package the release binary twice from one staged payload: +# dist/tty7--windows-.zip portable (unzip anywhere) +# dist/tty7--windows--setup.exe Inno Setup installer +# (Program Files or per-user, Start Menu shortcut, "Apps" uninstall entry) +# +# Fonts are embedded via include_bytes! and the app icon is compiled into the +# executable as a resource (see build.rs). So the payload is tty7.exe plus a +# sibling completions\ dir (loaded at runtime — see terminal::signature) and the +# license/readme. Both artifacts are unsigned builds — SmartScreen will +# warn on first launch. +$ErrorActionPreference = 'Stop' + +$Target = $args[0] +$Arch = $args[1] +$Version = (Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value +$Name = "tty7-$Version-windows-$Arch" +$Stage = "dist/$Name" + +Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $Stage | Out-Null + +Copy-Item "target/$Target/release/tty7.exe" "$Stage/tty7.exe" +New-Item -ItemType Directory -Force -Path "$Stage/completions" | Out-Null +Copy-Item "assets/completions/*.json" "$Stage/completions/" +Copy-Item LICENSE "$Stage/LICENSE.txt" +Copy-Item README.md "$Stage/README.md" + +Compress-Archive -Path "$Stage/*" -DestinationPath "dist/$Name.zip" -Force + +# Installer, built from the same staged payload. ISCC is on PATH on GitHub's +# windows-latest image; fall back to the default install location. +$Iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source +if (-not $Iscc) { $Iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" } +& $Iscc ` + "/DAppVersion=$Version" ` + "/DStageDir=$((Resolve-Path $Stage).Path)" ` + "/DOutputDir=$((Resolve-Path dist).Path)" ` + "/DOutputName=$Name-setup" ` + .github/scripts/windows-installer.iss +if ($LASTEXITCODE -ne 0) { throw "ISCC exited with $LASTEXITCODE" } + +Remove-Item -Recurse -Force $Stage +Write-Host "OK dist/$Name.zip" +Write-Host "OK dist/$Name-setup.exe" +```` + +## File: .github/workflows/release.yml +````yaml +name: Release + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - runner: macos-14 + os: macos + arch: arm64 + target: aarch64-apple-darwin + # macos-13 was retired; macos-15-intel is the remaining hosted x86_64 image. + - runner: macos-15-intel + os: macos + arch: x86_64 + target: x86_64-apple-darwin + - runner: windows-latest + os: windows + arch: x86_64 + target: x86_64-pc-windows-msvc + - runner: ubuntu-latest + os: linux + arch: x86_64 + target: x86_64-unknown-linux-gnu + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout tty7 + uses: actions/checkout@v4 + with: + path: tty7 + + # gpui-component is pulled as a git dependency (see Cargo.toml's patch + # section), so no sibling checkout is needed. + + # gpui's Linux backends resolve the x11/wayland/xkb/font dev packages via + # pkg-config at build time — the same set the README documents for + # building from source on Linux. + - name: Install Linux system dependencies + if: matrix.os == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y pkg-config cmake clang libxkbcommon-dev \ + libxkbcommon-x11-dev libfontconfig1-dev libfreetype6-dev \ + libwayland-dev libx11-dev libxcb1-dev libzstd-dev libssl-dev + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: tty7 + + - name: Build + working-directory: tty7 + run: cargo build --release --target ${{ matrix.target }} + + # ---- Packaging: one step per OS ---------------------------------------- + # macOS gets a signed + notarized drag-to-Applications DMG. Windows gets + # an Inno Setup installer plus a portable zip; Linux a tarball — both + # unsigned, of the self-contained binary (fonts are embedded via + # include_bytes!; the Windows icon is compiled in via build.rs). + - name: Bundle macOS DMG + if: matrix.os == 'macos' + working-directory: tty7 + env: + # macOS code signing — the cert is imported into a throwaway keychain. + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + # Notarization — required for Developer ID builds to pass Gatekeeper. + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: bash .github/scripts/bundle-macos.sh "${{ matrix.target }}" "${{ matrix.arch }}" + + - name: Package Linux tarball + if: matrix.os == 'linux' + working-directory: tty7 + run: bash .github/scripts/bundle-linux.sh "${{ matrix.target }}" "${{ matrix.arch }}" + + - name: Package Windows installer + zip + if: matrix.os == 'windows' + working-directory: tty7 + shell: pwsh + run: '& ./.github/scripts/bundle-windows.ps1 "${{ matrix.target }}" "${{ matrix.arch }}"' + + - name: Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: | + tty7/dist/*.dmg + tty7/dist/*.tar.gz + tty7/dist/*.zip + tty7/dist/*-setup.exe +```` + +## File: src/core/actions.rs +````rust +//! Menu / keyboard actions, defined in one place so both the application shell +//! (`app.rs`) and the terminal view (`terminal::view`) can reference them +⋮---- +//! (`app.rs`) and the terminal view (`terminal::view`) can reference them +//! without depending on each other. They drive the macOS menu bar and the +⋮---- +//! without depending on each other. They drive the macOS menu bar and the +//! keymap, so a click and a shortcut go through exactly the same path. +⋮---- +//! keymap, so a click and a shortcut go through exactly the same path. +use gpui::actions; +⋮---- +actions!( +```` + +## File: src/core/session.rs +````rust +//! Session persistence: remember the tab / split-pane layout and each +//! terminal's working directory across restarts, plus a stack of recently +⋮---- +//! terminal's working directory across restarts, plus a stack of recently +//! closed tabs for "Reopen Closed Tab". +⋮---- +//! closed tabs for "Reopen Closed Tab". +//! +⋮---- +//! +//! The on-disk model mirrors the live `Pane` tree but stays purely +⋮---- +//! The on-disk model mirrors the live `Pane` tree but stays purely +//! serializable (no GPUI entities, no `gpui::Axis` which isn't `Serialize`). +⋮---- +//! serializable (no GPUI entities, no `gpui::Axis` which isn't `Serialize`). +//! It lives at `~/.config/tty7/session.json`, alongside `config.json`. +⋮---- +//! It lives at `~/.config/tty7/session.json`, alongside `config.json`. +//! +⋮---- +//! +//! All IO and parsing is best-effort: a missing/corrupt file just means "no +⋮---- +//! All IO and parsing is best-effort: a missing/corrupt file just means "no +//! session to restore", and write failures are logged rather than fatal — the +⋮---- +//! session to restore", and write failures are logged rather than fatal — the +//! app must never crash or stall over session bookkeeping. +⋮---- +//! app must never crash or stall over session bookkeeping. +use std::path::PathBuf; +⋮---- +/// Split orientation, mirroring `gpui::Axis` (which isn't `Serialize`). +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum SessionAxis { +⋮---- +/// A serializable mirror of one tab's `Pane` tree. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SessionPane { +/// A single terminal, restored in `cwd` (or the default dir if `None`). + Leaf { +⋮---- +/// Daemon pane id this leaf was mirroring. On restore we re-`attach` to + /// it when the daemon still has it alive (process + scrollback intact), +⋮---- +/// it when the daemon still has it alive (process + scrollback intact), + /// else fall back to spawning a fresh shell in `cwd`. `None` for sessions +⋮---- +/// else fall back to spawning a fresh shell in `cwd`. `None` for sessions + /// written by an older build (they just spawn fresh). +⋮---- +/// written by an older build (they just spawn fresh). + #[serde(default)] +⋮---- +/// A split of two subtrees along `axis`, with `a` taking `ratio` of space. + Split { +⋮---- +fn default_ratio() -> f32 { +⋮---- +/// A serializable mirror of one tab: its pane tree plus an optional user-set +/// name (from "Rename Tab"). A missing `name` falls back to the title-derived +⋮---- +/// name (from "Rename Tab"). A missing `name` falls back to the title-derived +/// label at render time. +⋮---- +/// label at render time. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionTab { +⋮---- +/// The full saved session: the open tabs and which one was active. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +⋮---- +pub struct Session { +⋮---- +impl Session { +/// Load the saved session. Returns `None` when the file is absent or + /// unreadable, and `None` (with a warning) when it fails to parse — never +⋮---- +/// unreadable, and `None` (with a warning) when it fails to parse — never + /// panics. +⋮---- +/// panics. + pub fn load() -> Option { +⋮---- +pub fn load() -> Option { +⋮---- +// Absent/unreadable file is the normal first-run case: silently None. +let text = std::fs::read_to_string(&path).ok()?; +⋮---- +Ok(session) => Some(session), +⋮---- +/// Persist the session as JSON, creating the parent directory if needed. + /// Any IO/serialization error is logged and swallowed. +⋮---- +/// Any IO/serialization error is logged and swallowed. + pub fn save(&self) { +⋮---- +pub fn save(&self) { +⋮---- +if let Some(parent) = path.parent() { +⋮---- +if let Err(e) = crate::core::config::write_atomic(&path, json.as_bytes()) { +⋮---- +/// `~/.config/tty7/session.json`, alongside `config.json`. + fn path() -> Option { +⋮---- +fn path() -> Option { +⋮---- +/// Helpers for every test that touches the on-disk `session.json`. The +/// config-dir pin is process-wide (`set_config_dir` is first-call-wins), so +⋮---- +/// config-dir pin is process-wide (`set_config_dir` is first-call-wins), so +/// the file is process-wide too — any test that reads or writes it must hold +⋮---- +/// the file is process-wide too — any test that reads or writes it must hold +/// [`lock_session_file`] across the whole read/write sequence, or parallel +⋮---- +/// [`lock_session_file`] across the whole read/write sequence, or parallel +/// tests clobber each other's session. +⋮---- +/// tests clobber each other's session. +#[cfg(test)] +pub(crate) mod test_support { +⋮---- +/// Serialize access to the shared `session.json`. + pub(crate) fn lock_session_file() -> MutexGuard<'static, ()> { +⋮---- +pub(crate) fn lock_session_file() -> MutexGuard<'static, ()> { +// A poisoned lock just means another test failed mid-sequence; every +// holder rewrites the file from scratch, so the state is still sound. +SESSION_FILE.lock().unwrap_or_else(|e| e.into_inner()) +⋮---- +/// Pin the process config dir at a shared temp location so `save`/`load` + /// (which resolve `session.json` under it) never touch the real `~/.config`. +⋮---- +/// (which resolve `session.json` under it) never touch the real `~/.config`. + /// `set_config_dir` is first-call-wins; every caller computes the same path. +⋮---- +/// `set_config_dir` is first-call-wins; every caller computes the same path. + pub(crate) fn pin_config_dir() -> PathBuf { +⋮---- +pub(crate) fn pin_config_dir() -> PathBuf { +let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); +std::fs::create_dir_all(&dir).ok(); +crate::core::config::set_config_dir(dir.clone()); +⋮---- +mod tests { +⋮---- +fn session_json_round_trips_nested_tree() { +⋮---- +tabs: vec![ +⋮---- +let json = serde_json::to_string(&session).unwrap(); +let back: Session = serde_json::from_str(&json).unwrap(); +assert_eq!(back.active, 1); +assert_eq!(back.tabs.len(), 2); +assert!(matches!( +⋮---- +SessionPane::Split { ratio, .. } => assert!((ratio - 0.3).abs() < 1e-6), +_ => panic!("expected a split"), +⋮---- +fn session_defaults_fill_missing_fields() { +// An empty object → default (active 0, no tabs). +let s: Session = serde_json::from_str("{}").unwrap(); +assert_eq!(s.active, 0); +assert!(s.tabs.is_empty()); +⋮---- +// A split without a ratio falls back to the 0.5 default, and a leaf +// without cwd/pane_id decodes with `None`s. +⋮---- +.unwrap(); +⋮---- +SessionPane::Split { ratio, .. } => assert_eq!(ratio, 0.5), +_ => panic!("expected split"), +⋮---- +fn save_then_load_recovers_the_session() { +let _file = lock_session_file(); +pin_config_dir(); +⋮---- +tabs: vec![SessionTab { +⋮---- +session.save(); +let loaded = Session::load().expect("a saved session should load back"); +assert_eq!(loaded.tabs.len(), 1); +assert_eq!(loaded.tabs[0].name.as_deref(), Some("main")); +```` + +## File: src/core/shells.rs +````rust +//! Shell discovery: enumerate the shells installed on this machine so the UI +//! can offer them in the new-tab dropdown, and resolve the platform default. +⋮---- +//! can offer them in the new-tab dropdown, and resolve the platform default. +//! +⋮---- +//! +//! Mirrors Warp's approach (`app/src/util/windows.rs` there): rather than +⋮---- +//! Mirrors Warp's approach (`app/src/util/windows.rs` there): rather than +//! asking the user to type a program path into config, probe the well-known +⋮---- +//! asking the user to type a program path into config, probe the well-known +//! install locations up front and present what actually exists. +⋮---- +//! install locations up front and present what actually exists. +//! +⋮---- +//! +//! - **Unix**: `/etc/shells` is the system's own inventory — parse it, keep the +⋮---- +//! - **Unix**: `/etc/shells` is the system's own inventory — parse it, keep the +//! entries that exist, dedupe by basename (the same shell often appears as +⋮---- +//! entries that exist, dedupe by basename (the same shell often appears as +//! both `/bin/zsh` and `/usr/local/bin/zsh`). The login shell (`$SHELL`) is +⋮---- +//! both `/bin/zsh` and `/usr/local/bin/zsh`). The login shell (`$SHELL`) is +//! seeded first so it wins its dedupe slot and leads the list. Package +⋮---- +//! seeded first so it wins its dedupe slot and leads the list. Package +//! managers don't register what they install there (Homebrew only *suggests* +⋮---- +//! managers don't register what they install there (Homebrew only *suggests* +//! adding fish to `/etc/shells`), so a curated set of well-known shells is +⋮---- +//! adding fish to `/etc/shells`), so a curated set of well-known shells is +//! then probed on `PATH` as the catch-all. +⋮---- +//! then probed on `PATH` as the catch-all. +//! - **Windows**: there is no inventory file, so probe each shell's known +⋮---- +//! - **Windows**: there is no inventory file, so probe each shell's known +//! homes: PowerShell 7 across its six-ish install roots, Windows PowerShell +⋮---- +//! homes: PowerShell 7 across its six-ish install roots, Windows PowerShell +//! in System32, cmd via `%ComSpec%`, Git Bash under the Git install, and WSL +⋮---- +//! in System32, cmd via `%ComSpec%`, Git Bash under the Git install, and WSL +//! distributions via `wsl.exe -l -q`. +⋮---- +//! distributions via `wsl.exe -l -q`. +//! +⋮---- +//! +//! Everything effectful (filesystem, env, spawning `wsl.exe`) stays in thin +⋮---- +//! Everything effectful (filesystem, env, spawning `wsl.exe`) stays in thin +//! wrappers; the parsing/selection logic is pure functions with unit tests. +⋮---- +//! wrappers; the parsing/selection logic is pure functions with unit tests. +//! Discovery can take a beat (WSL enumeration spawns a process), so callers +⋮---- +//! Discovery can take a beat (WSL enumeration spawns a process), so callers +//! run [`detect_shells`] off the UI thread. +⋮---- +//! run [`detect_shells`] off the UI thread. +use std::path::Path; +// The probe helpers below build candidate paths; they're Windows-only code. +⋮---- +use std::path::PathBuf; +⋮---- +/// One launchable shell surfaced in the new-tab dropdown. `program` + `args` +/// have the same shape as `config::ShellConfig` / `protocol::ShellSpec`: a +⋮---- +/// have the same shape as `config::ShellConfig` / `protocol::ShellSpec`: a +/// bare name resolved via `PATH` or an absolute path, plus launch arguments. +⋮---- +/// bare name resolved via `PATH` or an absolute path, plus launch arguments. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectedShell { +/// Human-readable menu label, e.g. `zsh`, `PowerShell 7`, `WSL · Ubuntu`. + pub label: String, +⋮---- +impl DetectedShell { +fn bare(label: impl Into, program: impl Into) -> Self { +⋮---- +label: label.into(), +program: program.into(), +⋮---- +/// Enumerate the shells installed on this machine, best-effort. Order is +/// meaningful: the entry most likely to be the user's default comes first. +⋮---- +/// meaningful: the entry most likely to be the user's default comes first. +/// Runs filesystem probes (and `wsl.exe` on Windows) — call off the UI thread. +⋮---- +/// Runs filesystem probes (and `wsl.exe` on Windows) — call off the UI thread. +pub fn detect_shells() -> Vec { +⋮---- +pub fn detect_shells() -> Vec { +⋮---- +detect_unix() +⋮---- +detect_windows() +⋮---- +/// The short display name of the shell a *default* spawn resolves to: the +/// config override when set, otherwise the platform default (`$SHELL` on Unix, +⋮---- +/// config override when set, otherwise the platform default (`$SHELL` on Unix, +/// the probed PowerShell on Windows). Drives the "Default (zsh)" menu label. +⋮---- +/// the probed PowerShell on Windows). Drives the "Default (zsh)" menu label. +pub fn default_shell_name(configured: Option<&str>) -> String { +⋮---- +pub fn default_shell_name(configured: Option<&str>) -> String { +⋮---- +Some(p) if !p.trim().is_empty() => p.to_string(), +⋮---- +std::env::var("SHELL").unwrap_or_else(|_| "sh".into()) +⋮---- +windows_default_shell().to_string() +⋮---- +basename(&program) +⋮---- +/// The last path component of `program`, lowercased on Windows and stripped of +/// a trailing `.exe` — `C:\...\pwsh.exe` and `/usr/local/bin/fish` both reduce +⋮---- +/// a trailing `.exe` — `C:\...\pwsh.exe` and `/usr/local/bin/fish` both reduce +/// to their bare shell name for labels and dedupe keys. +⋮---- +/// to their bare shell name for labels and dedupe keys. +fn basename(program: &str) -> String { +⋮---- +fn basename(program: &str) -> String { +⋮---- +.file_name() +.map(|n| n.to_string_lossy().into_owned()) +.unwrap_or_else(|| program.to_string()); +if cfg!(windows) { +let lower = base.to_ascii_lowercase(); +lower.strip_suffix(".exe").unwrap_or(&lower).to_string() +⋮---- +// --------------------------------------------------------------------------- +// Unix +⋮---- +/// Parse `/etc/shells` content: one absolute path per line, `#` comments and +/// blank lines skipped. Pure — the caller supplies the file content. +⋮---- +/// blank lines skipped. Pure — the caller supplies the file content. +#[cfg_attr(windows, allow(dead_code))] +fn parse_etc_shells(content: &str) -> Vec { +⋮---- +.lines() +.map(str::trim) +.filter(|l| !l.is_empty() && !l.starts_with('#')) +.map(str::to_string) +.collect() +⋮---- +/// Order + dedupe the Unix candidate list: keep the first occurrence of each +/// basename that `exists` confirms, labelled by that basename. Pure — `exists` +⋮---- +/// basename that `exists` confirms, labelled by that basename. Pure — `exists` +/// is injected so tests need no real filesystem. +⋮---- +/// is injected so tests need no real filesystem. +#[cfg_attr(windows, allow(dead_code))] +fn unix_shells_from( +⋮---- +if !exists(&path) { +⋮---- +let name = basename(&path); +if seen.insert(name.clone()) { +out.push(DetectedShell::bare(name, path)); +⋮---- +/// Shells package managers commonly install *without* registering them in +/// `/etc/shells` — Homebrew and nix leave that edit to the user, and few make +⋮---- +/// `/etc/shells` — Homebrew and nix leave that edit to the user, and few make +/// it, so `/etc/shells` misses e.g. a brew-installed fish entirely. Probed on +⋮---- +/// it, so `/etc/shells` misses e.g. a brew-installed fish entirely. Probed on +/// `PATH` (the login-shell-enriched one — see `enrich_path_from_login_shell` +⋮---- +/// `PATH` (the login-shell-enriched one — see `enrich_path_from_login_shell` +/// in `main` — so Dock launches see Homebrew's prefix too). +⋮---- +/// in `main` — so Dock launches see Homebrew's prefix too). +#[cfg_attr(windows, allow(dead_code))] +⋮---- +/// Expand [`PATH_PROBED_SHELLS`] into concrete candidate paths, one per +/// `path_var` directory in `PATH` order. Fed through the same exists + dedupe +⋮---- +/// `path_var` directory in `PATH` order. Fed through the same exists + dedupe +/// pass as the `/etc/shells` entries, so the first directory that actually +⋮---- +/// pass as the `/etc/shells` entries, so the first directory that actually +/// holds the shell wins — `which` semantics without spawning anything. +⋮---- +/// holds the shell wins — `which` semantics without spawning anything. +/// Relative `PATH` entries are skipped: a `./fish` candidate would resolve +⋮---- +/// Relative `PATH` entries are skipped: a `./fish` candidate would resolve +/// somewhere else at every spawn. Pure — the caller supplies `path_var`. +⋮---- +/// somewhere else at every spawn. Pure — the caller supplies `path_var`. +#[cfg_attr(windows, allow(dead_code))] +fn path_shell_candidates(path_var: &str) -> Vec { +let dirs: Vec<&str> = path_var.split(':').filter(|d| d.starts_with('/')).collect(); +⋮---- +.iter() +.flat_map(|name| { +dirs.iter() +.map(move |dir| format!("{}/{name}", dir.trim_end_matches('/'))) +⋮---- +fn detect_unix() -> Vec { +// Seed the login shell first so it wins its basename's dedupe slot and +// leads the list — it also covers shells installed outside /etc/shells +// (nix/homebrew installs the user pointed $SHELL at without registering). +// The PATH probe comes last: registered shells keep their `/etc/shells` +// paths, and only the unregistered leftovers (brew fish, nushell, …) are +// picked up from `PATH`. +let login = std::env::var("SHELL").ok().filter(|s| !s.is_empty()); +let etc = std::fs::read_to_string("/etc/shells").unwrap_or_default(); +let path_var = std::env::var("PATH").unwrap_or_default(); +⋮---- +.into_iter() +.chain(parse_etc_shells(&etc)) +.chain(path_shell_candidates(&path_var)); +unix_shells_from(candidates, |p| Path::new(p).is_file()) +⋮---- +// Windows +⋮---- +/// The Windows shell a *default* spawn launches: PowerShell 7 (`pwsh.exe`) +/// when installed, else Windows PowerShell. Probed once and cached — the +⋮---- +/// when installed, else Windows PowerShell. Probed once and cached — the +/// daemon consults this on every pane spawn. +⋮---- +/// daemon consults this on every pane spawn. +#[cfg(windows)] +pub fn windows_default_shell() -> &'static str { +use std::sync::OnceLock; +⋮---- +DEFAULT.get_or_init(|| { +find_pwsh7() +.map(|p| p.to_string_lossy().into_owned()) +.unwrap_or_else(|| "powershell.exe".to_string()) +⋮---- +/// Locate PowerShell 7 the way Warp does: fixed install roots first (Program +/// Files x64/x86/ARM, dotnet tools, scoop, the Microsoft Store shim), then a +⋮---- +/// Files x64/x86/ARM, dotnet tools, scoop, the Microsoft Store shim), then a +/// `PATH` search as the catch-all. +⋮---- +/// `PATH` search as the catch-all. +#[cfg(windows)] +fn find_pwsh7() -> Option { +⋮---- +if let Some(pf) = std::env::var_os(var).filter(|v| !v.is_empty()) { +⋮---- +roots.push(pf.join("PowerShell").join("7")); +roots.push(pf.join("PowerShell").join("7-preview")); +⋮---- +if let Some(home) = std::env::var_os("USERPROFILE").filter(|v| !v.is_empty()) { +⋮---- +roots.push(home.join(".dotnet").join("tools")); +roots.push(home.join("scoop").join("shims")); +⋮---- +if let Some(local) = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) { +roots.push(PathBuf::from(local).join("Microsoft").join("WindowsApps")); +⋮---- +pick_first_existing(roots.iter().map(|r| r.join("pwsh.exe"))) +.or_else(|| find_in_path("pwsh.exe")) +⋮---- +/// First candidate that exists on disk. Shared by the per-shell probes. +#[cfg(windows)] +fn pick_first_existing(candidates: impl IntoIterator) -> Option { +candidates.into_iter().find(|p| p.is_file()) +⋮---- +/// Minimal `PATH` search (no PATHEXT expansion — callers pass the full +/// `foo.exe` name). +⋮---- +/// `foo.exe` name). +#[cfg(windows)] +fn find_in_path(exe: &str) -> Option { +⋮---- +.map(|dir| dir.join(exe)) +.find(|p| p.is_file()) +⋮---- +fn detect_windows() -> Vec { +⋮---- +PathBuf::from(std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into())); +⋮---- +if let Some(pwsh) = find_pwsh7() { +out.push(DetectedShell::bare( +⋮---- +pwsh.to_string_lossy().into_owned(), +⋮---- +.join("System32") +.join("WindowsPowerShell") +.join("v1.0") +.join("powershell.exe"); +if ps5.is_file() { +⋮---- +ps5.to_string_lossy().into_owned(), +⋮---- +.map(PathBuf::from) +.filter(|p| p.is_file()) +.unwrap_or_else(|| system_root.join("System32").join("cmd.exe")); +if cmd.is_file() { +⋮---- +cmd.to_string_lossy().into_owned(), +⋮---- +if let Some(bash) = find_git_bash() { +out.push(DetectedShell { +label: "Git Bash".into(), +program: bash.to_string_lossy().into_owned(), +// Interactive login shell — matches Git Bash's own launcher. +args: vec!["-i".into(), "-l".into()], +⋮---- +for distro in list_wsl_distros() { +⋮---- +label: format!("WSL · {distro}"), +program: "wsl.exe".into(), +// `--cd ~` lands in the distro's home rather than a translated +// Windows path the inner shell can't do much with. +args: vec!["--distribution".into(), distro, "--cd".into(), "~".into()], +⋮---- +/// Git Bash from the usual Git-for-Windows install roots (machine-wide x64, +/// x86, and the per-user installer's home). +⋮---- +/// x86, and the per-user installer's home). +#[cfg(windows)] +fn find_git_bash() -> Option { +⋮---- +candidates.push(PathBuf::from(pf).join("Git").join("bin").join("bash.exe")); +⋮---- +candidates.push( +⋮---- +.join("Programs") +.join("Git") +.join("bin") +.join("bash.exe"), +⋮---- +pick_first_existing(candidates) +⋮---- +/// Installed WSL distribution names via `wsl.exe -l -q`, or empty when WSL is +/// absent. `CREATE_NO_WINDOW` keeps the probe from flashing a console window +⋮---- +/// absent. `CREATE_NO_WINDOW` keeps the probe from flashing a console window +/// (we're a GUI process). +⋮---- +/// (we're a GUI process). +#[cfg(windows)] +fn list_wsl_distros() -> Vec { +⋮---- +.args(["-l", "-q"]) +.creation_flags(CREATE_NO_WINDOW) +.output() +⋮---- +if !output.status.success() { +⋮---- +parse_wsl_list(&output.stdout) +⋮---- +/// Decode `wsl.exe -l -q` output — UTF-16LE, one distro per line — skipping +/// blanks and Docker Desktop's internal distros. Pure for testability. +⋮---- +/// blanks and Docker Desktop's internal distros. Pure for testability. +#[cfg_attr(unix, allow(dead_code))] +fn parse_wsl_list(bytes: &[u8]) -> Vec { +// UTF-16LE: pair up bytes, tolerate a stray trailing byte. +⋮---- +.chunks_exact(2) +.map(|c| u16::from_le_bytes([c[0], c[1]])) +.collect(); +⋮---- +text.lines() +.map(|l| l.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}' || c == '\0')) +.filter(|l| !l.is_empty() && !l.starts_with("docker-desktop")) +⋮---- +mod tests { +⋮---- +fn parse_etc_shells_skips_comments_and_blanks() { +⋮---- +assert_eq!( +⋮---- +fn unix_shells_dedupe_by_basename_keeping_first() { +// The login shell (seeded first) claims "zsh"; the /etc/shells copy of +// zsh under another prefix is dropped; missing files are dropped. +⋮---- +.map(String::from); +⋮---- +let got = unix_shells_from(candidates, exists); +⋮---- +fn path_shell_candidates_expand_dirs_in_order_skipping_relative() { +let cands = path_shell_candidates("/opt/homebrew/bin:relative:.:/usr/bin/:"); +// Per shell, one candidate per *absolute* PATH dir, in PATH order, with +// any trailing slash on the dir normalized away. +assert_eq!(cands[0], "/opt/homebrew/bin/fish"); +assert_eq!(cands[1], "/usr/bin/fish"); +assert!(cands.contains(&"/opt/homebrew/bin/nu".to_string())); +assert!(cands.iter().all(|c| c.starts_with('/'))); +assert_eq!(cands.len(), PATH_PROBED_SHELLS.len() * 2); +⋮---- +fn unregistered_path_shells_are_detected_after_etc_shells() { +// A brew-installed fish: absent from /etc/shells (and not the login +// shell), present on PATH — must still make the list, after the +// registered shells. zsh exists on PATH too but keeps its /etc/shells +// slot via the basename dedupe. +let etc = ["/bin/zsh".to_string(), "/bin/bash".to_string()]; +⋮---- +.chain(path_shell_candidates("/opt/homebrew/bin:/usr/bin")); +⋮---- +matches!( +⋮---- +fn parse_wsl_list_decodes_utf16le_and_filters() { +// "Ubuntu\r\ndocker-desktop\r\ndocker-desktop-data\r\nDebian\r\n\r\n" +⋮---- +let bytes: Vec = text.encode_utf16().flat_map(u16::to_le_bytes).collect(); +assert_eq!(parse_wsl_list(&bytes), vec!["Ubuntu", "Debian"]); +⋮---- +fn parse_wsl_list_tolerates_bom_and_empty_input() { +assert_eq!(parse_wsl_list(&[]), Vec::::new()); +⋮---- +assert_eq!(parse_wsl_list(&bytes), vec!["Arch"]); +⋮---- +fn basename_reduces_paths_to_shell_names() { +assert_eq!(basename("/usr/local/bin/fish"), "fish"); +assert_eq!(basename("zsh"), "zsh"); +⋮---- +assert_eq!(basename(r"C:\Program Files\PowerShell\7\pwsh.exe"), "pwsh"); +assert_eq!(basename("CMD.EXE"), "cmd"); +⋮---- +fn default_shell_name_prefers_the_configured_program() { +assert_eq!(default_shell_name(Some("/usr/bin/fish")), "fish"); +assert_eq!(default_shell_name(Some("pwsh")), "pwsh"); +// Blank config falls through to the platform default — just assert it +// yields *something* non-empty without pinning this host's $SHELL. +assert!(!default_shell_name(None).is_empty()); +assert!(!default_shell_name(Some(" ")).is_empty()); +```` + +## File: src/daemon/protocol.rs +````rust +//! Wire protocol between the GUI **client** and the persistent **daemon**. +//! +⋮---- +//! +//! One Unix-domain-socket connection carries exactly one *pane* (a single PTY + +⋮---- +//! One Unix-domain-socket connection carries exactly one *pane* (a single PTY + +//! child). The GUI opens one connection per terminal view; session listing uses +⋮---- +//! child). The GUI opens one connection per terminal view; session listing uses +//! a short-lived control connection. This mirrors the in-process model where one +⋮---- +//! a short-lived control connection. This mirrors the in-process model where one +//! `TerminalView` owns one terminal, so nothing higher up needs multiplexing. +⋮---- +//! `TerminalView` owns one terminal, so nothing higher up needs multiplexing. +//! +⋮---- +//! +//! ## Framing +⋮---- +//! ## Framing +//! +⋮---- +//! +//! Every message is a length-prefixed frame: +⋮---- +//! Every message is a length-prefixed frame: +//! +⋮---- +//! +//! ```text +⋮---- +//! ```text +//! [u32 LE payload_len][u8 kind][payload (payload_len bytes)] +⋮---- +//! [u32 LE payload_len][u8 kind][payload (payload_len bytes)] +//! ``` +⋮---- +//! ``` +//! +⋮---- +//! +//! The `kind` byte selects the variant. Hot-path variants (`Input`, `Output`, +⋮---- +//! The `kind` byte selects the variant. Hot-path variants (`Input`, `Output`, +//! `Snapshot`) carry the raw PTY bytes *verbatim* as the payload — no +⋮---- +//! `Snapshot`) carry the raw PTY bytes *verbatim* as the payload — no +//! serialization, no copy beyond the frame. Cold control variants serialize +⋮---- +//! serialization, no copy beyond the frame. Cold control variants serialize +//! their small structs as JSON, which keeps the wire format easy to evolve and +⋮---- +//! their small structs as JSON, which keeps the wire format easy to evolve and +//! debug without pulling in a binary-codec dependency. +⋮---- +//! debug without pulling in a binary-codec dependency. +//! +⋮---- +//! +//! Decoding never trusts the length blindly: frames larger than [`MAX_FRAME`] +⋮---- +//! Decoding never trusts the length blindly: frames larger than [`MAX_FRAME`] +//! are rejected so a desynced/hostile peer can't make us allocate unboundedly. +⋮---- +//! are rejected so a desynced/hostile peer can't make us allocate unboundedly. +⋮---- +use std::path::PathBuf; +⋮---- +/// Upper bound on a single frame's payload. A `Snapshot` replays the daemon's +/// byte ring (a few MB by default), so this is generous; anything past it is a +⋮---- +/// byte ring (a few MB by default), so this is generous; anything past it is a +/// protocol desync and we error rather than allocate. +⋮---- +/// protocol desync and we error rather than allocate. +pub const MAX_FRAME: usize = 64 * 1024 * 1024; +⋮---- +/// Terminal geometry shared by spawn/attach/resize. Cell pixel size travels too +/// so the daemon can set an accurate `TIOCSWINSZ` (`ws_xpixel`/`ws_ypixel`), +⋮---- +/// so the daemon can set an accurate `TIOCSWINSZ` (`ws_xpixel`/`ws_ypixel`), +/// which some full-screen apps read. +⋮---- +/// which some full-screen apps read. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct WinSize { +⋮---- +/// A shell program plus launch arguments, carried by `Spawn` when the user +/// picked a specific shell from the new-tab dropdown. Same shape as +⋮---- +/// picked a specific shell from the new-tab dropdown. Same shape as +/// `config::ShellConfig`, but defined here so the wire format doesn't depend +⋮---- +/// `config::ShellConfig`, but defined here so the wire format doesn't depend +/// on the config module's evolution. +⋮---- +/// on the config module's evolution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShellSpec { +/// Bare name resolved via `PATH` (`"pwsh"`) or an absolute path. + pub program: String, +⋮---- +/// Metadata for one live pane, returned by `List` for session restore / pickers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneInfo { +⋮---- +/// False once the child has exited but the pane lingers (so a client can + /// still read its final scrollback). +⋮---- +/// still read its final scrollback). + pub alive: bool, +⋮---- +/// Messages the GUI client sends to the daemon. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientMsg { +/// Create a new pane (spawn a shell) in `cwd`, sized to `size`. The daemon + /// replies `Spawned`, then this connection becomes that pane's stream. +⋮---- +/// replies `Spawned`, then this connection becomes that pane's stream. + /// `shell` overrides the daemon's default shell resolution (config → +⋮---- +/// `shell` overrides the daemon's default shell resolution (config → + /// platform default) when the user picked one from the new-tab dropdown. +⋮---- +/// platform default) when the user picked one from the new-tab dropdown. + Spawn { +⋮---- +/// Bind this connection to an existing pane and (re)size it. The daemon + /// replies with a `Snapshot` then live `Output`. +⋮---- +/// replies with a `Snapshot` then live `Output`. + Attach { pane_id: u64, size: WinSize }, +/// Raw bytes typed/pasted into the pane. Hot path — payload is verbatim. + Input(Vec), +/// The client's view changed size; resize the PTY (`SIGWINCH` to the child). + Resize(WinSize), +/// Disconnect from the pane without killing it (it keeps running detached). + Detach, +/// Terminate a pane's child and forget it. + Kill { pane_id: u64 }, +/// Ask for the list of live panes (control connection). + List, +/// Shut the whole daemon down: hang up every pane's child, then exit the + /// process. A control-connection message the GUI sends to force a fresh +⋮---- +/// process. A control-connection message the GUI sends to force a fresh + /// daemon — e.g. so a newly granted macOS permission (Full Disk Access) takes +⋮---- +/// daemon — e.g. so a newly granted macOS permission (Full Disk Access) takes + /// effect, which a long-lived daemon process can't otherwise see. Ends every +⋮---- +/// effect, which a long-lived daemon process can't otherwise see. Ends every + /// running session, so the caller confirms with the user first. +⋮---- +/// running session, so the caller confirms with the user first. + Shutdown, +⋮---- +/// Messages the daemon sends back to the GUI client. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DaemonMsg { +/// Result of `Spawn`: the id of the freshly created pane. + Spawned { pane_id: u64 }, +/// The geometry the pane's ring was recorded under (the PTY's current + /// size), sent immediately before `Snapshot` so the client can size its +⋮---- +/// size), sent immediately before `Snapshot` so the client can size its + /// local grid to match before replaying. Replaying at any other width +⋮---- +/// local grid to match before replaying. Replaying at any other width + /// mis-wraps history and lands relative cursor motion on the wrong rows. +⋮---- +/// mis-wraps history and lands relative cursor motion on the wrong rows. + Size(WinSize), +/// One-shot replay of the pane's byte ring, sent right after `Attach`/`Spawn` + /// so the client's local emulator rebuilds the current screen + scrollback. +⋮---- +/// so the client's local emulator rebuilds the current screen + scrollback. + Snapshot(Vec), +/// Live PTY output tail. Hot path — payload is verbatim. + Output(Vec), +/// The foreground cwd, sniffed daemon-side from OSC 7 / proc lookup. + Cwd(PathBuf), +/// Shell prompt/command state, sniffed daemon-side from OSC 133. + Prompt { +⋮---- +/// The pane's child exited; `code` is its status when known. + Exited { code: Option }, +/// Reply to `List`. + PaneList(Vec), +/// A request failed (e.g. `Attach` to an unknown/dead pane id). + Error(String), +⋮---- +// Kind bytes. Client and daemon have independent spaces (a connection always +// knows which direction it is reading), so the small overlaps are intentional. +mod kind { +// Client -> daemon +⋮---- +/// `Spawn` with an explicit shell override. A separate kind (rather than a + /// new field under `SPAWN`) so a default spawn stays byte-identical on the +⋮---- +/// new field under `SPAWN`) so a default spawn stays byte-identical on the + /// wire: the GUI and the long-lived daemon can be different versions, and +⋮---- +/// wire: the GUI and the long-lived daemon can be different versions, and + /// an old daemon must keep serving new-GUI default spawns. Only picking a +⋮---- +/// an old daemon must keep serving new-GUI default spawns. Only picking a + /// non-default shell sends this, and only a too-old daemon rejects it. +⋮---- +/// non-default shell sends this, and only a too-old daemon rejects it. + pub const SPAWN_SHELL: u8 = 9; +⋮---- +// Daemon -> client +⋮---- +/// Write one framed message: `[u32 LE len][u8 kind][payload]`. +pub fn write_frame(w: &mut W, kind: u8, payload: &[u8]) -> io::Result<()> { +⋮---- +pub fn write_frame(w: &mut W, kind: u8, payload: &[u8]) -> io::Result<()> { +let len = payload.len(); +⋮---- +return Err(io::Error::new( +⋮---- +w.write_all(&(len as u32).to_le_bytes())?; +w.write_all(&[kind])?; +w.write_all(payload)?; +Ok(()) +⋮---- +/// Read one framed message, returning `(kind, payload)`. Returns an `UnexpectedEof` +/// error when the peer closes cleanly between frames (callers treat that as a +⋮---- +/// error when the peer closes cleanly between frames (callers treat that as a +/// normal disconnect). +⋮---- +/// normal disconnect). +pub fn read_frame(r: &mut R) -> io::Result<(u8, Vec)> { +⋮---- +pub fn read_frame(r: &mut R) -> io::Result<(u8, Vec)> { +⋮---- +r.read_exact(&mut len_buf)?; +⋮---- +r.read_exact(&mut kind)?; +let mut payload = vec![0u8; len]; +r.read_exact(&mut payload)?; +Ok((kind[0], payload)) +⋮---- +/// Extract one complete frame from the front of `buf`, if fully buffered — the +/// resumable counterpart of [`read_frame`] for callers that read the stream +⋮---- +/// resumable counterpart of [`read_frame`] for callers that read the stream +/// with timeouts (the client reader enforces the DEC 2026 synchronized-update +⋮---- +/// with timeouts (the client reader enforces the DEC 2026 synchronized-update +/// deadline this way). A partial frame stays in `buf` untouched until more +⋮---- +/// deadline this way). A partial frame stays in `buf` untouched until more +/// bytes arrive, so a read that times out mid-frame loses nothing. Returns +⋮---- +/// bytes arrive, so a read that times out mid-frame loses nothing. Returns +/// `Ok(None)` while the frame is incomplete; an oversize length is a protocol +⋮---- +/// `Ok(None)` while the frame is incomplete; an oversize length is a protocol +/// desync and errors, mirroring `read_frame`. +⋮---- +/// desync and errors, mirroring `read_frame`. +pub fn take_frame(buf: &mut Vec) -> io::Result)>> { +⋮---- +pub fn take_frame(buf: &mut Vec) -> io::Result)>> { +const HEADER: usize = 5; // u32 LE payload length + u8 kind +if buf.len() < HEADER { +return Ok(None); +⋮---- +let len = u32::from_le_bytes(buf[..4].try_into().unwrap()) as usize; +⋮---- +if buf.len() < HEADER + len { +⋮---- +let payload = buf[HEADER..HEADER + len].to_vec(); +buf.drain(..HEADER + len); +Ok(Some((kind, payload))) +⋮---- +/// Serialize a control struct to JSON, mapping serde errors to `io::Error` so +/// the encode/decode surface is a single error type. +⋮---- +/// the encode/decode surface is a single error type. +fn to_json(value: &T) -> io::Result> { +⋮---- +fn to_json(value: &T) -> io::Result> { +serde_json::to_vec(value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) +⋮---- +fn from_json Deserialize<'de>>(bytes: &[u8]) -> io::Result { +serde_json::from_slice(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) +⋮---- +impl ClientMsg { +/// Encode and write this message as one frame. + pub fn encode(&self, w: &mut W) -> io::Result<()> { +⋮---- +pub fn encode(&self, w: &mut W) -> io::Result<()> { +⋮---- +// Default spawn keeps the legacy frame (kind + tuple payload) +// byte-for-byte so an older daemon still serves it; an explicit +// shell rides the newer SPAWN_SHELL frame. See `kind::SPAWN_SHELL`. +⋮---- +} => write_frame(w, kind::SPAWN, &to_json(&(cwd, size))?), +⋮---- +} => write_frame(w, kind::SPAWN_SHELL, &to_json(&(cwd, size, shell))?), +⋮---- +write_frame(w, kind::ATTACH, &to_json(&(pane_id, size))?) +⋮---- +ClientMsg::Input(bytes) => write_frame(w, kind::INPUT, bytes), +ClientMsg::Resize(size) => write_frame(w, kind::RESIZE, &to_json(size)?), +ClientMsg::Detach => write_frame(w, kind::DETACH, &[]), +ClientMsg::Kill { pane_id } => write_frame(w, kind::KILL, &to_json(pane_id)?), +ClientMsg::List => write_frame(w, kind::LIST, &[]), +ClientMsg::Shutdown => write_frame(w, kind::SHUTDOWN, &[]), +⋮---- +/// Reconstruct a message from a decoded frame. + pub fn from_frame(k: u8, payload: Vec) -> io::Result { +⋮---- +pub fn from_frame(k: u8, payload: Vec) -> io::Result { +Ok(match k { +⋮---- +let (cwd, size) = from_json(&payload)?; +⋮---- +let (cwd, size, shell) = from_json(&payload)?; +⋮---- +let (pane_id, size) = from_json(&payload)?; +⋮---- +kind::RESIZE => ClientMsg::Resize(from_json(&payload)?), +⋮---- +pane_id: from_json(&payload)?, +⋮---- +format!("unknown ClientMsg kind {other}"), +⋮---- +/// Read and decode the next client message from `r`. + pub fn read(r: &mut R) -> io::Result { +⋮---- +pub fn read(r: &mut R) -> io::Result { +let (k, payload) = read_frame(r)?; +⋮---- +impl DaemonMsg { +⋮---- +DaemonMsg::Spawned { pane_id } => write_frame(w, kind::SPAWNED, &to_json(pane_id)?), +DaemonMsg::Size(size) => write_frame(w, kind::SIZE, &to_json(size)?), +DaemonMsg::Snapshot(bytes) => write_frame(w, kind::SNAPSHOT, bytes), +DaemonMsg::Output(bytes) => write_frame(w, kind::OUTPUT, bytes), +DaemonMsg::Cwd(path) => write_frame(w, kind::CWD, &to_json(path)?), +⋮---- +} => write_frame(w, kind::PROMPT, &to_json(&(active, at_prompt, last_exit))?), +DaemonMsg::Exited { code } => write_frame(w, kind::EXITED, &to_json(code)?), +DaemonMsg::PaneList(list) => write_frame(w, kind::PANE_LIST, &to_json(list)?), +DaemonMsg::Error(msg) => write_frame(w, kind::ERROR, &to_json(msg)?), +⋮---- +kind::SIZE => DaemonMsg::Size(from_json(&payload)?), +⋮---- +kind::CWD => DaemonMsg::Cwd(from_json(&payload)?), +⋮---- +let (active, at_prompt, last_exit) = from_json(&payload)?; +⋮---- +code: from_json(&payload)?, +⋮---- +kind::PANE_LIST => DaemonMsg::PaneList(from_json(&payload)?), +kind::ERROR => DaemonMsg::Error(from_json(&payload)?), +⋮---- +format!("unknown DaemonMsg kind {other}"), +⋮---- +/// Read and decode the next daemon message from `r`. + pub fn read(r: &mut R) -> io::Result { +⋮---- +mod tests { +⋮---- +/// End-to-end: a full attach session's worth of `ClientMsg`s and `DaemonMsg`s + /// crossing a *real* duplex stream (loopback TCP — the same transport shape the +⋮---- +/// crossing a *real* duplex stream (loopback TCP — the same transport shape the + /// daemon uses on Windows, and close enough to the Unix socket to exercise the +⋮---- +/// daemon uses on Windows, and close enough to the Unix socket to exercise the + /// framing). Unlike the single-`Cursor` round-trips above, this drives both +⋮---- +/// framing). Unlike the single-`Cursor` round-trips above, this drives both + /// directions across a thread boundary with mixed, back-to-back frames, so it +⋮---- +/// directions across a thread boundary with mixed, back-to-back frames, so it + /// catches framing bugs that only surface when `read_frame` must reassemble a +⋮---- +/// catches framing bugs that only surface when `read_frame` must reassemble a + /// message split across TCP segments or sitting behind an unrelated one. This is +⋮---- +/// message split across TCP segments or sitting behind an unrelated one. This is + /// the client↔daemon IPC seam the rest of the suite otherwise only tests in +⋮---- +/// the client↔daemon IPC seam the rest of the suite otherwise only tests in + /// halves. +⋮---- +/// halves. + #[test] +fn full_session_round_trips_over_a_real_duplex_stream() { +use std::io::Write; +⋮---- +use std::thread; +⋮---- +let listener = TcpListener::bind("127.0.0.1:0").unwrap(); +let addr = listener.local_addr().unwrap(); +⋮---- +// A realistic exchange: the client spawns a pane, resizes, types a command +// and detaches; the daemon acknowledges, replays a snapshot, streams output, +// reports prompt state, then exit. +let client_msgs = vec![ +⋮---- +let daemon_msgs = vec![ +⋮---- +// Daemon end: accept, decode every client message, then stream the replies. +let expect_from_client = client_msgs.clone(); +let reply_with = daemon_msgs.clone(); +⋮---- +let (mut sock, _) = listener.accept().unwrap(); +let got: Vec = (0..expect_from_client.len()) +.map(|_| ClientMsg::read(&mut sock).unwrap()) +.collect(); +⋮---- +m.encode(&mut sock).unwrap(); +⋮---- +sock.flush().unwrap(); +⋮---- +// Client end: send every request, then decode every reply. +let mut sock = TcpStream::connect(addr).unwrap(); +⋮---- +let got_from_daemon: Vec = (0..daemon_msgs.len()) +.map(|_| DaemonMsg::read(&mut sock).unwrap()) +⋮---- +let got_from_client = daemon.join().unwrap(); +assert_eq!(got_from_client, client_msgs, "daemon decoded client stream"); +assert_eq!(got_from_daemon, daemon_msgs, "client decoded daemon stream"); +⋮---- +/// Round-trip every `ClientMsg` variant through encode → read. + #[test] +fn client_roundtrip() { +let msgs = vec![ +⋮---- +m.encode(&mut buf).unwrap(); +⋮---- +assert_eq!(*m, ClientMsg::read(&mut cursor).unwrap()); +⋮---- +/// Round-trip every `DaemonMsg` variant through encode → read. + #[test] +fn daemon_roundtrip() { +⋮---- +assert_eq!(*m, DaemonMsg::read(&mut cursor).unwrap()); +⋮---- +/// Wire compatibility across GUI/daemon version skew, both directions: + /// a default spawn (`shell: None`) must emit the *legacy* frame — kind +⋮---- +/// a default spawn (`shell: None`) must emit the *legacy* frame — kind + /// `SPAWN` with a `(cwd, size)` tuple an old daemon can decode — and a +⋮---- +/// `SPAWN` with a `(cwd, size)` tuple an old daemon can decode — and a + /// hand-built legacy frame must decode with `shell: None`. Locks the +⋮---- +/// hand-built legacy frame must decode with `shell: None`. Locks the + /// compat contract documented on `kind::SPAWN_SHELL`. +⋮---- +/// compat contract documented on `kind::SPAWN_SHELL`. + #[test] +fn default_spawn_stays_wire_compatible_with_old_daemons() { +// New client -> old daemon: encode and pick the frame apart. +⋮---- +cwd: Some(PathBuf::from("/work")), +⋮---- +msg.encode(&mut buf).unwrap(); +let (k, payload) = read_frame(&mut std::io::Cursor::new(&buf)).unwrap(); +assert_eq!(k, kind::SPAWN, "default spawn must use the legacy kind"); +// An old daemon deserializes exactly a (cwd, size) tuple. +let (cwd, size): (Option, WinSize) = serde_json::from_slice(&payload).unwrap(); +assert_eq!(cwd, Some(PathBuf::from("/work"))); +assert_eq!(size, SIZE); +⋮---- +// Old client -> new daemon: a hand-built legacy frame decodes to +// `shell: None`. +let legacy = serde_json::to_vec(&(Some(PathBuf::from("/old")), SIZE)).unwrap(); +let decoded = ClientMsg::from_frame(kind::SPAWN, legacy).unwrap(); +assert_eq!( +⋮---- +/// An empty-payload binary frame (e.g. an `Input([])`) still round-trips and + /// an oversize length is rejected. +⋮---- +/// an oversize length is rejected. + #[test] +fn frame_edges() { +⋮---- +write_frame(&mut buf, 3, &[]).unwrap(); +⋮---- +assert_eq!(read_frame(&mut cursor).unwrap(), (3, vec![])); +⋮---- +// A hand-rolled frame claiming a huge length must be rejected. +⋮---- +bad.extend_from_slice(&(u32::MAX).to_le_bytes()); +bad.push(3); +⋮---- +assert!(read_frame(&mut cursor).is_err()); +⋮---- +/// `write_frame` refuses to emit a payload larger than `MAX_FRAME` rather than + /// putting a frame on the wire the peer would reject. +⋮---- +/// putting a frame on the wire the peer would reject. + #[test] +fn write_frame_rejects_oversize_payload() { +let oversize = vec![0u8; MAX_FRAME + 1]; +⋮---- +assert!(write_frame(&mut buf, 3, &oversize).is_err()); +// Nothing partial should have been emitted before the size check. +assert!(buf.is_empty()); +⋮---- +/// An unknown kind byte is a protocol desync, surfaced as an error (not a panic) + /// for both directions. +⋮---- +/// for both directions. + #[test] +fn from_frame_rejects_unknown_kind() { +assert!(ClientMsg::from_frame(99, vec![]).is_err()); +assert!(DaemonMsg::from_frame(99, vec![]).is_err()); +⋮---- +/// `take_frame` decodes exactly `write_frame`'s output, leaves partial + /// frames buffered (byte-at-a-time arrival included), preserves trailing +⋮---- +/// frames buffered (byte-at-a-time arrival included), preserves trailing + /// bytes of the next frame, and rejects an oversize length. +⋮---- +/// bytes of the next frame, and rejects an oversize length. + #[test] +fn take_frame_is_resumable_and_mirrors_read_frame() { +// Two frames, delivered one byte at a time: nothing decodes until each +// frame completes, and the buffer is never corrupted by partial reads. +⋮---- +write_frame(&mut wire, 3, b"hello").unwrap(); +write_frame(&mut wire, 9, &[]).unwrap(); +⋮---- +buf.push(b); +while let Some(frame) = take_frame(&mut buf).unwrap() { +got.push(frame); +⋮---- +assert_eq!(got, vec![(3, b"hello".to_vec()), (9, vec![])]); +assert!(buf.is_empty(), "nothing left over after both frames"); +⋮---- +// A complete frame followed by a partial one: the first pops, the +// partial tail stays intact for the next read. +⋮---- +write_frame(&mut buf, 3, b"done").unwrap(); +buf.extend_from_slice(&10u32.to_le_bytes()); // next frame's header only +assert_eq!(take_frame(&mut buf).unwrap(), Some((3, b"done".to_vec()))); +assert_eq!(take_frame(&mut buf).unwrap(), None); +assert_eq!(buf, 10u32.to_le_bytes()); +⋮---- +// An oversize length is a desync, same as read_frame. +let mut bad = (u32::MAX).to_le_bytes().to_vec(); +⋮---- +assert!(take_frame(&mut bad).is_err()); +⋮---- +/// A frame truncated mid-stream — after the length prefix, or mid-payload — + /// surfaces as an error (the reader treats it as a dropped peer), never a +⋮---- +/// surfaces as an error (the reader treats it as a dropped peer), never a + /// short/garbage frame. +⋮---- +/// short/garbage frame. + #[test] +fn read_frame_on_truncated_frame_is_an_error() { +// Length prefix only, no kind byte. +let mut cut = std::io::Cursor::new(5u32.to_le_bytes().to_vec()); +⋮---- +// Kind present but the payload is shorter than the length promised. +⋮---- +buf.extend_from_slice(&10u32.to_le_bytes()); +buf.push(3); +buf.extend_from_slice(b"only4"); +⋮---- +/// A control frame whose JSON payload is garbage decodes to an error rather + /// than panicking — a desynced peer can't crash the reader. +⋮---- +/// than panicking — a desynced peer can't crash the reader. + #[test] +fn from_frame_rejects_malformed_json_payloads() { +assert!(ClientMsg::from_frame(kind::SPAWN, b"not json".to_vec()).is_err()); +assert!(DaemonMsg::from_frame(kind::PANE_LIST, b"{oops".to_vec()).is_err()); +⋮---- +/// A clean close between frames (empty input) reads as `UnexpectedEof`, which + /// callers treat as a normal disconnect. +⋮---- +/// callers treat as a normal disconnect. + #[test] +fn read_frame_on_empty_input_is_eof() { +⋮---- +let err = read_frame(&mut empty).unwrap_err(); +assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof); +// The typed readers surface the same EOF. +⋮---- +assert!(ClientMsg::read(&mut empty2).is_err()); +⋮---- +/// `PaneInfo`'s `#[serde(default)]` fields tolerate an older/leaner JSON that + /// omits `cwd` and `title`. +⋮---- +/// omits `cwd` and `title`. + #[test] +fn pane_info_deserializes_with_defaults() { +let info: PaneInfo = serde_json::from_str(r#"{"pane_id": 5, "alive": true}"#).unwrap(); +assert_eq!(info.pane_id, 5); +assert!(info.alive); +assert_eq!(info.cwd, None); +assert_eq!(info.title, ""); +```` + +## File: src/daemon/shell_integration.rs +````rust +//! Shell integration: inject a small startup snippet into the shell tty7 spawns +//! so the shell *actively reports* its state — prompt boundaries, command +⋮---- +//! so the shell *actively reports* its state — prompt boundaries, command +//! start/finish, exit codes, and cwd — instead of us guessing from the outside. +⋮---- +//! start/finish, exit codes, and cwd — instead of us guessing from the outside. +//! +⋮---- +//! +//! This is the foundation the inline input editor builds on. The reporting +⋮---- +//! This is the foundation the inline input editor builds on. The reporting +//! protocol is the FinalTerm / iTerm2 **OSC 133** semantic-prompt standard, so it +⋮---- +//! protocol is the FinalTerm / iTerm2 **OSC 133** semantic-prompt standard, so it +//! interoperates with the wider ecosystem rather than a bespoke scheme: +⋮---- +//! interoperates with the wider ecosystem rather than a bespoke scheme: +//! - `OSC 133 ; A ST` prompt start +⋮---- +//! - `OSC 133 ; A ST` prompt start +//! - `OSC 133 ; B ST` prompt end / command input begins +⋮---- +//! - `OSC 133 ; B ST` prompt end / command input begins +//! - `OSC 133 ; C ST` command output begins (command executing) +⋮---- +//! - `OSC 133 ; C ST` command output begins (command executing) +//! - `OSC 133 ; D ; ST` command finished, with its exit code +⋮---- +//! - `OSC 133 ; D ; ST` command finished, with its exit code +//! plus `OSC 7` to report the cwd precisely (many login shells don't emit it +⋮---- +//! plus `OSC 7` to report the cwd precisely (many login shells don't emit it +//! unless they think they're in Terminal.app). +⋮---- +//! unless they think they're in Terminal.app). +//! +⋮---- +//! +//! Supports zsh, bash, fish and PowerShell; each needs a different injection +⋮---- +//! Supports zsh, bash, fish and PowerShell; each needs a different injection +//! mechanism because the shells disagree on how much control they hand an +⋮---- +//! mechanism because the shells disagree on how much control they hand an +//! integrator: +⋮---- +//! integrator: +//! - **zsh** has `ZDOTDIR`, an env var that retargets *all* of its startup +⋮---- +//! - **zsh** has `ZDOTDIR`, an env var that retargets *all* of its startup +//! files at once — the cleanest hook of the four. See [`zsh_redirectors`]. +⋮---- +//! files at once — the cleanest hook of the four. See [`zsh_redirectors`]. +//! - **fish** has no such redirect, but its `-C`/`--init-command` flag runs +⋮---- +//! - **fish** has no such redirect, but its `-C`/`--init-command` flag runs +//! extra commands after fish's own (unmodified) config load — no throwaway +⋮---- +//! extra commands after fish's own (unmodified) config load — no throwaway +//! directory needed at all. See [`setup_fish`]. +⋮---- +//! directory needed at all. See [`setup_fish`]. +//! - **bash** has neither: no env var retargets its rc file, and `--rcfile` +⋮---- +//! - **bash** has neither: no env var retargets its rc file, and `--rcfile` +//! (the only override it does have) is silently ignored for *login* +⋮---- +//! (the only override it does have) is silently ignored for *login* +//! shells, which is how terminals normally spawn it. So we spawn bash as a +⋮---- +//! shells, which is how terminals normally spawn it. So we spawn bash as a +//! plain non-login shell instead and have our rcfile manually replay the +⋮---- +//! plain non-login shell instead and have our rcfile manually replay the +//! login-shell startup-file chain (`/etc/profile`, `~/.bash_profile` & +⋮---- +//! login-shell startup-file chain (`/etc/profile`, `~/.bash_profile` & +//! co.) before layering hooks on top — see [`setup_bash`] and +⋮---- +//! co.) before layering hooks on top — see [`setup_bash`] and +//! [`Injection::force_non_login`]. Bash also has no native precmd/preexec, +⋮---- +//! [`Injection::force_non_login`]. Bash also has no native precmd/preexec, +//! so the hook body vendors the relevant parts of +⋮---- +//! so the hook body vendors the relevant parts of +//! [bash-preexec](https://github.com/rcaloras/bash-preexec) (MIT), the +⋮---- +//! [bash-preexec](https://github.com/rcaloras/bash-preexec) (MIT), the +//! same shim VS Code relies on for this. +⋮---- +//! same shim VS Code relies on for this. +//! - **PowerShell** (the Windows default, and any `pwsh`) has no dotfile +⋮---- +//! - **PowerShell** (the Windows default, and any `pwsh`) has no dotfile +//! redirect either, but `-EncodedCommand` runs a script *after* its own +⋮---- +//! redirect either, but `-EncodedCommand` runs a script *after* its own +//! profiles load — like fish's `-C`, no file on disk. It has no +⋮---- +//! profiles load — like fish's `-C`, no file on disk. It has no +//! precmd/preexec, so — following Warp and VS Code — the body wraps two +⋮---- +//! precmd/preexec, so — following Warp and VS Code — the body wraps two +//! host hooks: the `prompt` function (for the A/B/D marks + cwd) and +⋮---- +//! host hooks: the `prompt` function (for the A/B/D marks + cwd) and +//! `PSConsoleHostReadLine`, PSReadLine's line reader (the closest thing to +⋮---- +//! `PSConsoleHostReadLine`, PSReadLine's line reader (the closest thing to +//! a preexec, for the C mark). See [`setup_powershell`]. +⋮---- +//! a preexec, for the C mark). See [`setup_powershell`]. +//! +⋮---- +//! +//! Across all four: **the user's own dotfiles are never modified** — the +⋮---- +//! Across all four: **the user's own dotfiles are never modified** — the +//! mechanisms above only affect shells tty7 itself launches. +⋮---- +//! mechanisms above only affect shells tty7 itself launches. +use std::collections::HashMap; +⋮---- +/// The zsh integration body, sourced from our injected `.zshrc` after the user's +/// own `.zshrc` has run. Guarded so it installs exactly once per interactive +⋮---- +/// own `.zshrc` has run. Guarded so it installs exactly once per interactive +/// shell. See the module docs for the OSC 133 semantics. +⋮---- +/// shell. See the module docs for the OSC 133 semantics. +const ZSH_INTEGRATION: &str = r#" +⋮---- +/// The fish integration body, passed verbatim as a `-C`/`--init-command` +/// argument (see [`setup_fish`]) — fish has already loaded the user's *real* +⋮---- +/// argument (see [`setup_fish`]) — fish has already loaded the user's *real* +/// `config.fish` by the time this runs, so unlike zsh/bash there's nothing here +⋮---- +/// `config.fish` by the time this runs, so unlike zsh/bash there's nothing here +/// to source manually. +⋮---- +/// to source manually. +/// +⋮---- +/// +/// fish has no event that fires *after* the prompt is drawn, so the B marker +⋮---- +/// fish has no event that fires *after* the prompt is drawn, so the B marker +/// (prompt end / input begins) can't be emitted from an `--on-event` handler +⋮---- +/// (prompt end / input begins) can't be emitted from an `--on-event` handler +/// the way A/C/D are — it has to be spliced into `fish_prompt` itself. We +⋮---- +/// the way A/C/D are — it has to be spliced into `fish_prompt` itself. We +/// capture whatever `fish_prompt` already is (the user's own, or a prompt +⋮---- +/// capture whatever `fish_prompt` already is (the user's own, or a prompt +/// framework's) and wrap it: call the original, then emit B right after. +⋮---- +/// framework's) and wrap it: call the original, then emit B right after. +const FISH_INTEGRATION: &str = r#" +⋮---- +/// The bash integration body, appended after the replayed login-file chain +/// (see [`setup_bash`]). Bash has no native precmd/preexec, so this vendors the +⋮---- +/// (see [`setup_bash`]). Bash has no native precmd/preexec, so this vendors the +/// core mechanism from [bash-preexec](https://github.com/rcaloras/bash-preexec) +⋮---- +/// core mechanism from [bash-preexec](https://github.com/rcaloras/bash-preexec) +/// (MIT) — the same shim VS Code uses — trimmed of everything but the +⋮---- +/// (MIT) — the same shim VS Code uses — trimmed of everything but the +/// precmd/preexec plumbing: a `DEBUG` trap infers "a command is genuinely about +⋮---- +/// precmd/preexec plumbing: a `DEBUG` trap infers "a command is genuinely about +/// to run interactively" (as opposed to firing mid-completion, mid readline +⋮---- +/// to run interactively" (as opposed to firing mid-completion, mid readline +/// binding, or for a piece of `PROMPT_COMMAND` itself), and `PROMPT_COMMAND` +⋮---- +/// binding, or for a piece of `PROMPT_COMMAND` itself), and `PROMPT_COMMAND` +/// runs registered precmd functions before each prompt. +⋮---- +/// runs registered precmd functions before each prompt. +/// +⋮---- +/// +/// If the user's own `.bashrc` already loaded bash-preexec (several prompt +⋮---- +/// If the user's own `.bashrc` already loaded bash-preexec (several prompt +/// frameworks bundle it) we don't install it a second time — re-running the +⋮---- +/// frameworks bundle it) we don't install it a second time — re-running the +/// install sequence would clear and never restore the already-installed +⋮---- +/// install sequence would clear and never restore the already-installed +/// `DEBUG` trap. We detect that via bash-preexec's own `bash_preexec_imported` +⋮---- +/// `DEBUG` trap. We detect that via bash-preexec's own `bash_preexec_imported` +/// sentinel and, either way, register our hooks through its public extension +⋮---- +/// sentinel and, either way, register our hooks through its public extension +/// points (`precmd_functions` / `preexec_functions`) rather than the "function +⋮---- +/// points (`precmd_functions` / `preexec_functions`) rather than the "function +/// literally named `precmd`/`preexec`" convenience, which could collide with +⋮---- +/// literally named `precmd`/`preexec`" convenience, which could collide with +/// the user's own. +⋮---- +/// the user's own. +const BASH_INTEGRATION: &str = r#" +⋮---- +/// The PowerShell integration body, base64-encoded (see +/// [`powershell_encoded_command`]) and passed as `-EncodedCommand`, which +⋮---- +/// [`powershell_encoded_command`]) and passed as `-EncodedCommand`, which +/// PowerShell runs *after* loading the user's profiles — so, like fish's `-C`, +⋮---- +/// PowerShell runs *after* loading the user's profiles — so, like fish's `-C`, +/// it layers hooks on top of the user's own prompt without a file on disk and +⋮---- +/// it layers hooks on top of the user's own prompt without a file on disk and +/// without touching their config. +⋮---- +/// without touching their config. +/// +⋮---- +/// +/// PowerShell has no precmd/preexec, so — mirroring Warp and VS Code — we wrap +⋮---- +/// PowerShell has no precmd/preexec, so — mirroring Warp and VS Code — we wrap +/// two host hooks: +⋮---- +/// two host hooks: +/// - **`prompt`** runs before each prompt is drawn. It emits `133;D` (the +⋮---- +/// - **`prompt`** runs before each prompt is drawn. It emits `133;D` (the +/// last command's exit code) and the `OSC 7` cwd as side effects, then +⋮---- +/// last command's exit code) and the `OSC 7` cwd as side effects, then +/// returns the user's own prompt wrapped in `133;A` … `133;B`. The byte +⋮---- +/// returns the user's own prompt wrapped in `133;A` … `133;B`. The byte +/// order is therefore `[D][cwd][A]prompt[B]`, exactly what the daemon's +⋮---- +/// order is therefore `[D][cwd][A]prompt[B]`, exactly what the daemon's +/// sniffer keys `at_prompt` off (see `daemon::pane::handle_osc133`). +⋮---- +/// sniffer keys `at_prompt` off (see `daemon::pane::handle_osc133`). +/// - **`PSConsoleHostReadLine`** is PSReadLine's line reader — the closest +⋮---- +/// - **`PSConsoleHostReadLine`** is PSReadLine's line reader — the closest +/// thing PowerShell has to a preexec. After it returns the submitted line, +⋮---- +/// thing PowerShell has to a preexec. After it returns the submitted line, +/// before the command runs, we emit `133;C` (command output begins). +⋮---- +/// before the command runs, we emit `133;C` (command output begins). +/// +⋮---- +/// +/// `$?` must be captured as the very first statement of `prompt` (an +⋮---- +/// `$?` must be captured as the very first statement of `prompt` (an +/// assignment sets `$?` to true, clobbering it), and is restored before the +⋮---- +/// assignment sets `$?` to true, clobbering it), and is restored before the +/// user's own prompt runs so a status-aware prompt still sees the real result. +⋮---- +/// user's own prompt runs so a status-aware prompt still sees the real result. +const POWERSHELL_INTEGRATION: &str = r#" +⋮---- +/// The redirector files written into our throwaway `ZDOTDIR`. zsh reads its +/// startup files from `$ZDOTDIR`, so for zsh to reach all four of ours we must +⋮---- +/// startup files from `$ZDOTDIR`, so for zsh to reach all four of ours we must +/// keep `ZDOTDIR` pointing at *our* dir at every hand-off between files. But +⋮---- +/// keep `ZDOTDIR` pointing at *our* dir at every hand-off between files. But +/// while each redirector actually *sources the user's real file* — and once the +⋮---- +/// while each redirector actually *sources the user's real file* — and once the +/// live session begins — `ZDOTDIR` has to point at the user's real config dir +⋮---- +/// live session begins — `ZDOTDIR` has to point at the user's real config dir +/// instead: a whole ecosystem of zsh tooling (Zim, oh-my-zsh, `compinit`'s +⋮---- +/// instead: a whole ecosystem of zsh tooling (Zim, oh-my-zsh, `compinit`'s +/// `.zcompdump`) locates its own state via `${ZDOTDIR:-$HOME}`, and if that +⋮---- +/// `.zcompdump`) locates its own state via `${ZDOTDIR:-$HOME}`, and if that +/// resolved to our *empty* throwaway dir it would reinstall / rebuild from +⋮---- +/// resolved to our *empty* throwaway dir it would reinstall / rebuild from +/// scratch on every new pane — the 3-second stall and Zim "Installed" spam of +⋮---- +/// scratch on every new pane — the 3-second stall and Zim "Installed" spam of +/// issue #15. So each redirector swaps `ZDOTDIR` to the real dir around the +⋮---- +/// issue #15. So each redirector swaps `ZDOTDIR` to the real dir around the +/// `source`, then swaps our dir back so zsh still reaches the next redirector; +⋮---- +/// `source`, then swaps our dir back so zsh still reaches the next redirector; +/// the integration body ([`ZSH_INTEGRATION`]) restores the real dir for good +⋮---- +/// the integration body ([`ZSH_INTEGRATION`]) restores the real dir for good +/// once every startup file has run. +⋮---- +/// once every startup file has run. +/// +⋮---- +/// +/// The source is done at top level (never wrapped in a function) so the user's +⋮---- +/// The source is done at top level (never wrapped in a function) so the user's +/// config keeps its normal global scope. +⋮---- +/// config keeps its normal global scope. +fn zsh_redirectors() -> [(&'static str, String); 4] { +⋮---- +fn zsh_redirectors() -> [(&'static str, String); 4] { +// Run the user's file of the same name with ZDOTDIR aimed at their *real* +// config dir, then restore ours so zsh reads the next redirector. The real +// dir is `TTY7_USER_ZDOTDIR`, captured into the env before launch; when it's +// absent we *unset* ZDOTDIR (not fall back to $HOME) so the file sees exactly +// what a real launch gives it — an unset ZDOTDIR — and the classic relocate +// idiom `: ${ZDOTDIR:=~/.config/zsh}` still fires. `tail` runs after the +// source but before the restore. +⋮---- +format!( +⋮---- +// The user's own .zshenv may itself relocate ZDOTDIR — the classic tiny +// `~/.zshenv` that does `ZDOTDIR=~/.config/zsh`. Capture wherever it points +// *after* sourcing as the real dir for the later redirectors (and nested +// tty7); otherwise they'd look under $HOME and miss the user's real config. +⋮---- +redirect(".zshenv", "export TTY7_USER_ZDOTDIR=${ZDOTDIR:-$HOME}\n"), +⋮---- +(".zprofile", redirect(".zprofile", "")), +// Our integration is appended *after* the user's .zshrc (and after ZDOTDIR +// is restored to ours) so it extends — not gets clobbered by — the user's +// PROMPT / hooks. +⋮---- +format!("{}{ZSH_INTEGRATION}", redirect(".zshrc", "")), +⋮---- +(".zlogin", redirect(".zlogin", "")), +⋮---- +/// Environment overrides + spawn adjustments produced by `setup`. +pub struct Injection { +⋮---- +pub struct Injection { +/// Env vars to add to the child shell's environment. + pub env: HashMap, +/// Extra argv entries to append after the program (e.g. bash's + /// `--rcfile `, fish's `-C