Commit Diff


commit - 352c5af8004bc103e79dc90de7d207e880861514
commit + 442591b5aa83d4c5778b0c087f9c11fe605554a8
blob - e59e798225ff9b94fd56cb657a3256b2cb2b58cc
blob + 940557c038a596a0702cb4a624c7c1c02657c3b9
--- Cargo.lock
+++ Cargo.lock
@@ -116,15 +116,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed
 
 [[package]]
 name = "bytes"
-version = "1.11.1"
+version = "1.12.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
 
 [[package]]
 name = "cc"
-version = "1.2.64"
+version = "1.2.65"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
+checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
 dependencies = [
  "find-msvc-tools",
  "jobserver",
@@ -822,9 +822,9 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3
 
 [[package]]
 name = "log"
-version = "0.4.32"
+version = "0.4.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
 
 [[package]]
 name = "lru-slab"
blob - 211f8da3bcfa817a44efe176c928ad170b5400aa
blob + d70776f6759ce3048a0e2a12f8be09a4b26d7ad9
--- MODULE.bazel
+++ MODULE.bazel
@@ -8,6 +8,7 @@ bazel_dep(name = "rules_cc", version = "0.2.19")
 bazel_dep(name = "rules_zig", version = "0.16.0")
 bazel_dep(name = "rules_rust", version = "0.70.0")
 bazel_dep(name = "rules_python", version = "2.0.3")
+bazel_dep(name = "rules_go", version = "0.61.1")
 
 # https://github.com/bazelbuild/rules_rust
 rust = use_extension("@rules_rust//rust:extensions.bzl", "rust")
@@ -53,3 +54,7 @@ use_repo(pip, "pypi")
 # https://github.com/aherrmann/rules_zig
 zig = use_extension("@rules_zig//zig:extensions.bzl", "zig")
 zig.toolchain(zig_version = "0.16.0")
+
+# https://github.com/bazel-contrib/rules_go
+go_sdk = use_extension("@rules_go//go:extensions.bzl", "go_sdk")
+go_sdk.download(version = "1.26.3")
blob - b17088c85acfd64e01e72447eb2ad05e3db14158
blob + 2435e9ae03ed9d4c20e3fe65eaaf89d1d2a5abb7
--- MODULE.bazel.lock
+++ MODULE.bazel.lock
@@ -27,6 +27,7 @@
     "https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.5/MODULE.bazel": "004ba890363d05372a97248c37205ae64b6fa31047629cd2c0895a9d0c7779e8",
     "https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.5/source.json": "ac2c3213df8f985785f1d0aeb7f0f73d5324e6e67d593d9b9470fb74a25d4a9b",
     "https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838",
+    "https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b",
     "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd",
     "https://bcr.bazel.build/modules/bazel_features/1.10.0/MODULE.bazel": "f75e8807570484a99be90abcd52b5e1f390362c258bcb73106f4544957a48101",
     "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8",
@@ -42,6 +43,7 @@
     "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87",
     "https://bcr.bazel.build/modules/bazel_features/1.32.0/MODULE.bazel": "095d67022a58cb20f7e20e1aefecfa65257a222c18a938e2914fd257b5f1ccdc",
     "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6",
+    "https://bcr.bazel.build/modules/bazel_features/1.36.0/MODULE.bazel": "596cb62090b039caf1cad1d52a8bc35cf188ca9a4e279a828005e7ee49a1bec3",
     "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
     "https://bcr.bazel.build/modules/bazel_features/1.42.1/MODULE.bazel": "275a59b5406ff18c01739860aa70ad7ccb3cfb474579411decca11c93b951080",
     "https://bcr.bazel.build/modules/bazel_features/1.43.0/MODULE.bazel": "defa2226f06ba20550d6548c3a2ea2a7929634437a52973869c20c225450eb91",
@@ -71,6 +73,12 @@
     "https://bcr.bazel.build/modules/buildozer/8.5.1/source.json": "e3386e6ff4529f2442800dee47ad28d3e6487f36a1f75ae39ae56c70f0cd2fbd",
     "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8",
     "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2",
+    "https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8",
+    "https://bcr.bazel.build/modules/gazelle/0.33.0/MODULE.bazel": "a13a0f279b462b784fb8dd52a4074526c4a2afe70e114c7d09066097a46b3350",
+    "https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a",
+    "https://bcr.bazel.build/modules/gazelle/0.36.0/MODULE.bazel": "e375d5d6e9a6ca59b0cb38b0540bc9a05b6aa926d322f2de268ad267a2ee74c0",
+    "https://bcr.bazel.build/modules/gazelle/0.51.3/MODULE.bazel": "618a729142f66de1e2cb776d026413763be5b80d5e3d29ffb9d3d90c5defde90",
+    "https://bcr.bazel.build/modules/gazelle/0.51.3/source.json": "fbe5312a01fb4a2a58caff4a0d40dcf384b6f0bda75e85546663360da1d7538a",
     "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb",
     "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4",
     "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6",
@@ -88,7 +96,8 @@
     "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9",
     "https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92",
     "https://bcr.bazel.build/modules/package_metadata/0.0.3/MODULE.bazel": "77890552ecea9e284b5424c9de827a58099348763a4359e975c359a83d4faa83",
-    "https://bcr.bazel.build/modules/package_metadata/0.0.3/source.json": "742075a428ad12a3fa18a69014c2f57f01af910c6d9d18646c990200853e641a",
+    "https://bcr.bazel.build/modules/package_metadata/0.0.5/MODULE.bazel": "ef4f9439e3270fdd6b9fd4dbc3d2f29d13888e44c529a1b243f7a31dfbc2e8e4",
+    "https://bcr.bazel.build/modules/package_metadata/0.0.5/source.json": "2326db2f6592578177751c3e1f74786b79382cd6008834c9d01ec865b9126a85",
     "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
     "https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f",
     "https://bcr.bazel.build/modules/platforms/0.0.4/MODULE.bazel": "9b328e31ee156f53f3c416a64f8491f7eb731742655a47c9eec4703a71644aee",
@@ -102,10 +111,15 @@
     "https://bcr.bazel.build/modules/platforms/1.1.0/source.json": "fcf351c47596c939140ab0d333dfdd08ed1ea6ce33c2fe70c12493a301cf1344",
     "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7",
     "https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c",
+    "https://bcr.bazel.build/modules/protobuf/27.1/MODULE.bazel": "703a7b614728bb06647f965264967a8ef1c39e09e8f167b3ca0bb1fd80449c0d",
+    "https://bcr.bazel.build/modules/protobuf/29.0-rc2.bcr.1/MODULE.bazel": "52f4126f63a2f0bbf36b99c2a87648f08467a4eaf92ba726bc7d6a500bbf770c",
     "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df",
     "https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92",
+    "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e",
     "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95",
     "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0",
+    "https://bcr.bazel.build/modules/protobuf/3.19.2/MODULE.bazel": "532ffe5f2186b69fdde039efe6df13ba726ff338c6bc82275ad433013fa10573",
+    "https://bcr.bazel.build/modules/protobuf/3.19.6/MODULE.bazel": "9233edc5e1f2ee276a60de3eaa47ac4132302ef9643238f23128fea53ea12858",
     "https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d",
     "https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42",
     "https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79",
@@ -145,6 +159,12 @@
     "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642",
     "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6",
     "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8",
+    "https://bcr.bazel.build/modules/rules_go/0.41.0/MODULE.bazel": "55861d8e8bb0e62cbd2896f60ff303f62ffcb0eddb74ecb0e5c0cbe36fc292c8",
+    "https://bcr.bazel.build/modules/rules_go/0.42.0/MODULE.bazel": "8cfa875b9aa8c6fce2b2e5925e73c1388173ea3c32a0db4d2b4804b453c14270",
+    "https://bcr.bazel.build/modules/rules_go/0.46.0/MODULE.bazel": "3477df8bdcc49e698b9d25f734c4f3a9f5931ff34ee48a2c662be168f5f2d3fd",
+    "https://bcr.bazel.build/modules/rules_go/0.59.0/MODULE.bazel": "b7e43e7414a3139a7547d1b4909b29085fbe5182b6c58cbe1ed4c6272815aeae",
+    "https://bcr.bazel.build/modules/rules_go/0.61.1/MODULE.bazel": "b599cc67f98e8dbe040631129fbd23f190a557f7be506d4781619d0fd4dbbbec",
+    "https://bcr.bazel.build/modules/rules_go/0.61.1/source.json": "ff52d46fca8e2ac87c610c11f05c3a9f0fa08dc4294664c6a31449092409c5aa",
     "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74",
     "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86",
     "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39",
@@ -183,6 +203,7 @@
     "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7",
     "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483",
     "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73",
+    "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2",
     "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96",
     "https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e",
     "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f",
@@ -230,6 +251,7 @@
     "https://bcr.bazel.build/modules/yq.bzl/0.1.1/MODULE.bazel": "9039681f9bcb8958ee2c87ffc74bdafba9f4369096a2b5634b88abc0eaefa072",
     "https://bcr.bazel.build/modules/yq.bzl/0.1.1/source.json": "2d2bad780a9f2b9195a4a370314d2c17ae95eaa745cefc2e12fbc49759b15aa3",
     "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0",
+    "https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27",
     "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca",
     "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806",
     "https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198"
@@ -318,7 +340,7 @@
     "@@rules_python+//python/uv:uv.bzl%uv": {
       "general": {
         "bzlTransitiveDigest": "I8FPZMevE2oI/peSpMBRVIN++WOtfjtJVjbPsBZQ87A=",
-        "usagesDigest": "R0pij/Kri0Z/7GcM2UBot6XmOqgduje0j8y9wNMWjLA=",
+        "usagesDigest": "XGwR3eTI95rhVlQBWeWHCf6MeLufEzhWrWRWAtF6Zv4=",
         "recordedInputs": [
           "REPO_MAPPING:rules_python+,bazel_tools bazel_tools",
           "REPO_MAPPING:rules_python+,platforms platforms"
@@ -3611,6 +3633,328 @@
     }
   },
   "facts": {
+    "@@rules_go+//go:extensions.bzl%go_sdk": {
+      "1.25.0": {
+        "aix_ppc64": [
+          "go1.25.0.aix-ppc64.tar.gz",
+          "e5234a7dac67bc86c528fe9752fc9d63557918627707a733ab4cac1a6faed2d4"
+        ],
+        "darwin_amd64": [
+          "go1.25.0.darwin-amd64.tar.gz",
+          "5bd60e823037062c2307c71e8111809865116714d6f6b410597cf5075dfd80ef"
+        ],
+        "darwin_arm64": [
+          "go1.25.0.darwin-arm64.tar.gz",
+          "544932844156d8172f7a28f77f2ac9c15a23046698b6243f633b0a0b00c0749c"
+        ],
+        "dragonfly_amd64": [
+          "go1.25.0.dragonfly-amd64.tar.gz",
+          "5ed3cf9a810a1483822538674f1336c06b51aa1b94d6d545a1a0319a48177120"
+        ],
+        "freebsd_386": [
+          "go1.25.0.freebsd-386.tar.gz",
+          "abea5d5c6697e6b5c224731f2158fe87c602996a2a233ac0c4730cd57bf8374e"
+        ],
+        "freebsd_amd64": [
+          "go1.25.0.freebsd-amd64.tar.gz",
+          "86e6fe0a29698d7601c4442052dac48bd58d532c51cccb8f1917df648138730b"
+        ],
+        "freebsd_arm": [
+          "go1.25.0.freebsd-arm.tar.gz",
+          "d90b78e41921f72f30e8bbc81d9dec2cff7ff384a33d8d8debb24053e4336bfe"
+        ],
+        "freebsd_arm64": [
+          "go1.25.0.freebsd-arm64.tar.gz",
+          "451d0da1affd886bfb291b7c63a6018527b269505db21ce6e14724f22ab0662e"
+        ],
+        "freebsd_riscv64": [
+          "go1.25.0.freebsd-riscv64.tar.gz",
+          "7b565f76bd8bda46549eeaaefe0e53b251e644c230577290c0f66b1ecdb3cdbe"
+        ],
+        "illumos_amd64": [
+          "go1.25.0.illumos-amd64.tar.gz",
+          "b1e1fdaab1ad25aa1c08d7a36c97d45d74b98b89c3f78c6d2145f77face54a2c"
+        ],
+        "linux_386": [
+          "go1.25.0.linux-386.tar.gz",
+          "8c602dd9d99bc9453b3995d20ce4baf382cc50855900a0ece5de9929df4a993a"
+        ],
+        "linux_amd64": [
+          "go1.25.0.linux-amd64.tar.gz",
+          "2852af0cb20a13139b3448992e69b868e50ed0f8a1e5940ee1de9e19a123b613"
+        ],
+        "linux_arm64": [
+          "go1.25.0.linux-arm64.tar.gz",
+          "05de75d6994a2783699815ee553bd5a9327d8b79991de36e38b66862782f54ae"
+        ],
+        "linux_armv6l": [
+          "go1.25.0.linux-armv6l.tar.gz",
+          "a5a8f8198fcf00e1e485b8ecef9ee020778bf32a408a4e8873371bfce458cd09"
+        ],
+        "linux_loong64": [
+          "go1.25.0.linux-loong64.tar.gz",
+          "cab86b1cf761b1cb3bac86a8877cfc92e7b036fc0d3084123d77013d61432afc"
+        ],
+        "linux_mips": [
+          "go1.25.0.linux-mips.tar.gz",
+          "d66b6fb74c3d91b9829dc95ec10ca1f047ef5e89332152f92e136cf0e2da5be1"
+        ],
+        "linux_mips64": [
+          "go1.25.0.linux-mips64.tar.gz",
+          "4082e4381a8661bc2a839ff94ba3daf4f6cde20f8fb771b5b3d4762dc84198a2"
+        ],
+        "linux_mips64le": [
+          "go1.25.0.linux-mips64le.tar.gz",
+          "70002c299ec7f7175ac2ef673b1b347eecfa54ae11f34416a6053c17f855afcc"
+        ],
+        "linux_mipsle": [
+          "go1.25.0.linux-mipsle.tar.gz",
+          "b00a3a39eff099f6df9f1c7355bf28e4589d0586f42d7d4a394efb763d145a73"
+        ],
+        "linux_ppc64": [
+          "go1.25.0.linux-ppc64.tar.gz",
+          "df166f33bd98160662560a72ff0b4ba731f969a80f088922bddcf566a88c1ec1"
+        ],
+        "linux_ppc64le": [
+          "go1.25.0.linux-ppc64le.tar.gz",
+          "0f18a89e7576cf2c5fa0b487a1635d9bcbf843df5f110e9982c64df52a983ad0"
+        ],
+        "linux_riscv64": [
+          "go1.25.0.linux-riscv64.tar.gz",
+          "c018ff74a2c48d55c8ca9b07c8e24163558ffec8bea08b326d6336905d956b67"
+        ],
+        "linux_s390x": [
+          "go1.25.0.linux-s390x.tar.gz",
+          "34e5a2e19f2292fbaf8783e3a241e6e49689276aef6510a8060ea5ef54eee408"
+        ],
+        "netbsd_386": [
+          "go1.25.0.netbsd-386.tar.gz",
+          "f8586cdb7aa855657609a5c5f6dbf523efa00c2bbd7c76d3936bec80aa6c0aba"
+        ],
+        "netbsd_amd64": [
+          "go1.25.0.netbsd-amd64.tar.gz",
+          "ae8dc1469385b86a157a423bb56304ba45730de8a897615874f57dd096db2c2a"
+        ],
+        "netbsd_arm": [
+          "go1.25.0.netbsd-arm.tar.gz",
+          "1ff7e4cc764425fc9dd6825eaee79d02b3c7cafffbb3691687c8d672ade76cb7"
+        ],
+        "netbsd_arm64": [
+          "go1.25.0.netbsd-arm64.tar.gz",
+          "e1b310739f26724216aa6d7d7208c4031f9ff54c9b5b9a796ddc8bebcb4a5f16"
+        ],
+        "openbsd_386": [
+          "go1.25.0.openbsd-386.tar.gz",
+          "4802a9b20e533da91adb84aab42e94aa56cfe3e5475d0550bed3385b182e69d8"
+        ],
+        "openbsd_amd64": [
+          "go1.25.0.openbsd-amd64.tar.gz",
+          "c016cd984bebe317b19a4f297c4f50def120dc9788490540c89f28e42f1dabe1"
+        ],
+        "openbsd_arm": [
+          "go1.25.0.openbsd-arm.tar.gz",
+          "a1e31d0bf22172ddde42edf5ec811ef81be43433df0948ece52fecb247ccfd8d"
+        ],
+        "openbsd_arm64": [
+          "go1.25.0.openbsd-arm64.tar.gz",
+          "343ea8edd8c218196e15a859c6072d0dd3246fbbb168481ab665eb4c4140458d"
+        ],
+        "openbsd_ppc64": [
+          "go1.25.0.openbsd-ppc64.tar.gz",
+          "694c14da1bcaeb5e3332d49bdc2b6d155067648f8fe1540c5de8f3cf8e157154"
+        ],
+        "openbsd_riscv64": [
+          "go1.25.0.openbsd-riscv64.tar.gz",
+          "aa510ad25cf54c06cd9c70b6d80ded69cb20188ac6e1735655eef29ff7e7885f"
+        ],
+        "plan9_386": [
+          "go1.25.0.plan9-386.tar.gz",
+          "46f8cef02086cf04bf186c5912776b56535178d4cb319cd19c9fdbdd29231986"
+        ],
+        "plan9_amd64": [
+          "go1.25.0.plan9-amd64.tar.gz",
+          "29b34391d84095e44608a228f63f2f88113a37b74a79781353ec043dfbcb427b"
+        ],
+        "plan9_arm": [
+          "go1.25.0.plan9-arm.tar.gz",
+          "0a047107d13ebe7943aaa6d54b1d7bbd2e45e68ce449b52915a818da715799c2"
+        ],
+        "solaris_amd64": [
+          "go1.25.0.solaris-amd64.tar.gz",
+          "9977f9e4351984364a3b2b78f8b88bfd1d339812356d5237678514594b7d3611"
+        ],
+        "windows_386": [
+          "go1.25.0.windows-386.zip",
+          "df9f39db82a803af0db639e3613a36681ab7a42866b1384b3f3a1045663961a7"
+        ],
+        "windows_amd64": [
+          "go1.25.0.windows-amd64.zip",
+          "89efb4f9b30812eee083cc1770fdd2913c14d301064f6454851428f9707d190b"
+        ],
+        "windows_arm64": [
+          "go1.25.0.windows-arm64.zip",
+          "27bab004c72b3d7bd05a69b6ec0fc54a309b4b78cc569dd963d8b3ec28bfdb8c"
+        ]
+      },
+      "1.26.3": {
+        "aix_ppc64": [
+          "go1.26.3.aix-ppc64.tar.gz",
+          "9fd8b45c4aa58fa6adf7f347343a3b50c02b17a4b5c43381dd9bf87be563183d"
+        ],
+        "darwin_amd64": [
+          "go1.26.3.darwin-amd64.tar.gz",
+          "278d580b32e299fe4a9c990fcf2d02acfe538c7e551a6ee18f9c7164573d2c63"
+        ],
+        "darwin_arm64": [
+          "go1.26.3.darwin-arm64.tar.gz",
+          "875cf54a15311eee2c99b9dd67c68c4a49351d489ab622bf2cfd28c8f2078d3c"
+        ],
+        "dragonfly_amd64": [
+          "go1.26.3.dragonfly-amd64.tar.gz",
+          "6bfeaae407b12affd477cd48674e51d34931b6d98afc59de0f50ef93523ea4bf"
+        ],
+        "freebsd_386": [
+          "go1.26.3.freebsd-386.tar.gz",
+          "270df83863a4fbeb716565e91915f54af4ed911ec503651fbce6c14f9e00018c"
+        ],
+        "freebsd_amd64": [
+          "go1.26.3.freebsd-amd64.tar.gz",
+          "2a5a3b0265f24cdf3878bbab19bb1086f71ae5d29566f238214847d1e3745b4e"
+        ],
+        "freebsd_arm": [
+          "go1.26.3.freebsd-arm.tar.gz",
+          "db3700f0173ef7d15b96b4a2fa34c7fce90455e3125491183d751c9270b63d96"
+        ],
+        "freebsd_arm64": [
+          "go1.26.3.freebsd-arm64.tar.gz",
+          "07431d472522d3e3b9fce3f5d1ea825e2fbb14d7b0b7fbfa548726654217127c"
+        ],
+        "illumos_amd64": [
+          "go1.26.3.illumos-amd64.tar.gz",
+          "2c5bc6a2c7c43e09a91b19117fa18ce9012393a9f42fc0d153cad345fd328dad"
+        ],
+        "linux_386": [
+          "go1.26.3.linux-386.tar.gz",
+          "0ef3626a149b5811c813838c62b7d6618d03ea36047b32c90b0e4851cc42b1fa"
+        ],
+        "linux_amd64": [
+          "go1.26.3.linux-amd64.tar.gz",
+          "2b2cfc7148493da5e73981bffbf3353af381d5f93e789c82c79aff64962eb556"
+        ],
+        "linux_arm64": [
+          "go1.26.3.linux-arm64.tar.gz",
+          "9d89a3ea57d141c2b22d70083f2c8459ba3890f2d9e818e7e933b75614936565"
+        ],
+        "linux_armv6l": [
+          "go1.26.3.linux-armv6l.tar.gz",
+          "d44133d4c66b1451a1e247da26db7716f76a081c0169a75e6c84e1871e394320"
+        ],
+        "linux_loong64": [
+          "go1.26.3.linux-loong64.tar.gz",
+          "05215802b85a33dcfdb933a6c3ab881f4f0405587ee6581d33f34cc5c2ab740c"
+        ],
+        "linux_mips": [
+          "go1.26.3.linux-mips.tar.gz",
+          "76800ce7007d5eacabfe25d038e0a99e73a0fb70c4dd13f8a9662045fb4a52a7"
+        ],
+        "linux_mips64": [
+          "go1.26.3.linux-mips64.tar.gz",
+          "f2c755d17c6834dd3ae805d815a1b9e2eda66375de6ca910efb903a257ff3a3d"
+        ],
+        "linux_mips64le": [
+          "go1.26.3.linux-mips64le.tar.gz",
+          "54f7b00bf2d5cf4b21734cc8eb3c61c51a76177d3d20cd667e4b1ba8fb3343d5"
+        ],
+        "linux_mipsle": [
+          "go1.26.3.linux-mipsle.tar.gz",
+          "31f7d8c886136b725d36880f6ee16fe22a7243751839a2de2ea2da3cb4fd3fe1"
+        ],
+        "linux_ppc64": [
+          "go1.26.3.linux-ppc64.tar.gz",
+          "459746b1b06eb24836d1e4699c6568220c95e82de9b1b155c74eb4fdfd711532"
+        ],
+        "linux_ppc64le": [
+          "go1.26.3.linux-ppc64le.tar.gz",
+          "dbd82b50530ead2beb1fd72215117380df3cb16332b51467116dc35b3691dd75"
+        ],
+        "linux_riscv64": [
+          "go1.26.3.linux-riscv64.tar.gz",
+          "3b8fd5112340b72587e42c619f43270f1bc21f63cfdb587e6b72e0336580727c"
+        ],
+        "linux_s390x": [
+          "go1.26.3.linux-s390x.tar.gz",
+          "5c0605b7175449f1c8e8cb02efaba2695caab914fad4dcedc764c2f4c6dfe6ca"
+        ],
+        "netbsd_386": [
+          "go1.26.3.netbsd-386.tar.gz",
+          "15c74255bb23fe9690faac4e489c654cea35d5059a59744e0afd0f78a29a6e53"
+        ],
+        "netbsd_amd64": [
+          "go1.26.3.netbsd-amd64.tar.gz",
+          "a622ef5f3a6d42661ca75bdc939d8cb0468bb1a4418755b6d8c1b4acb82dd03c"
+        ],
+        "netbsd_arm": [
+          "go1.26.3.netbsd-arm.tar.gz",
+          "696df9d8562ac0118c3b8fd6578978d1c4e35583fe2d655e18b6520da7508ec9"
+        ],
+        "netbsd_arm64": [
+          "go1.26.3.netbsd-arm64.tar.gz",
+          "faca070ad7866db5f5a085fe4380408394e2427e093e9770146620c0db508251"
+        ],
+        "openbsd_386": [
+          "go1.26.3.openbsd-386.tar.gz",
+          "a29ad1b1f1a0a3a0f7e70a579f8ab1a26c03778bdcae4f58bd5a29f303104af9"
+        ],
+        "openbsd_amd64": [
+          "go1.26.3.openbsd-amd64.tar.gz",
+          "b2d952b8f0b74a6d2c1a01251aca75ec8eb00a505ebc993f192b792c6762c800"
+        ],
+        "openbsd_arm": [
+          "go1.26.3.openbsd-arm.tar.gz",
+          "1038789f09e31a6b7b5cd7a5e5b3f65cb88ceefb68e34875a7e41b1a28fe2fb7"
+        ],
+        "openbsd_arm64": [
+          "go1.26.3.openbsd-arm64.tar.gz",
+          "1e2df1dc7a4af8bf2a937e7641f300b855bf12bb84a01c44d00a2d68ec18ce26"
+        ],
+        "openbsd_ppc64": [
+          "go1.26.3.openbsd-ppc64.tar.gz",
+          "42ac85fff0f26a1121ba94d7677f6c51cf3d92b53ad2980e54d80f0b196d57a9"
+        ],
+        "openbsd_riscv64": [
+          "go1.26.3.openbsd-riscv64.tar.gz",
+          "c3ada901258530e4ccb58d58537fb9203733ef76155e589125fd2de2e33e857a"
+        ],
+        "plan9_386": [
+          "go1.26.3.plan9-386.tar.gz",
+          "0c5ee46c1345f16edb9ed7317050c20178fdb2b67f0adc2d7f5cf9339147bee5"
+        ],
+        "plan9_amd64": [
+          "go1.26.3.plan9-amd64.tar.gz",
+          "d672908489ef1982b63110597e1804656a5a9519544337af382233a171a1c96e"
+        ],
+        "plan9_arm": [
+          "go1.26.3.plan9-arm.tar.gz",
+          "8ff8f45a52b4900febf8ecf0aa9ac0d49233c76fa4efe405e6ff0d81a6a50749"
+        ],
+        "solaris_amd64": [
+          "go1.26.3.solaris-amd64.tar.gz",
+          "e9c5eab8d081c57fad50895b99e18faf78cccbbe957dea231eba3a32c964d7e7"
+        ],
+        "windows_386": [
+          "go1.26.3.windows-386.zip",
+          "cefec7bd234f57dcc22e2ad2b2e98e45840d998770c5b10b22daddf728dc7cac"
+        ],
+        "windows_amd64": [
+          "go1.26.3.windows-amd64.zip",
+          "20d2ceafb4ed41b96b879010927b28bc92a5be57a7c1801ce365a9ca51d3224a"
+        ],
+        "windows_arm64": [
+          "go1.26.3.windows-arm64.zip",
+          "95cd63bc6b0da77409ba819215afea9ddf5702c55a3b20af3dd90ea95c7b130c"
+        ]
+      }
+    },
     "@@rules_python+//python/extensions:pip.bzl%pip": {
       "dist_hashes": {
         "https://pypi.org/simple": {
blob - 1f62bfe083a9cf76b2c567d3b28134cdef3e2055
blob + 9e39f2fd86db738777813a1cc58b0a5dd6627416
--- README.md
+++ README.md
@@ -19,6 +19,7 @@ Various tools I have been using throughout the years.
 | [speediness](speediness/) | network speed test |
 | [tempfox](tempfox/) | Firefox user.js preference overrides |
 | [wlr-river-title](wlr-river-title/) | River/Wayland focused view title printer |
+| [wrd](wrd/) | full-text search and reader for an offline web archive |
 
 ## Toolchains
 
@@ -27,6 +28,7 @@ Various tools I have been using throughout the years.
 | Rust | nightly (2026-05-11) |
 | Zig | 0.16.0 |
 | Python | 3.14 |
+| Go | 1.26.3 |
 
 ## Building
 
blob - /dev/null
blob + e917f2717dd5865165e62677852ad0b3dfa4a268 (mode 644)
--- /dev/null
+++ wrd/BUILD
@@ -0,0 +1,23 @@
+load("@rules_go//go:def.bzl", "go_binary", "go_library")
+load("//bazel:local-deploy.bzl", "local_deploy")
+
+go_library(
+    name = "wrd_lib",
+    srcs = [
+        "main.go",
+        "tui.go",
+    ],
+    importpath = "wrd",
+    visibility = ["//visibility:private"],
+)
+
+go_binary(
+    name = "wrd",
+    embed = [":wrd_lib"],
+    visibility = ["//visibility:public"],
+)
+
+local_deploy(
+    name = "deploy",
+    srcs = [":wrd"],
+)
blob - /dev/null
blob + c4e5b1b6c1351161cff29ab3b719ef4d17472c23 (mode 644)
--- /dev/null
+++ wrd/README.md
@@ -0,0 +1,52 @@
+# wrd
+
+*web recall daemon* — full-text search and reader for the offline web archive in `~/misc/www`. Launches into live full-text search across the rendered text of every page; `Tab` switches to browsing by path. Selecting a result opens it in the [cha](https://sr.ht/~bptato/chawan/) terminal browser — markdown is rendered to HTML with `lowdown` first.
+
+Search reads a plain-text cache under `$XDG_CACHE_HOME/wrd`, rebuilt incrementally as pages change. Everything runs in-process.
+
+## Building
+
+```sh
+bazel build //wrd
+```
+
+Install it locally (to `~/.local/share/tools`) with:
+
+```sh
+bazel run //wrd:deploy
+```
+
+## Usage
+
+```sh
+wrd [scope]
+```
+
+### Options
+
+| Argument | Description |
+|----------|-------------|
+| `<scope>` | Optional. Restrict to pages whose path contains this substring (e.g. `wrd purescript`) |
+
+### Controls
+
+| Key | Action |
+|-----|--------|
+| *type* | Filter results |
+| `Tab` | Toggle content / browse mode |
+| `↑` `↓` / `Ctrl-n` `Ctrl-p` | Move selection |
+| `PgUp` `PgDn` | Page through results |
+| `Enter` | Open the page in cha |
+| `Ctrl-o` | Open the reconstructed live URL in cha |
+| `Ctrl-y` | Yank the live URL (`wl-copy`) |
+| `Ctrl-u` | Clear the query |
+| `Esc` / `Ctrl-c` | Quit |
+
+## Requirements
+
+| Tool | Used for |
+|------|----------|
+| `cha` | Rendering and displaying pages |
+| `lowdown` | Markdown → HTML for display |
+| `pandoc` | Markdown → text for the search index |
+| `wl-copy` | Clipboard (`Ctrl-y`) |
blob - /dev/null
blob + 3c8233b33edae0b81abd437ad8f7e0ab574409d6 (mode 644)
--- /dev/null
+++ wrd/go.mod
@@ -0,0 +1,3 @@
+module wrd
+
+go 1.26
blob - /dev/null
blob + afb38c4714fb342a7f201d1fb4ad77e2e477011e (mode 644)
--- /dev/null
+++ wrd/main.go
@@ -0,0 +1,244 @@
+package main
+
+import (
+	"cmp"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"sync"
+	"time"
+)
+
+// caps how many content matches are collected per keystroke
+const resultLimit = 2000
+
+type doc struct {
+	src        string
+	rel        string
+	mtime      time.Time
+	lines      []string
+	linesLower []string
+}
+
+type match struct {
+	d    *doc
+	line int // 1-based, 0 in browse mode
+}
+
+func (m match) text() string {
+	if m.line == 0 {
+		return m.d.rel
+	}
+	return m.d.rel + ":" + strings.TrimSpace(m.d.lines[m.line-1])
+}
+
+type app struct {
+	base, txt string
+	scope     string
+	docs      []*doc
+
+	content bool
+	query   []rune
+	results []match
+	sel     int
+	top     int
+
+	rows, cols int
+	savedStty  string
+}
+
+func main() {
+	home, _ := os.UserHomeDir()
+	cache := cmp.Or(os.Getenv("XDG_CACHE_HOME"), filepath.Join(home, ".cache"))
+	a := &app{
+		base:    filepath.Join(home, "misc", "www"),
+		txt:     filepath.Join(cache, "wrd", "text"),
+		content: true,
+	}
+	if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
+		a.scope = os.Args[1]
+	}
+
+	a.scan()
+	if len(a.docs) == 0 {
+		fmt.Fprintln(os.Stderr, "wrd: no pages under", a.base)
+		os.Exit(1)
+	}
+	a.index()
+	a.load()
+	a.run()
+}
+
+func (a *app) scan() {
+	_ = filepath.WalkDir(a.base, func(p string, d os.DirEntry, err error) error {
+		if err != nil || d.IsDir() {
+			return nil // skip unreadable entries, keep walking
+		}
+		ext := strings.ToLower(filepath.Ext(p))
+		if ext != ".html" && ext != ".md" {
+			return nil
+		}
+		rel, _ := filepath.Rel(a.base, p)
+		if a.scope != "" && !strings.Contains(rel, a.scope) {
+			return nil
+		}
+		fi, err := os.Stat(p)
+		if err != nil {
+			return nil // broken symlink or a vanished file
+		}
+		a.docs = append(a.docs, &doc{src: p, rel: rel, mtime: fi.ModTime()})
+		return nil
+	})
+}
+
+// index renders each page to a text cache, skipping ones already up to date
+func (a *app) index() {
+	type job struct{ src, out string }
+	var jobs []job
+	for _, d := range a.docs {
+		out := filepath.Join(a.txt, d.rel+".txt")
+		if oi, err := os.Stat(out); err == nil &&
+			!d.mtime.Truncate(time.Second).After(oi.ModTime().Truncate(time.Second)) {
+			continue
+		}
+		jobs = append(jobs, job{d.src, out})
+	}
+	if len(jobs) == 0 {
+		return
+	}
+
+	fmt.Fprintf(os.Stderr, "indexing %d pages\n", len(jobs))
+	ch := make(chan job)
+	var wg sync.WaitGroup
+	for range runtime.NumCPU() {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			for j := range ch {
+				if err := render(j.src, j.out); err != nil {
+					fmt.Fprintf(os.Stderr, "wrd: %s: %v\n", j.src, err)
+				}
+			}
+		}()
+	}
+	for _, j := range jobs {
+		ch <- j
+	}
+	close(ch)
+	wg.Wait()
+}
+
+func render(src, out string) error {
+	fi, err := os.Stat(src)
+	if err != nil {
+		return err
+	}
+	if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
+		return err
+	}
+	f, err := os.Create(out)
+	if err != nil {
+		return err
+	}
+
+	html := strings.EqualFold(filepath.Ext(src), ".html")
+	cmd := exec.Command("pandoc", "-t", "plain", src)
+	if html {
+		cmd = exec.Command("cha", "-d", src)
+	}
+	cmd.Stdout = f
+	rerr := cmd.Run()
+	if rerr != nil && !html { // fall back to the raw markdown
+		_, _ = f.Seek(0, 0)
+		_ = f.Truncate(0)
+		if b, e := os.ReadFile(src); e == nil {
+			_, _ = f.Write(b)
+			rerr = nil
+		}
+	}
+	_ = f.Close()
+
+	// stamp the cache with the source mtime
+	_ = os.Chtimes(out, time.Now(), fi.ModTime())
+	return rerr
+}
+
+func (a *app) load() {
+	for _, d := range a.docs {
+		b, err := os.ReadFile(filepath.Join(a.txt, d.rel+".txt"))
+		if err != nil {
+			continue
+		}
+		s := strings.TrimRight(string(b), "\n")
+		if strings.TrimSpace(s) == "" {
+			continue // missing or empty render: leave it as no-text
+		}
+		d.lines = strings.Split(s, "\n")
+		d.linesLower = make([]string, len(d.lines))
+		for i, ln := range d.lines {
+			d.linesLower[i] = strings.ToLower(ln)
+		}
+	}
+}
+
+func (a *app) search() {
+	q := strings.TrimSpace(string(a.query))
+	fold := q == strings.ToLower(q) // lowercase query => case-insensitive
+	a.results = a.results[:0]
+
+	switch {
+	case a.content && q == "":
+	case a.content:
+		for _, d := range a.docs {
+			lines := d.lines
+			if fold {
+				lines = d.linesLower
+			}
+			for i, ln := range lines {
+				if strings.Contains(ln, q) {
+					a.results = append(a.results, match{d, i + 1})
+					if len(a.results) >= resultLimit {
+						goto done
+					}
+				}
+			}
+		}
+	default:
+		for _, d := range a.docs {
+			hay := d.rel
+			if fold {
+				hay = strings.ToLower(hay)
+			}
+			if fuzzy(hay, q) {
+				a.results = append(a.results, match{d: d})
+			}
+		}
+	}
+done:
+	a.sel = max(0, min(a.sel, len(a.results)-1))
+	a.top = 0
+}
+
+func fuzzy(hay, needle string) bool {
+	nr := []rune(needle)
+	i := 0
+	for _, c := range hay {
+		if i == len(nr) {
+			break
+		}
+		if c == nr[i] {
+			i++
+		}
+	}
+	return i == len(nr)
+}
+
+func url(rel string) string {
+	if strings.HasSuffix(rel, "/index.html") {
+		rel = strings.TrimSuffix(rel, "index.html")
+	}
+	return "https://" + rel
+}
blob - /dev/null
blob + f957c41e6f2a6716611dc96ecd9656b168389846 (mode 644)
--- /dev/null
+++ wrd/tui.go
@@ -0,0 +1,332 @@
+package main
+
+import (
+	"cmp"
+	"fmt"
+	"os"
+	"os/exec"
+	"os/signal"
+	"sort"
+	"strings"
+	"syscall"
+	"unicode/utf8"
+	"unsafe"
+)
+
+// Terminal state is saved once, in enter(); later transitions only flip raw mode
+const (
+	altOn  = "\x1b[?1049h\x1b[?25l" // alt screen, hide cursor
+	altOff = "\x1b[?25h\x1b[?1049l" // show cursor, primary screen
+)
+
+func (a *app) enter() {
+	cmd := exec.Command("stty", "-g")
+	cmd.Stdin = os.Stdin
+	if out, err := cmd.Output(); err == nil {
+		a.savedStty = strings.TrimSpace(string(out))
+	}
+	a.resume()
+}
+
+func (a *app) resume() { a.raw(); fmt.Print(altOn) }
+func (a *app) leave()  { fmt.Print(altOff); a.cooked() }
+
+// raw disables isig/ixon too, so ctrl-c/s/q arrive as bytes instead of signals
+func (a *app) raw() { a.stty("-echo", "-icanon", "-isig", "-ixon", "min", "1", "time", "0") }
+
+func (a *app) cooked() { a.stty(cmp.Or(a.savedStty, "sane")) }
+
+func (a *app) stty(args ...string) {
+	cmd := exec.Command("stty", args...)
+	cmd.Stdin = os.Stdin
+	_ = cmd.Run()
+}
+
+// size queries the window via ioctl
+func (a *app) size() {
+	var ws struct{ row, col, x, y uint16 }
+	_, _, _ = syscall.Syscall(syscall.SYS_IOCTL, os.Stdin.Fd(),
+		uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&ws)))
+	a.rows = max(int(ws.row), 5)
+	a.cols = max(int(ws.col), 20)
+}
+
+func (a *app) run() {
+	sort.SliceStable(a.docs, func(i, j int) bool {
+		return a.docs[i].mtime.After(a.docs[j].mtime)
+	})
+	a.enter()
+	defer a.leave()
+
+	// defer does not run on signal death; restore the terminal by hand
+	sig := make(chan os.Signal, 1)
+	signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
+	go func() { <-sig; a.leave(); os.Exit(1) }()
+
+	a.size()
+	a.search()
+	a.draw()
+
+	buf := make([]byte, 64)
+	for {
+		n, err := os.Stdin.Read(buf)
+		if err != nil || n == 0 {
+			return
+		}
+		if a.handle(buf[:n]) {
+			return
+		}
+		a.draw()
+	}
+}
+
+func (a *app) handle(b []byte) bool {
+	changed := false
+	for i := 0; i < len(b); {
+		c := b[i]
+		switch {
+		case c == 0x1b: // CSI sequence; a lone esc quits; ignore alt/partial
+			if i+1 < len(b) && b[i+1] == '[' {
+				j := i + 2
+				for j < len(b) && (b[j] < 0x40 || b[j] > 0x7e) {
+					j++
+				}
+				if j < len(b) {
+					switch param, final := string(b[i+2:j]), b[j]; {
+					case final == 'A':
+						a.move(-1)
+					case final == 'B':
+						a.move(1)
+					case final == '~' && param == "5":
+						a.move(-(a.rows - 2))
+					case final == '~' && param == "6":
+						a.move(a.rows - 2)
+					}
+					i = j + 1
+					continue
+				}
+			}
+			if len(b) == 1 {
+				return true
+			}
+			// alt-prefixed key or a split escape: drop the esc, keep going
+		case c == 3: // ctrl-c
+			return true
+		case c == 9: // tab
+			a.content = !a.content
+			changed = true
+		case c == 13 || c == 10: // enter
+			a.open()
+		case c == 15: // ctrl-o
+			a.openURL()
+		case c == 25: // ctrl-y
+			a.yank()
+		case c == 14: // ctrl-n
+			a.move(1)
+		case c == 16: // ctrl-p
+			a.move(-1)
+		case c == 21: // ctrl-u
+			a.query = a.query[:0]
+			changed = true
+		case c == 127 || c == 8: // backspace
+			if len(a.query) > 0 {
+				a.query = a.query[:len(a.query)-1]
+				changed = true
+			}
+		case c < 32:
+		default:
+			r, n := utf8.DecodeRune(b[i:])
+			if r == utf8.RuneError && n <= 1 {
+				i++ // invalid or a multibyte rune split across reads
+				continue
+			}
+			if r >= 32 && r != 0x7f {
+				a.query = append(a.query, r)
+				changed = true
+			}
+			i += n
+			continue
+		}
+		i++
+	}
+	if changed {
+		a.search()
+	}
+	return false
+}
+
+func (a *app) move(d int) {
+	if len(a.results) > 0 {
+		a.sel = max(0, min(a.sel+d, len(a.results)-1))
+	}
+}
+
+// cur returns the selected match, or false if there is none
+func (a *app) cur() (match, bool) {
+	if a.sel < len(a.results) {
+		return a.results[a.sel], true
+	}
+	return match{}, false
+}
+
+func (a *app) draw() {
+	a.size()
+	listH := a.rows - 1
+	leftW := min(max(a.cols*2/5, 24), a.cols-10)
+	prevW := a.cols - leftW - 1
+
+	if a.sel < a.top {
+		a.top = a.sel
+	}
+	if a.sel >= a.top+listH {
+		a.top = a.sel - listH + 1
+	}
+
+	var sb strings.Builder
+	sb.WriteString("\x1b[H\x1b[2J")
+
+	mode := "content"
+	if !a.content {
+		mode = "browse"
+	}
+	prompt := fmt.Sprintf("\x1b[1m%s\x1b[0m ▸ %s", mode, string(a.query))
+	count := fmt.Sprintf("\x1b[2m%d/%d\x1b[0m", min(len(a.results), a.sel+1), len(a.results))
+	sb.WriteString(pad(prompt, a.cols-visLen(count)) + count + "\r\n")
+
+	prev := a.previewLines(prevW, listH)
+	for i := 0; i < listH; i++ {
+		left := strings.Repeat(" ", leftW)
+		if ri := a.top + i; ri < len(a.results) {
+			left = padRaw(truncate(a.results[ri].text(), leftW), leftW)
+			if ri == a.sel {
+				left = "\x1b[7m" + left + "\x1b[0m"
+			}
+		}
+		right := ""
+		if i < len(prev) {
+			right = prev[i]
+		}
+		sb.WriteString(left + " \x1b[2m│\x1b[0m " + right + "\r\n")
+	}
+	fmt.Print(strings.TrimSuffix(sb.String(), "\r\n"))
+}
+
+// previewLines renders the selection's text centred on the matched line
+func (a *app) previewLines(w, h int) []string {
+	m, ok := a.cur()
+	if !ok || w < 4 {
+		return nil
+	}
+	if len(m.d.lines) == 0 {
+		return []string{"\x1b[2m(no cached text)\x1b[0m"}
+	}
+	start := 0
+	if m.line > 0 {
+		start = max(0, m.line-1-h/2)
+	}
+	var out []string
+	for i := start; i < len(m.d.lines) && len(out) < h; i++ {
+		row := truncate(m.d.lines[i], w-2)
+		if i+1 == m.line {
+			out = append(out, "\x1b[1m"+row+"\x1b[0m")
+		} else {
+			out = append(out, "\x1b[2m"+row+"\x1b[0m")
+		}
+	}
+	return out
+}
+
+func (a *app) withExternal(fn func()) {
+	a.leave()
+	fn()
+	a.resume()
+}
+
+func (a *app) open() {
+	m, ok := a.cur()
+	if !ok {
+		return
+	}
+	src := m.d.src
+	a.withExternal(func() {
+		if strings.HasSuffix(strings.ToLower(src), ".md") {
+			tmp, err := os.CreateTemp("", "wrd-*.html")
+			if err != nil {
+				return
+			}
+			defer func() { _ = os.Remove(tmp.Name()) }()
+			out, _ := exec.Command("lowdown", src).Output()
+			_, _ = tmp.Write(out)
+			_ = tmp.Close()
+			src = tmp.Name()
+		}
+		runTTY(exec.Command("cha", src))
+	})
+}
+
+func (a *app) openURL() {
+	if m, ok := a.cur(); ok {
+		u := url(m.d.rel)
+		a.withExternal(func() { runTTY(exec.Command("cha", u)) })
+	}
+}
+
+func (a *app) yank() {
+	m, ok := a.cur()
+	if !ok {
+		return
+	}
+	cmd := exec.Command("wl-copy")
+	cmd.Stdin = strings.NewReader(url(m.d.rel))
+	_ = cmd.Run()
+}
+
+func runTTY(cmd *exec.Cmd) {
+	cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
+	_ = cmd.Run()
+}
+
+// visLen counts visible runes, ignoring SGR escapes
+func visLen(s string) int {
+	n, esc := 0, false
+	for _, r := range s {
+		switch {
+		case esc:
+			esc = r != 'm'
+		case r == 0x1b:
+			esc = true
+		default:
+			n++
+		}
+	}
+	return n
+}
+
+func truncate(s string, w int) string {
+	rs := []rune(s)
+	if w <= 0 {
+		return ""
+	}
+	if len(rs) <= w {
+		return s
+	}
+	if w == 1 {
+		return string(rs[:1])
+	}
+	return string(rs[:w-1]) + "…"
+}
+
+func padRaw(s string, w int) string {
+	rs := []rune(s)
+	if len(rs) >= w {
+		return string(rs[:w])
+	}
+	return s + strings.Repeat(" ", w-len(rs))
+}
+
+func pad(s string, w int) string {
+	if v := visLen(s); v < w {
+		return s + strings.Repeat(" ", w-v)
+	}
+	return s
+}