You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/10/20 08:24:55 UTC

(maka) 17/24: chore(gpui): sync Kit to upstream 0.8.0

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/nsh-tui-shell
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 4b1b1d79521e8e907a823fb395246f4c3143f38e
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Tue Oct 20 15:54:36 2026 +0800

    chore(gpui): sync Kit to upstream 0.8.0
    
    Import the fixed 77ff6f7 Kit source and coherent 0.8.0 dependency closure, preserving Maka input, selection, retained geometry and scoped runtime ownership. Use upstream list and HTML fixes and connect ExternalEditor links to native targets.
    
    Generated-by: OpenAI Codex
---
 Cargo.lock                                         |  447 ++++++-
 Cargo.toml                                         |    3 +
 LICENSE                                            |    6 +-
 third-party/gpui-kit/README.md                     |    4 +
 third-party/gpui-kit/SOURCE.md                     |   37 +-
 third-party/gpui-kit/crates/base/Cargo.toml        |    8 +-
 .../gpui-kit/crates/base/src/a11y_disabled.rs      |  304 +++++
 third-party/gpui-kit/crates/base/src/button.rs     |   13 +-
 third-party/gpui-kit/crates/base/src/checkbox.rs   |    9 +-
 .../gpui-kit/crates/base/src/date_picker.rs        |    2 +
 .../dock/fixtures/legacy_empty_stack_panel.json    |    3 +
 .../fixtures/placeholder_wrapped_empty_stack.json  |   16 +
 .../gpui-kit/crates/base/src/dock/state_convert.rs |   59 +-
 third-party/gpui-kit/crates/base/src/focus_trap.rs |   51 +-
 .../crates/base/src/input/base/blink_cursor.rs     |   40 +-
 .../gpui-kit/crates/base/src/input/base/element.rs |  456 ++++---
 .../gpui-kit/crates/base/src/input/base/state.rs   |  711 ++++++++++-
 .../src/input/editor/display_map/display_map.rs    |    7 +
 .../base/src/input/editor/lsp/completions.rs       |  176 ++-
 .../crates/base/src/input/editor/lsp/mod.rs        |   41 +
 .../crates/base/src/input/editor/search.rs         |   16 +
 third-party/gpui-kit/crates/base/src/input/mod.rs  |    9 +-
 third-party/gpui-kit/crates/base/src/lib.rs        |    2 +
 third-party/gpui-kit/crates/base/src/link.rs       |    6 +-
 third-party/gpui-kit/crates/base/src/motion.rs     |    2 +-
 .../gpui-kit/crates/base/src/motion/presence.rs    |    5 +-
 .../gpui-kit/crates/base/src/number_input.rs       |   18 +-
 third-party/gpui-kit/crates/base/src/overscroll.rs |  463 +++++++
 third-party/gpui-kit/crates/base/src/popover.rs    |   13 +-
 third-party/gpui-kit/crates/base/src/radio.rs      |    3 +-
 third-party/gpui-kit/crates/base/src/root.rs       |  352 +++++-
 .../gpui-kit/crates/base/src/scroll_bounce.rs      |  537 +++++++-
 .../gpui-kit/crates/base/src/scrollable_mask.rs    |  168 ++-
 third-party/gpui-kit/crates/base/src/scrollbar.rs  |  218 +++-
 third-party/gpui-kit/crates/base/src/select.rs     |  138 +-
 third-party/gpui-kit/crates/base/src/slider.rs     |  140 ++-
 third-party/gpui-kit/crates/base/src/switch.rs     |   11 +-
 third-party/gpui-kit/crates/base/src/tabs.rs       |    3 +-
 .../gpui-kit/crates/base/src/test_support.rs       |    9 +
 .../gpui-kit/crates/base/src/text/document.rs      |   40 +-
 .../gpui-kit/crates/base/src/text/editor_host.rs   | 1324 ++++++++++++++++++++
 .../gpui-kit/crates/base/src/text/editor_seam.rs   |   86 ++
 .../gpui-kit/crates/base/src/text/format/html.rs   |  297 ++++-
 .../crates/base/src/text/format/html5minify/mod.rs |   34 +-
 .../crates/base/src/text/format/markdown.rs        |    1 +
 .../gpui-kit/crates/base/src/text/inline.rs        |  432 +++++--
 .../gpui-kit/crates/base/src/text/inline_flow.rs   |  194 ++-
 .../gpui-kit/crates/base/src/text/inline_object.rs |   52 +-
 third-party/gpui-kit/crates/base/src/text/link.rs  |  407 +++++-
 third-party/gpui-kit/crates/base/src/text/mod.rs   |    2 +
 third-party/gpui-kit/crates/base/src/text/node.rs  |  541 +++++++-
 .../crates/base/src/text/selection_adapter.rs      |   83 +-
 third-party/gpui-kit/crates/base/src/text/state.rs |   29 +-
 third-party/gpui-kit/crates/base/src/text/style.rs |   33 +-
 .../gpui-kit/crates/base/src/text/text_view.rs     |  472 ++++++-
 .../gpui-kit/crates/base/src/text_selection.rs     |  158 ++-
 third-party/gpui-kit/crates/base/src/time_field.rs |    3 +-
 third-party/gpui-kit/crates/base/src/toggle.rs     |    3 +-
 third-party/gpui-kit/crates/base/src/tree.rs       |  154 ++-
 .../gpui-kit/crates/base/src/window_border.rs      |   17 +-
 third-party/gpui-kit/crates/shell/Cargo.toml       |   21 +-
 third-party/gpui-kit/crates/shell/README.md        |  107 +-
 .../gpui-kit/crates/shell/component-inventory.json |   17 +
 .../gpui-kit/crates/shell/src/capability.rs        |  321 ++++-
 .../crates/shell/src/component_registry.rs         |  147 ++-
 .../gpui-kit/crates/shell/src/components/mod.rs    |   40 +-
 .../crates/shell/src/components/shell/chat.rs      |  187 ++-
 .../shell/src/components/shell/layout/textarea.rs  |    1 +
 .../crates/shell/src/components/shell/media/mod.rs |    4 +
 .../shell/src/components/shell/media/webview.rs    |  456 +++++++
 .../src/components/shell/retained_forms/mod.rs     |    1 +
 .../shell/src/components/shell/scroll/scroll.rs    |    2 +
 third-party/gpui-kit/crates/shell/src/default-env  |   92 ++
 .../crates/shell/src/engine/quickjs/dock_api.rs    |   39 +-
 .../crates/shell/src/engine/quickjs/entity_api.rs  |  190 ++-
 .../crates/shell/src/engine/quickjs/host.rs        |  248 +++-
 .../shell/src/engine/quickjs/host_modules.rs       |   34 +-
 .../crates/shell/src/engine/quickjs/js_error.rs    |   66 +
 .../crates/shell/src/engine/quickjs/mod.rs         |  955 ++++++++++----
 .../crates/shell/src/engine/quickjs/overlay.rs     |  102 +-
 .../crates/shell/src/engine/quickjs/sandbox.rs     |  584 ++++++++-
 .../crates/shell/src/engine/quickjs/scheduler.rs   |  157 ++-
 .../shell/src/engine/quickjs/standard/encoding.rs  |  307 +++++
 .../shell/src/engine/quickjs/standard/fetch.rs     |   59 +-
 .../crates/shell/src/engine/quickjs/standard/fs.rs |    8 +
 .../shell/src/engine/quickjs/standard/mod.rs       |    6 +
 .../shell/src/engine/quickjs/standard/net.rs       |    9 +-
 .../crates/shell/src/engine/quickjs/standard/os.rs |   18 +-
 .../shell/src/engine/quickjs/standard/process.rs   |    2 +-
 .../shell/src/engine/quickjs/standard/websocket.rs |   41 +-
 .../crates/shell/src/engine/quickjs/template.rs    |   65 +-
 .../crates/shell/src/engine/quickjs/theme_api.rs   |   22 +-
 .../crates/shell/src/engine/quickjs/window_api.rs  |   27 +-
 third-party/gpui-kit/crates/shell/src/entities.rs  |    2 +-
 third-party/gpui-kit/crates/shell/src/host.rs      |  148 ++-
 .../gpui-kit/crates/shell/src/host/agents.md       |   64 +-
 .../gpui-kit/crates/shell/src/host/installed.rs    |    4 +-
 .../gpui-kit/crates/shell/src/host/scaffold.rs     |    4 +-
 third-party/gpui-kit/crates/shell/src/lib.rs       |    5 +-
 .../gpui-kit/crates/shell/src/materialize.rs       |   16 +
 .../shell/src/materialize/components/list.rs       |   32 +-
 .../shell/src/materialize/components/tooltip.rs    |    6 +-
 .../src/materialize/components/virtual_list.rs     |   60 +-
 third-party/gpui-kit/crates/shell/src/path.rs      |   32 +-
 third-party/gpui-kit/crates/shell/src/plugin.rs    |  232 +++-
 third-party/gpui-kit/crates/shell/src/policy.rs    |   26 +-
 third-party/gpui-kit/crates/shell/src/process.rs   |  730 ++++++++++-
 third-party/gpui-kit/crates/shell/src/root.rs      |   37 +-
 third-party/gpui-kit/crates/shell/src/runtime.rs   |  549 ++++++--
 third-party/gpui-kit/crates/shell/src/spec.rs      |   61 +-
 third-party/gpui-kit/crates/shell/src/style.rs     |  210 +++-
 third-party/gpui-kit/crates/shell/src/tests/fs.rs  |  316 +++++
 .../gpui-kit/crates/shell/src/tests/network.rs     |  123 +-
 .../gpui-kit/crates/shell/src/tests/process.rs     |  301 ++++-
 .../gpui-kit/crates/shell/src/tests/render.rs      |  293 ++++-
 .../gpui-kit/crates/shell/src/tests/snapshot.rs    |  101 ++
 .../crates/shell/src/tests/standard_runtime.rs     |  144 ++-
 .../gpui-kit/crates/shell/src/tests/template.rs    |   37 +
 third-party/gpui-kit/crates/shell/src/typings.rs   |  461 ++++++-
 third-party/gpui-kit/crates/shell/src/value.rs     |  120 +-
 third-party/gpui-kit/crates/shell/src/view.rs      |   32 +-
 third-party/gpui-kit/crates/shell/src/watch.rs     |   74 +-
 third-party/gpui-kit/upstream.json                 |  230 ++--
 123 files changed, 15627 insertions(+), 2026 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index ef03f97a14..a12ff11fba 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -349,6 +349,17 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "annotate-snippets"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1"
+dependencies = [
+ "anstyle",
+ "memchr",
+ "unicode-width",
+]
+
 [[package]]
 name = "anstyle"
 version = "1.0.14"
@@ -992,6 +1003,12 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
 
+[[package]]
+name = "base62"
+version = "2.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3a647dce9631f152a99ec16cfc09267ae55fe280d5dc288964027d458076323"
+
 [[package]]
 name = "base64"
 version = "0.22.1"
@@ -2686,7 +2703,7 @@ dependencies = [
  "uuid",
  "windows 0.58.0",
  "zeroize",
- "zune-core",
+ "zune-core 0.5.1",
 ]
 
 [[package]]
@@ -3974,6 +3991,26 @@ dependencies = [
  "syn 2.0.119",
 ]
 
+[[package]]
+name = "enum-iterator"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016"
+dependencies = [
+ "enum-iterator-derive",
+]
+
+[[package]]
+name = "enum-iterator-derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
 [[package]]
 name = "enumflags2"
 version = "0.7.12"
@@ -4824,6 +4861,16 @@ dependencies = [
  "polyval 0.7.3",
 ]
 
+[[package]]
+name = "gif"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
+dependencies = [
+ "color_quant",
+ "weezl",
+]
+
 [[package]]
 name = "gif"
 version = "0.14.2"
@@ -5714,6 +5761,17 @@ dependencies = [
  "regex-syntax",
 ]
 
+[[package]]
+name = "globwalk"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc"
+dependencies = [
+ "bitflags 1.3.2",
+ "ignore",
+ "walkdir",
+]
+
 [[package]]
 name = "glow"
 version = "0.17.0"
@@ -5782,7 +5840,7 @@ dependencies = [
 
 [[package]]
 name = "gpui-base"
-version = "0.7.3"
+version = "0.8.0"
 dependencies = [
  "aho-corasick",
  "anyhow",
@@ -5826,6 +5884,64 @@ dependencies = [
  "windows 0.58.0",
 ]
 
+[[package]]
+name = "gpui-component"
+version = "0.8.0"
+source = "git+https://github.com/longbridge/gpui-kit?rev=77ff6f794dca3bcee2e90977cedf12a64aed17e4#77ff6f794dca3bcee2e90977cedf12a64aed17e4"
+dependencies = [
+ "anyhow",
+ "chrono",
+ "core-text",
+ "enum-iterator",
+ "gpui-base",
+ "gpui-component-macros",
+ "gpui-fast",
+ "gpui-kit-assets",
+ "gpui-pre",
+ "gpui-pre-macros",
+ "gpui-pre-sum-tree",
+ "instant",
+ "itertools 0.13.0",
+ "log",
+ "lsp-types",
+ "markdown",
+ "notify",
+ "num-traits",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
+ "once_cell",
+ "paste",
+ "raw-window-handle",
+ "regex",
+ "resvg 0.45.1",
+ "ropey 2.0.0-beta.1",
+ "rust-i18n",
+ "schemars 1.2.2",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "similar 2.7.0",
+ "smallvec",
+ "smol",
+ "tracing",
+ "unicode-segmentation",
+ "unicode-width",
+ "uuid",
+ "windows 0.58.0",
+]
+
+[[package]]
+name = "gpui-component-macros"
+version = "0.8.0"
+source = "git+https://github.com/longbridge/gpui-kit?rev=77ff6f794dca3bcee2e90977cedf12a64aed17e4#77ff6f794dca3bcee2e90977cedf12a64aed17e4"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
 [[package]]
 name = "gpui-fast"
 version = "0.1.10"
@@ -5874,7 +5990,7 @@ dependencies = [
  "rand 0.9.5",
  "raw-window-handle",
  "regex",
- "resvg",
+ "resvg 0.46.0",
  "schemars 1.2.2",
  "seahash",
  "serde",
@@ -5887,7 +6003,7 @@ dependencies = [
  "thiserror 2.0.20",
  "ttf-parser",
  "url",
- "usvg",
+ "usvg 0.46.0",
  "uuid",
  "waker-fn",
  "web-time",
@@ -6133,8 +6249,8 @@ dependencies = [
 
 [[package]]
 name = "gpui-fps"
-version = "0.7.3"
-source = "git+https://github.com/longbridge/gpui-kit?rev=c5f8eca4a0759f8c0c7ed624eddfd03f1c93a5f2#c5f8eca4a0759f8c0c7ed624eddfd03f1c93a5f2"
+version = "0.8.0"
+source = "git+https://github.com/longbridge/gpui-kit?rev=77ff6f794dca3bcee2e90977cedf12a64aed17e4#77ff6f794dca3bcee2e90977cedf12a64aed17e4"
 dependencies = [
  "core-graphics 0.24.0",
  "gpui-fast",
@@ -6149,6 +6265,38 @@ dependencies = [
  "windows 0.58.0",
 ]
 
+[[package]]
+name = "gpui-kit"
+version = "0.8.0"
+source = "git+https://github.com/longbridge/gpui-kit?rev=77ff6f794dca3bcee2e90977cedf12a64aed17e4#77ff6f794dca3bcee2e90977cedf12a64aed17e4"
+dependencies = [
+ "gpui-base",
+ "gpui-component",
+ "gpui-fast",
+ "gpui-fast-platform",
+ "gpui-fast-web",
+ "gpui-kit-assets",
+ "gpui-pre",
+ "gpui-pre-macros",
+ "gpui-pre-platform",
+ "gpui-pre-web",
+]
+
+[[package]]
+name = "gpui-kit-assets"
+version = "0.8.0"
+source = "git+https://github.com/longbridge/gpui-kit?rev=77ff6f794dca3bcee2e90977cedf12a64aed17e4#77ff6f794dca3bcee2e90977cedf12a64aed17e4"
+dependencies = [
+ "anyhow",
+ "gpui-fast",
+ "gpui-pre",
+ "gpui-pre-reqwest",
+ "log",
+ "rust-embed",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+]
+
 [[package]]
 name = "gpui-pre"
 version = "0.3.9"
@@ -6198,7 +6346,7 @@ dependencies = [
  "rand 0.9.5",
  "raw-window-handle",
  "regex",
- "resvg",
+ "resvg 0.46.0",
  "schemars 1.2.2",
  "seahash",
  "serde",
@@ -6211,7 +6359,7 @@ dependencies = [
  "thiserror 2.0.20",
  "ttf-parser",
  "url",
- "usvg",
+ "usvg 0.46.0",
  "uuid",
  "waker-fn",
  "web-time",
@@ -6681,7 +6829,7 @@ checksum = "4b0d73e1bc02eef886a5947f41b8512708792e9e5d3053d181843311a487f96f"
 
 [[package]]
 name = "gpui-shell"
-version = "0.7.3"
+version = "0.8.0"
 dependencies = [
  "anyhow",
  "async-channel",
@@ -6693,6 +6841,7 @@ dependencies = [
  "gpui-fast",
  "gpui-fast-platform",
  "gpui-fps",
+ "gpui-kit",
  "gpui-pre",
  "gpui-pre-platform",
  "gpui-pre-reqwest",
@@ -6712,11 +6861,21 @@ dependencies = [
  "tar",
  "tracing",
  "tungstenite 0.29.0",
- "usvg",
+ "usvg 0.46.0",
  "wait-timeout",
  "windows 0.58.0",
 ]
 
+[[package]]
+name = "granit-parser"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e20f99e46474f56bd905c56e817ebddcf377a611f94c53ac4649e4d3fa3c0cd0"
+dependencies = [
+ "arraydeque",
+ "smallvec",
+]
+
 [[package]]
 name = "grep-matcher"
 version = "0.1.9"
@@ -7503,7 +7662,7 @@ dependencies = [
  "byteorder-lite",
  "color_quant",
  "exr",
- "gif",
+ "gif 0.14.2",
  "image-webp",
  "moxcms",
  "num-traits",
@@ -7512,8 +7671,8 @@ dependencies = [
  "ravif",
  "rayon",
  "tiff",
- "zune-core",
- "zune-jpeg",
+ "zune-core 0.5.1",
+ "zune-jpeg 0.5.15",
 ]
 
 [[package]]
@@ -7524,6 +7683,12 @@ dependencies = [
  "quick-error 2.0.1",
 ]
 
+[[package]]
+name = "imagesize"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
+
 [[package]]
 name = "imagesize"
 version = "0.14.0"
@@ -7729,6 +7894,15 @@ dependencies = [
  "once_cell",
 ]
 
+[[package]]
+name = "itertools"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
+dependencies = [
+ "either",
+]
+
 [[package]]
 name = "itertools"
 version = "0.13.0"
@@ -8097,6 +8271,17 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "kurbo"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
+dependencies = [
+ "arrayvec",
+ "euclid",
+ "smallvec",
+]
+
 [[package]]
 name = "kurbo"
 version = "0.13.1"
@@ -9608,7 +9793,7 @@ dependencies = [
  "schemars 1.2.2",
  "serde",
  "serde_json",
- "similar",
+ "similar 3.2.0",
  "syntect",
  "tempfile",
  "tokio",
@@ -10486,6 +10671,12 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "nohash-hasher"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+
 [[package]]
 name = "nom"
 version = "1.2.4"
@@ -10529,6 +10720,15 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
 
+[[package]]
+name = "normpath"
+version = "1.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b11ce00d2594068e8a27c9146fdc5cf9f3ac38eb42c7cd34d05fea618873ac9f"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "notify"
 version = "8.2.0"
@@ -13703,21 +13903,38 @@ version = "0.7.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
 
+[[package]]
+name = "resvg"
+version = "0.45.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"
+dependencies = [
+ "gif 0.13.3",
+ "image-webp",
+ "log",
+ "pico-args",
+ "rgb",
+ "svgtypes 0.15.3",
+ "tiny-skia 0.11.4",
+ "usvg 0.45.1",
+ "zune-jpeg 0.4.21",
+]
+
 [[package]]
 name = "resvg"
 version = "0.46.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419"
 dependencies = [
- "gif",
+ "gif 0.14.2",
  "image-webp",
  "log",
  "pico-args",
  "rgb",
- "svgtypes",
+ "svgtypes 0.16.1",
  "tiny-skia 0.11.4",
- "usvg",
- "zune-jpeg",
+ "usvg 0.46.0",
+ "zune-jpeg 0.5.15",
 ]
 
 [[package]]
@@ -13943,6 +14160,91 @@ dependencies = [
  "sqlite-wasm-rs",
 ]
 
+[[package]]
+name = "rust-embed"
+version = "8.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23"
+dependencies = [
+ "rust-embed-impl",
+ "rust-embed-utils",
+ "walkdir",
+]
+
+[[package]]
+name = "rust-embed-impl"
+version = "8.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053"
+dependencies = [
+ "mime_guess",
+ "proc-macro2",
+ "quote",
+ "rust-embed-utils",
+ "shellexpand",
+ "syn 2.0.119",
+ "walkdir",
+]
+
+[[package]]
+name = "rust-embed-utils"
+version = "8.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a"
+dependencies = [
+ "globset",
+ "sha2 0.11.1",
+ "walkdir",
+]
+
+[[package]]
+name = "rust-i18n"
+version = "4.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8502e73e6461c738659455c5d2cf95bc132760a6f203c717e66ee72b78906d7f"
+dependencies = [
+ "globwalk",
+ "regex",
+ "rust-i18n-macro",
+ "rust-i18n-support",
+ "smallvec",
+]
+
+[[package]]
+name = "rust-i18n-macro"
+version = "4.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d54a28dd9d46108d56b9f0c85b220908844233e561c03087890325104f90f2c6"
+dependencies = [
+ "glob",
+ "proc-macro2",
+ "quote",
+ "rust-i18n-support",
+ "serde",
+ "serde_json",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "rust-i18n-support"
+version = "4.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e806c2a9701cbd6043992cd935c30cb1fdb128fa15e7b506f3b46f97045737c0"
+dependencies = [
+ "arc-swap",
+ "base62",
+ "foldhash 0.2.0",
+ "globwalk",
+ "itertools 0.11.0",
+ "normpath",
+ "serde",
+ "serde-saphyr",
+ "serde_json",
+ "siphasher",
+ "toml 0.8.23",
+ "triomphe",
+]
+
 [[package]]
 name = "rust_decimal"
 version = "1.42.1"
@@ -14484,6 +14786,23 @@ dependencies = [
  "serde_derive",
 ]
 
+[[package]]
+name = "serde-saphyr"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8050abb251097357e24aff63ba2c52a6309ecb7d23a5474023df960a02694d8"
+dependencies = [
+ "annotate-snippets",
+ "base64 0.23.1",
+ "encoding_rs_io",
+ "granit-parser",
+ "nohash-hasher",
+ "num-traits",
+ "serde_core",
+ "smallvec",
+ "zmij",
+]
+
 [[package]]
 name = "serde_bytes"
 version = "0.11.19"
@@ -14788,6 +15107,15 @@ version = "1.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
 
+[[package]]
+name = "shellexpand"
+version = "3.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8"
+dependencies = [
+ "dirs",
+]
+
 [[package]]
 name = "shlex"
 version = "1.3.0"
@@ -14904,6 +15232,16 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
 
+[[package]]
+name = "similar"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
+dependencies = [
+ "bstr",
+ "unicode-segmentation",
+]
+
 [[package]]
 name = "similar"
 version = "3.2.0"
@@ -15436,13 +15774,23 @@ version = "0.4.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb"
 
+[[package]]
+name = "svgtypes"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"
+dependencies = [
+ "kurbo 0.11.3",
+ "siphasher",
+]
+
 [[package]]
 name = "svgtypes"
 version = "0.16.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
 dependencies = [
- "kurbo",
+ "kurbo 0.13.1",
  "siphasher",
 ]
 
@@ -15888,7 +16236,7 @@ dependencies = [
  "half",
  "quick-error 2.0.1",
  "weezl",
- "zune-jpeg",
+ "zune-jpeg 0.5.15",
 ]
 
 [[package]]
@@ -16382,6 +16730,17 @@ dependencies = [
  "petgraph",
 ]
 
+[[package]]
+name = "triomphe"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d15f4f5898bcfc5d8a8e45ed30b2f108d710c1a2e5489dbf80e078f707181bc"
+dependencies = [
+ "arc-swap",
+ "serde",
+ "stable_deref_trait",
+]
+
 [[package]]
 name = "try-lock"
 version = "0.2.5"
@@ -16793,6 +17152,33 @@ dependencies = [
  "url",
 ]
 
+[[package]]
+name = "usvg"
+version = "0.45.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"
+dependencies = [
+ "base64 0.22.1",
+ "data-url",
+ "flate2",
+ "fontdb",
+ "imagesize 0.13.0",
+ "kurbo 0.11.3",
+ "log",
+ "pico-args",
+ "roxmltree 0.20.0",
+ "rustybuzz",
+ "simplecss",
+ "siphasher",
+ "strict-num",
+ "svgtypes 0.15.3",
+ "tiny-skia-path 0.11.4",
+ "unicode-bidi",
+ "unicode-script",
+ "unicode-vo",
+ "xmlwriter",
+]
+
 [[package]]
 name = "usvg"
 version = "0.46.0"
@@ -16804,7 +17190,7 @@ dependencies = [
  "flate2",
  "fontdb",
  "imagesize 0.14.0",
- "kurbo",
+ "kurbo 0.13.1",
  "log",
  "pico-args",
  "roxmltree 0.21.1",
@@ -16812,7 +17198,7 @@ dependencies = [
  "simplecss",
  "siphasher",
  "strict-num",
- "svgtypes",
+ "svgtypes 0.16.1",
  "tiny-skia-path 0.11.4",
  "unicode-bidi",
  "unicode-script",
@@ -19067,6 +19453,12 @@ dependencies = [
  "pkg-config",
 ]
 
+[[package]]
+name = "zune-core"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
+
 [[package]]
 name = "zune-core"
 version = "0.5.1"
@@ -19082,13 +19474,22 @@ dependencies = [
  "simd-adler32",
 ]
 
+[[package]]
+name = "zune-jpeg"
+version = "0.4.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
+dependencies = [
+ "zune-core 0.4.12",
+]
+
 [[package]]
 name = "zune-jpeg"
 version = "0.5.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
 dependencies = [
- "zune-core",
+ "zune-core 0.5.1",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 7937fe0178..1b1c286425 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -210,3 +210,6 @@ gpui-fast-macos = { path = "third-party/gpui-fast/crates/gpui_macos" }
 gpui-fast-windows = { path = "third-party/gpui-fast/crates/gpui_windows" }
 gpui-fast-linux = { path = "third-party/gpui-fast/crates/gpui_linux" }
 gpui-pre-scheduler = { path = "third-party/gpui-fast/crates/scheduler" }
+
+[patch."https://github.com/longbridge/gpui-kit"]
+gpui-base = { path = "third-party/gpui-kit/crates/base" }
diff --git a/LICENSE b/LICENSE
index 8d898e341b..93f36e64eb 100644
--- a/LICENSE
+++ b/LICENSE
@@ -339,11 +339,11 @@ Copyright 2022 - 2025 Zed Industries, Inc.
 Original license texts, finite file provenance and modifications are recorded in
 third-party/gpui-fast/{LICENSE,LICENSE-APACHE,upstream.json,SOURCE.md}.
 
-GPUI Kit Base source (adapted, Apache License 2.0)
+GPUI Kit Base and Shell sources (adapted, Apache License 2.0)
 
-third-party/gpui-kit contains gpui-base from
+third-party/gpui-kit contains gpui-base and gpui-shell from
 https://github.com/longbridge/gpui-kit at revision
-29c04574dc838a8e59e84c9c9f3c4d15983222ce. Upstream license texts,
+77ff6f794dca3bcee2e90977cedf12a64aed17e4. Upstream license texts,
 original hashes and the bounded local modifications are recorded in
 third-party/gpui-kit/{LICENSE-APACHE,LICENSE-DOCS.md,upstream.json,SOURCE.md}.
 
diff --git a/third-party/gpui-kit/README.md b/third-party/gpui-kit/README.md
index 268b4a2603..3c7afba2e8 100644
--- a/third-party/gpui-kit/README.md
+++ b/third-party/gpui-kit/README.md
@@ -253,6 +253,10 @@ Check out [CONTRIBUTING.md](CONTRIBUTING.md) for more details.
 
 See the [comparison with Iced, egui and Qt 6](https://gpui-kit.com/docs/comparison) on the site.
 
+## Related project: Native Shell
+
+[Native Shell](https://nsh.dev) ([source](https://github.com/longbridge/native-shell)) is a sibling project that lets AI generate UI and renders it natively on iOS, Android, Linux (GTK) and macOS (AppKit). Its GPUI desktop backend is in development and will be built on GPUI Kit.
+
 ## License
 
 Software source and documentation code examples: [Apache-2.0](LICENSE-APACHE).
diff --git a/third-party/gpui-kit/SOURCE.md b/third-party/gpui-kit/SOURCE.md
index de8be2fcb9..636fe3ede3 100644
--- a/third-party/gpui-kit/SOURCE.md
+++ b/third-party/gpui-kit/SOURCE.md
@@ -19,7 +19,7 @@
 
 # GPUI Kit local presentation projection
 
-The `gpui-base` crate is copied from [GPUI Kit](https://github.com/longbridge/gpui-kit/tree/c5f8eca4a0759f8c0c7ed624eddfd03f1c93a5f2), revision `c5f8eca4a0759f8c0c7ed624eddfd03f1c93a5f2`, under Apache-2.0. The `gpui-shell` crate is copied from the same revision; see the section below. Other Kit crates remain dependencies from that exact revision. `upstream.json` records the finite imported file inventory and original SHA-256 hashes before modifications; licenses are retained byte-for-byt [...]
+The `gpui-base` crate is copied from [GPUI Kit](https://github.com/longbridge/gpui-kit/tree/77ff6f794dca3bcee2e90977cedf12a64aed17e4), revision `77ff6f794dca3bcee2e90977cedf12a64aed17e4`, under Apache-2.0. The `gpui-shell` crate is copied from the same revision; see the section below. Other Kit crates remain dependencies from that exact revision. `upstream.json` records the finite imported file inventory and original SHA-256 hashes before modifications; licenses are retained byte-for-byt [...]
 
 The bounded changes preserve the single native editing engine:
 
@@ -162,11 +162,11 @@ Plain-text paint compares its own attached document's copy projection, rather th
 
 ## Upstream synchronization (2026-10-14)
 
-Kit is pinned to `c5f8eca4a0759f8c0c7ed624eddfd03f1c93a5f2` (0.7.3). The input-menu callback and parser acknowledgement now use upstream's implementations, replacing the equivalent local patches. Upstream's immutable run/copy projections and quiet participant registration replace the corresponding mutable query paths. Its reflow selection preservation, table rendering and native controls are retained.
+This synchronization pinned Kit to `c5f8eca4a0759f8c0c7ed624eddfd03f1c93a5f2` (0.7.3); the current revision is recorded in the 2026-10-20 synchronization section below. The input-menu callback and parser acknowledgement now use upstream's implementations, replacing the equivalent local patches. Upstream's immutable run/copy projections and quiet participant registration replace the corresponding mutable query paths. Its reflow selection preservation, table rendering and native controls a [...]
 
 The local physical input groups, exact byte selections, per-hitbox retained geometry and locally projected theme values remain: the upstream global traversal stack and paint-order counter do not provide those behaviors. Quiet registration still passes a participant's first or changed group through original custody reconciliation; completed-frame pruning remains tied to the native focus tree and hitboxes. This update does not merge the separate UI performance branch or migrate Maka to gpu [...]
 
-同步上游后,菜单回调、解析确认和部分只读选区投影直接采用上游实现。仍保留上游尚未覆盖的物理输入隔离、精确字节选区、保留帧几何与局部主题投影;本次不合并独立性能分支,也不代表完成 gpui-shell 迁移。
+该次同步将 Kit 固定到 `c5f8eca4a0759f8c0c7ed624eddfd03f1c93a5f2`(0.7.3);当前版本见下方 2026-10-20 同步节。菜单回调、解析确认和部分只读选区投影直接采用上游实现。仍保留上游尚未覆盖的物理输入隔离、精确字节选区、保留帧几何与局部主题投影;本次不合并独立性能分支,也不代表完成 gpui-shell 迁移。
 
 Participant enablement checks the current value before acquiring a write lease. Repeating the same admission state during layout must not invalidate retained rows under upstream's finer-grained entity-write tracking; real enablement changes retain the original revocation and group withdrawal path.
 
@@ -174,18 +174,18 @@ Plain input modes can install an exact whitespace-delimited literal-token regist
 
 ## gpui-shell runtime (2026-10-16)
 
-`crates/shell` (`gpui-shell`) is copied from revision `c5f8eca4a0759f8c0c7ed624eddfd03f1c93a5f2` with its `rquickjs-compat` facade, README and component inventory; `upstream.json` records each original file. Its integration tests are omitted because most of them need `gpui-component`. The facade's manifest declares MIT to match the `rquickjs` crate it replaces; its source comes from the GPUI Kit repository. The workspace includes both crates as members and patches crates.io `rquickjs` to [...]
+`crates/shell` (`gpui-shell`) is copied from revision `77ff6f794dca3bcee2e90977cedf12a64aed17e4` with its `rquickjs-compat` facade, README and component inventory; `upstream.json` records each original file. Its integration tests are omitted because most of them need `gpui-component`. The facade's manifest declares MIT to match the `rquickjs` crate it replaces; its source comes from the GPUI Kit repository. The workspace includes both crates as members and patches crates.io `rquickjs` to [...]
 
-The manifest is normalized to explicit upstream dependencies and lints. `gpui-fast` and `quickjs` are default features. The `components` feature and its `gpui-component` dependency are removed: `gpui-component` at this revision does not build against the adapted Base above, so an embedding host registers its own component catalog. Check-cfg declares the removed feature name, so the code it guards compiles out without warnings.
+The manifest is normalized to explicit upstream dependencies and lints. `gpui-fast` and `quickjs` are default features. The `components` and `webview` features and their `gpui-component`, `gpui-webview` and `wry` dependencies are removed: `gpui-component` at this revision does not build against the adapted Base above, so an embedding host registers its own component catalog. Check-cfg declares the removed feature names, so the code it guards compiles out without warnings.
 
-The bounded source changes adapt the runtime to the adapted Base, add two development switches, and backport four upstream pull requests (#3547, #3549 and #3550 have since been merged; #3548 is open):
+The bounded source changes adapt the runtime to the adapted Base and add two development switches. The four former backports (#3547, #3548, #3549 and #3550) now use their merged upstream implementations:
 
 - `TextView` link handling uses Base's `on_link_activation`. Both handlers return a decision that prevents Base's default URL opening: the script callback receives the URL, or, without one, the shell opens only URLs its own rules admit. Accessibility activation counts as keyboard activation.
 - Two element helpers that call `ElementExt::on_prepaint` add the `IntoElement` bound the adapted trait requires.
 - `GPUI_SHELL_PASSIVE` opens the window without activating the application or taking focus. `GPUI_SHELL_METRICS_LOG` prints the runtime's script counters to stderr once a second. Both are off unless the variable is set.
-- From longbridge/gpui-kit #3547, #3548, #3549 and #3550: a `list` keeps the reader on the item at the top of the view when its item count changes; `with_follow_mode("tail")` makes a `list` follow its end; `on_visible_range_change` reports which items a `list` shows after a frame in which that changed; and `TextareaState.set_submit_on_enter` makes Enter submit without inserting a line break. The next sync replaces them with the upstream versions.
+- From the merged upstream #3547, #3548, #3549 and #3550: a `list` keeps the reader on the item at the top of the view when its item count changes; `with_follow_mode("tail")` makes a `list` follow its end; `on_visible_range_change` reports which items a `list` shows after a frame in which that changed; and `TextareaState.set_submit_on_enter` makes Enter submit without inserting a line break.
 
-`gpui-shell` 及其 `rquickjs-compat` 兼容层自同一上游版本复制,`upstream.json` 记录每个原始文件;集成测试大多依赖 `gpui-component`,因此未导入。兼容层清单声明 MIT 以对应其替代的 `rquickjs`,源码来自 GPUI Kit 仓库。工作区将两者作为成员,并把 crates.io 的 `rquickjs` 指向本地兼容层,使 LLRT 与 shell 共用同一 QuickJS VM。清单改为显式上游依赖与 lints,默认启用 `gpui-fast` 与 `quickjs`;该版本的 `gpui-component` 无法基于上面改过的 Base 编译,因此移除 `components` 特性及其依赖,由嵌入方注册自己的组件目录。源码改动仅限:链接处理改用 Base 的 `on_link_activation` 并阻止 Base 默认打开 URL,由脚本回调或 shell 自身的 URL 规则决定;两个调用 `on_prepaint` 的辅助函数补上 `IntoElement` 约束;新增仅在设置环境 [...]
+`gpui-shell` 及其 `rquickjs-compat` 兼容层自同一上游版本复制,`upstream.json` 记录每个原始文件;集成测试大多依赖 `gpui-component`,因此未导入。兼容层清单声明 MIT 以对应其替代的 `rquickjs`,源码来自 GPUI Kit 仓库。工作区将两者作为成员,并把 crates.io 的 `rquickjs` 指向本地兼容层,使 LLRT 与 shell 共用同一 QuickJS VM。清单改为显式上游依赖与 lints,默认启用 `gpui-fast` 与 `quickjs`;该版本的 `gpui-component` 无法基于上面改过的 Base 编译,因此同时移除 `components`、`webview` 特性及其 `gpui-component`、`gpui-webview`、`wry` 依赖,由嵌入方注册自己的组件目录;本地投影不提供上游 WebView 组件。源码改动仅限:链接处理改用 Base 的 `on_link_activation` 并阻止 Base 默认打开 URL,由脚本回调或 [...]
 
 ## Desktop theme fixes (2026-10-16)
 
@@ -221,16 +221,23 @@ One addition to the script API of `gpui-shell`, a candidate for an upstream pull
 
 为 `gpui-shell` 的脚本 API 新增一项,可提交上游 PR:`InputState` 与 `TextareaState` 新增 `set_placeholder(text)`,在存活的状态上调用 gpui-base 的 `set_placeholder`。此前占位文字只能在 `new({ placeholder })` 时设定,应用在运行中切换语言时,必须连同监听器重建每个输入框,才能翻译空输入框显示的文字。
 
-## `<pre>` text kept as text (2026-10-17)
+## Explicit measured-list end requests (2026-10-18)
 
-One fix to Base's HTML minifier, submitted upstream as longbridge/gpui-kit#3570:
+The shell exposes `list.scroll_to_end(requestKey)` for an explicit local action such as an accepted message. A new non-null string scrolls once after applying the current item count; a stable key does not undo a later user scroll. Tail mode resumes following, while Normal mode only scrolls once. The request reuses the retained native list state and clears pending native scrolling before moving to the end. Native tests check row identity and in-row pixel offsets, including a final row tal [...]
 
-- `TextView::html` minifies the HTML and parses the result again. The text of a `<pre>` or `<textarea>` skips whitespace collapsing, and the minifier wrote it as it was decoded, so escaped markup such as `&lt;b&gt;` came back as an element and an `&amp;` as a bare `&`. That text is now escaped with the function the collapsing path uses. The parser also drops a line feed right after `<pre>` or `<textarea>`, so text that starts the element with a line feed (comments before it, which the mi [...]
+shell 通过 `list.scroll_to_end(requestKey)` 表达消息受理等本地动作的滚动意图。新的非空字符串在应用当前条目数后滚动一次;相同键不会撤销用户之后的上滚。Tail 模式恢复跟随,Normal 模式只滚动一次。请求复用原生列表状态,先清除待执行的原生滚动。原生测试检查行身份和行内像素位置,包含末行高于视口的情况。导入的 WebSocket 请求头测试先准备脚本运行时,再开始原有的有界服务端等待,保留全部真实请求头断言。
 
-为 Base 的 HTML minifier 修了一处,已提交上游 longbridge/gpui-kit#3570:`TextView::html` 先压缩 HTML 再重新解析。`<pre>` 与 `<textarea>` 中的文本不折叠空白,minifier 原样写出解码后的文本,因此 `&lt;b&gt;` 这类转义标记会变回元素,`&amp;` 变回单独的 `&`。现在这段文本用折叠路径的同一函数转义。解析器还会丢掉紧跟 `<pre>` 或 `<textarea>` 的换行,因此以换行开头的首段文本(其前被 minifier 略去的注释不计)会在前面多写一个换行,开头的空行在第二次解析后得以保留。
 
-## Explicit measured-list end requests (2026-10-18)
+## Upstream synchronization (2026-10-20)
 
-The shell exposes `list.scroll_to_end(requestKey)` for an explicit local action such as an accepted message. A new non-null string scrolls once after applying the current item count; a stable key does not undo a later user scroll. Tail mode resumes following, while Normal mode only scrolls once. The request reuses the retained native list state and clears pending native scrolling before moving to the end. Native tests check row identity and in-row pixel offsets, including a final row tal [...]
+Base and Shell now track official `main` revision `77ff6f794dca3bcee2e90977cedf12a64aed17e4`, after the `v0.8.0` tag. Both crates and all remaining Kit dependencies use 0.8.0 from that same Git revision. The bounded inventory contains 406 original files and retains the component-to-Base window-border mapping. Fast remains at 0.1.10 and support snapshots at 0.3.9. Shell's new Kit dependency keeps default features off and resolves to the single adapted Base through the Git source patch; Ma [...]
 
-shell 通过 `list.scroll_to_end(requestKey)` 表达消息受理等本地动作的滚动意图。新的非空字符串在应用当前条目数后滚动一次;相同键不会撤销用户之后的上滚。Tail 模式恢复跟随,Normal 模式只滚动一次。请求复用原生列表状态,先清除待执行的原生滚动。原生测试检查行身份和行内像素位置,包含末行高于视口的情况。导入的 WebSocket 请求头测试先准备脚本运行时,再开始原有的有界服务端等待,保留全部真实请求头断言。
+The four list/submit backports and HTML minifier fix now use upstream implementations, including the newer nested `<pre><code>` whitespace preservation. Full-cycle focus trapping, overscroll, input viewport fixes, source copying across styles and external-editor rendering are imported. Maka retains original input provenance, post-dispatch Tab defaults, exact IME slots, local themes, selection groups, retained hitbox geometry, context-menu custody, native cleanup and scoped Host ownership [...]
+
+Immediate copying uses the upstream current snapshot and full-update revision guard. Its immutable copy projections belong to the existing per-hitbox contribution, including native retained translation and pruning; no parallel frame or copy registry is added. External-editor links use an explicit rendering source and ordinary native fragmented click targets, while TextView retains its original occurrence and admission owner.
+
+Base 与 Shell 更新到官方 `main` 固定版本 `77ff6f794dca3bcee2e90977cedf12a64aed17e4`,位于 `v0.8.0` 标签之后。两者及其余 Kit 依赖统一为同一 Git revision 的 0.8.0;有限来源清单包含 406 个原始文件,并保留 Component 到 Base 的 window-border 映射。Fast 保持 0.1.10,支持快照保持 0.3.9。Shell 新增的 Kit 依赖关闭默认 features,并通过 Git source patch 共用唯一适配 Base;Maka 继续注册自己的组件目录。QuickJS、兼容层及 LLRT 的 VM 固定版本不变。
+
+四项列表/提交回移及 HTML minifier 修复现采用上游实现,包含嵌套 `<pre><code>` 空白保留的新修复。引入完整一圈的 focus trap、overscroll、输入视口修复、跨样式源码复制及外部编辑器呈现。Maka 原输入来源、分发后的 Tab 默认行为、准确 IME 槽位、局部主题、选区分组、保留帧 hitbox 几何、菜单 custody、原生清理和 scoped Host owner 均保留。脚本占位文字更新及 measured-list 末尾请求仍使用原实现,因为新版上游 API 并不提供等价行为。
+
+即时复制采用上游当前 snapshot 与 full-update revision 检查;不可变复制投影附着既有的逐 hitbox contribution,共用原生保留帧平移和 pruning,不新增并行 frame 或复制 registry。外部编辑器链接使用明确的呈现来源与普通原生分片点击 target;TextView 保留其原 occurrence 和准入 owner。
diff --git a/third-party/gpui-kit/crates/base/Cargo.toml b/third-party/gpui-kit/crates/base/Cargo.toml
index cb8d2871ed..2eab19d47d 100644
--- a/third-party/gpui-kit/crates/base/Cargo.toml
+++ b/third-party/gpui-kit/crates/base/Cargo.toml
@@ -8,7 +8,7 @@ exclude = ["examples/"]
 repository = "https://github.com/longbridge/gpui-kit"
 homepage = "https://gpui-kit.com"
 documentation = "https://docs.rs/gpui-base"
-version = "0.7.3"
+version = "0.8.0"
 publish = false
 edition = "2024"
 autoexamples = false
@@ -46,7 +46,7 @@ version = "=0.3.9"
 
 [dependencies.gpui-fast]
 package = "gpui-fast"
-version = "0.1.7"
+version = "=0.1.10"
 features = ["windows-manifest-provided-by-host"]
 optional = true
 
@@ -152,7 +152,7 @@ features = ["test-support"]
 
 [dev-dependencies.gpui-fast]
 package = "gpui-fast"
-version = "0.1.7"
+version = "=0.1.10"
 features = ["windows-manifest-provided-by-host", "test-support"]
 
 [dev-dependencies.gpui_platform]
@@ -162,7 +162,7 @@ features = ["font-kit", "x11", "wayland", "runtime_shaders"]
 
 [dev-dependencies.gpui-fast-platform]
 package = "gpui-fast-platform"
-version = "0.1.7"
+version = "=0.1.10"
 features = ["font-kit", "x11", "wayland", "runtime_shaders"]
 
 [dev-dependencies.reqwest_client]
diff --git a/third-party/gpui-kit/crates/base/src/a11y_disabled.rs b/third-party/gpui-kit/crates/base/src/a11y_disabled.rs
new file mode 100644
index 0000000000..061e2004b5
--- /dev/null
+++ b/third-party/gpui-kit/crates/base/src/a11y_disabled.rs
@@ -0,0 +1,304 @@
+//! Disabled semantics on a control's accessibility node.
+//!
+//! GPUI has no `aria_disabled` setter, so a disabled control would reach
+//! assistive technology as one that accepts input. [`A11yDisabledExt`] wraps the
+//! control's root element without adding a layout node, a focus stop or an
+//! accessibility node, and only marks the node it writes disabled.
+use gpui::{
+    App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, InteractiveElement,
+    Interactivity, IntoElement, LayoutId, Pixels, Window, accesskit,
+};
+
+/// A control's root element whose accessibility node may be marked disabled.
+pub(crate) struct A11yDisabled<E> {
+    inner: E,
+    disabled: bool,
+}
+
+impl<E> A11yDisabled<E> {
+    pub(crate) fn new(inner: E, disabled: bool) -> Self {
+        Self { inner, disabled }
+    }
+}
+
+/// Marks a control's accessibility node disabled while `disabled` is true.
+///
+/// Call it at the end of the control's builder chain. With `test-support`, the
+/// wrapper goes inside the observation, so its snapshot reports the flag too.
+pub(crate) trait A11yDisabledExt: Sized {
+    type Output: IntoElement;
+
+    fn a11y_disabled(self, disabled: bool) -> Self::Output;
+}
+
+#[cfg(not(feature = "test-support"))]
+impl<E: Element + InteractiveElement> A11yDisabledExt for E {
+    type Output = A11yDisabled<E>;
+
+    fn a11y_disabled(self, disabled: bool) -> Self::Output {
+        A11yDisabled::new(self, disabled)
+    }
+}
+
+#[cfg(feature = "test-support")]
+impl<E: Element + InteractiveElement> A11yDisabledExt for gpui::Stateful<E> {
+    type Output = A11yDisabled<Self>;
+
+    fn a11y_disabled(self, disabled: bool) -> Self::Output {
+        A11yDisabled::new(self, disabled)
+    }
+}
+
+#[cfg(feature = "test-support")]
+impl<E: Element<PrepaintState = Option<gpui::Hitbox>> + InteractiveElement> A11yDisabledExt
+    for crate::test_support::Observed<E>
+{
+    type Output = crate::test_support::Observed<A11yDisabled<E>>;
+
+    fn a11y_disabled(self, disabled: bool) -> Self::Output {
+        self.map_inner(|inner| A11yDisabled::new(inner, disabled))
+    }
+}
+
+impl<E: Element> IntoElement for A11yDisabled<E> {
+    type Element = Self;
+
+    fn into_element(self) -> Self {
+        self
+    }
+}
+
+impl<E: InteractiveElement> InteractiveElement for A11yDisabled<E> {
+    fn interactivity(&mut self) -> &mut Interactivity {
+        self.inner.interactivity()
+    }
+}
+
+impl<E: Element> Element for A11yDisabled<E> {
+    type RequestLayoutState = E::RequestLayoutState;
+    type PrepaintState = E::PrepaintState;
+
+    fn id(&self) -> Option<ElementId> {
+        self.inner.id()
+    }
+
+    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
+        self.inner.source_location()
+    }
+
+    fn request_layout(
+        &mut self,
+        id: Option<&GlobalElementId>,
+        inspector: Option<&InspectorElementId>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> (LayoutId, Self::RequestLayoutState) {
+        self.inner.request_layout(id, inspector, window, cx)
+    }
+
+    fn prepaint(
+        &mut self,
+        id: Option<&GlobalElementId>,
+        inspector: Option<&InspectorElementId>,
+        bounds: Bounds<Pixels>,
+        layout: &mut Self::RequestLayoutState,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Self::PrepaintState {
+        self.inner
+            .prepaint(id, inspector, bounds, layout, window, cx)
+    }
+
+    fn paint(
+        &mut self,
+        id: Option<&GlobalElementId>,
+        inspector: Option<&InspectorElementId>,
+        bounds: Bounds<Pixels>,
+        layout: &mut Self::RequestLayoutState,
+        prepaint: &mut Self::PrepaintState,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        self.inner
+            .paint(id, inspector, bounds, layout, prepaint, window, cx)
+    }
+
+    fn a11y_role(&self) -> Option<gpui::Role> {
+        self.inner.a11y_role()
+    }
+
+    fn write_a11y_info(&self, node: &mut accesskit::Node) {
+        self.inner.write_a11y_info(node);
+        if self.disabled {
+            node.set_disabled();
+        }
+    }
+
+    fn a11y_synthetic_children(
+        &mut self,
+        prepaint: &mut Self::PrepaintState,
+        builder: &mut gpui::A11ySubtreeBuilder,
+    ) {
+        self.inner.a11y_synthetic_children(prepaint, builder);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::sync::{Arc, Mutex};
+
+    use gpui::{AppContext as _, Context, IntoElement, Render, RenderOnce, TestAppContext, canvas};
+
+    use super::*;
+    use crate::{
+        Button, Checkbox, DatePicker, Link, Radio, Select, Slider, Switch, Tab, TimeField,
+        TimeFieldState, Toggle, slider::SliderState,
+    };
+
+    /// The accessibility node a rendered control writes.
+    fn node_of(element: impl IntoElement) -> accesskit::Node {
+        let element = element.into_element();
+        let mut node = accesskit::Node::new(element.a11y_role().unwrap_or(gpui::Role::Unknown));
+        element.write_a11y_info(&mut node);
+        node
+    }
+
+    type Captured = Arc<Mutex<Vec<(&'static str, bool, accesskit::Node)>>>;
+
+    struct Probe {
+        captured: Captured,
+    }
+
+    impl Render for Probe {
+        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+            let captured = self.captured.clone();
+            canvas(
+                move |_, window, cx| {
+                    let slider = cx.new(|_| SliderState::new());
+                    let time = cx.new(|cx| TimeFieldState::new(window, cx));
+                    let focus = cx.focus_handle();
+                    let mut nodes = captured.lock().unwrap();
+                    nodes.clear();
+                    for disabled in [false, true] {
+                        nodes.push((
+                            "button",
+                            disabled,
+                            node_of(Button::new("button").disabled(disabled).render(window, cx)),
+                        ));
+                        nodes.push((
+                            "link",
+                            disabled,
+                            node_of(Link::new("link").disabled(disabled).render(window, cx)),
+                        ));
+                        nodes.push((
+                            "switch",
+                            disabled,
+                            node_of(Switch::new("switch").disabled(disabled).render(window, cx)),
+                        ));
+                        nodes.push((
+                            "radio",
+                            disabled,
+                            node_of(Radio::new("radio").disabled(disabled).render(window, cx)),
+                        ));
+                        nodes.push((
+                            "toggle",
+                            disabled,
+                            node_of(Toggle::new("toggle").disabled(disabled).render(window, cx)),
+                        ));
+                        nodes.push((
+                            "checkbox",
+                            disabled,
+                            node_of(
+                                Checkbox::new("checkbox")
+                                    .disabled(disabled)
+                                    .render(window, cx),
+                            ),
+                        ));
+                        nodes.push((
+                            "select",
+                            disabled,
+                            node_of(Select::new("select").disabled(disabled).render(window, cx)),
+                        ));
+                        nodes.push((
+                            "tab",
+                            disabled,
+                            node_of(Tab::new("tab").disabled(disabled).render(window, cx)),
+                        ));
+                        nodes.push((
+                            "date_picker",
+                            disabled,
+                            node_of(
+                                DatePicker::new("date-picker", &focus)
+                                    .disabled(disabled)
+                                    .render(window, cx),
+                            ),
+                        ));
+                        nodes.push((
+                            "time_field",
+                            disabled,
+                            node_of(
+                                TimeField::new("time-field", &time)
+                                    .disabled(disabled)
+                                    .render(window, cx),
+                            ),
+                        ));
+                        nodes.push((
+                            "slider",
+                            disabled,
+                            node_of(Slider::new(&slider).disabled(disabled).render(window, cx)),
+                        ));
+                    }
+                },
+                |_, _, _, _| {},
+            )
+        }
+    }
+
+    #[gpui::test]
+    fn a_disabled_control_marks_its_node_disabled(cx: &mut TestAppContext) {
+        let captured: Captured = Arc::default();
+        let (_, cx) = cx.add_window_view({
+            let captured = captured.clone();
+            move |_, _| Probe { captured }
+        });
+        cx.update(|window, cx| window.draw(cx).clear(cx));
+
+        let nodes = captured.lock().unwrap();
+        assert_eq!(nodes.len(), 22);
+        for (name, disabled, node) in nodes.iter() {
+            assert_ne!(node.role(), gpui::Role::Unknown, "{name} wrote no node");
+            assert_eq!(node.is_disabled(), *disabled, "{name}, disabled {disabled}");
+            if *name == "slider" {
+                // Nor does assistive technology move a disabled slider.
+                assert_eq!(
+                    node.supports_action(accesskit::Action::Increment),
+                    !disabled,
+                    "slider, disabled {disabled}"
+                );
+            }
+        }
+    }
+
+    struct Observed {
+        disabled: bool,
+    }
+
+    impl Render for Observed {
+        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+            Switch::new("switch").disabled(self.disabled)
+        }
+    }
+
+    #[gpui::test]
+    fn the_test_support_snapshot_sees_the_flag(cx: &mut TestAppContext) {
+        for disabled in [false, true] {
+            let (_, cx) = cx.add_window_view(move |_, _| Observed { disabled });
+            cx.update(|window, cx| {
+                window.draw(cx).clear(cx);
+                let snapshot = crate::test_support::find(window, &[], &"switch".into())
+                    .expect("switch is observed");
+                assert_eq!(snapshot.disabled(), disabled.then_some(true));
+            });
+        }
+    }
+}
diff --git a/third-party/gpui-kit/crates/base/src/button.rs b/third-party/gpui-kit/crates/base/src/button.rs
index 84a870c454..972cb3d17c 100644
--- a/third-party/gpui-kit/crates/base/src/button.rs
+++ b/third-party/gpui-kit/crates/base/src/button.rs
@@ -8,7 +8,10 @@ use gpui::{
 };
 use smallvec::SmallVec;
 
-use crate::{RoleOverride, Selectable, StateStyle, StyledExt as _, TestSupportExt as _};
+use crate::{
+    RoleOverride, Selectable, StateStyle, StyledExt as _, TestSupportExt as _,
+    a11y_disabled::A11yDisabledExt as _,
+};
 
 type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
 
@@ -254,6 +257,7 @@ impl RenderOnce for Button {
             )
             .children(self.children)
             .refine_style(&style)
+            .a11y_disabled(disabled)
     }
 }
 
@@ -547,10 +551,7 @@ mod tests {
 
         assert_eq!(disabled.role(), Role::Button);
         assert!(!disabled.supports_action(accesskit::Action::Click));
-
-        // GPUI's current StatefulInteractiveElement interface has no
-        // aria-disabled setter even though AccessKit can represent it. This
-        // assertion records that upstream gap instead of claiming support.
-        assert!(!disabled.is_disabled());
+        assert!(!enabled.is_disabled());
+        assert!(disabled.is_disabled());
     }
 }
diff --git a/third-party/gpui-kit/crates/base/src/checkbox.rs b/third-party/gpui-kit/crates/base/src/checkbox.rs
index 670f22516f..6e29b6193c 100644
--- a/third-party/gpui-kit/crates/base/src/checkbox.rs
+++ b/third-party/gpui-kit/crates/base/src/checkbox.rs
@@ -8,7 +8,10 @@ use gpui::{
 };
 use smallvec::SmallVec;
 
-use crate::{RoleOverride, StateStyle, StyledExt as _, TestSupportExt as _};
+use crate::{
+    RoleOverride, StateStyle, StyledExt as _, TestSupportExt as _,
+    a11y_disabled::A11yDisabledExt as _,
+};
 
 type ChangeHandler = Rc<dyn Fn(CheckboxState, &ClickEvent, &mut Window, &mut App)>;
 
@@ -126,6 +129,9 @@ impl Checkbox {
     }
 
     /// Sets the label exposed to accessibility clients.
+    ///
+    /// A checkbox without text children has no accessible name, so set this
+    /// whenever it is drawn without visible text.
     pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
         self.accessibility_label = Some(label.into());
         self
@@ -395,6 +401,7 @@ impl RenderOnce for Checkbox {
             )
             .children(self.children)
             .refine_style(&style)
+            .a11y_disabled(disabled)
     }
 }
 
diff --git a/third-party/gpui-kit/crates/base/src/date_picker.rs b/third-party/gpui-kit/crates/base/src/date_picker.rs
index 9b2433b91a..fb87e8c268 100644
--- a/third-party/gpui-kit/crates/base/src/date_picker.rs
+++ b/third-party/gpui-kit/crates/base/src/date_picker.rs
@@ -9,6 +9,7 @@ use gpui::{
 
 use crate::{
     StyledExt as _,
+    a11y_disabled::A11yDisabledExt as _,
     actions::{Cancel, Confirm},
 };
 
@@ -108,5 +109,6 @@ impl RenderOnce for DatePicker {
             })
             .refine_style(&self.style)
             .children(self.children)
+            .a11y_disabled(disabled)
     }
 }
diff --git a/third-party/gpui-kit/crates/base/src/dock/fixtures/legacy_empty_stack_panel.json b/third-party/gpui-kit/crates/base/src/dock/fixtures/legacy_empty_stack_panel.json
new file mode 100644
index 0000000000..7d5e24ca3e
--- /dev/null
+++ b/third-party/gpui-kit/crates/base/src/dock/fixtures/legacy_empty_stack_panel.json
@@ -0,0 +1,3 @@
+{
+  "center": { "panel_name": "StackPanel", "children": [], "info": { "panel": null } }
+}
diff --git a/third-party/gpui-kit/crates/base/src/dock/fixtures/placeholder_wrapped_empty_stack.json b/third-party/gpui-kit/crates/base/src/dock/fixtures/placeholder_wrapped_empty_stack.json
new file mode 100644
index 0000000000..204397a3aa
--- /dev/null
+++ b/third-party/gpui-kit/crates/base/src/dock/fixtures/placeholder_wrapped_empty_stack.json
@@ -0,0 +1,16 @@
+{
+  "center": {
+    "panel_name": "StackPanel",
+    "children": [
+      {
+        "panel_name": "TabPanel",
+        "children": [
+          { "panel_name": "StackPanel", "children": [], "info": { "panel": null } },
+          { "panel_name": "Alpha", "children": [], "info": { "panel": null } }
+        ],
+        "info": { "tabs": { "active_index": 0 } }
+      }
+    ],
+    "info": { "stack": { "sizes": [0.0], "axis": 0 } }
+  }
+}
diff --git a/third-party/gpui-kit/crates/base/src/dock/state_convert.rs b/third-party/gpui-kit/crates/base/src/dock/state_convert.rs
index 6857ed6b06..9b105b813c 100644
--- a/third-party/gpui-kit/crates/base/src/dock/state_convert.rs
+++ b/third-party/gpui-kit/crates/base/src/dock/state_convert.rs
@@ -88,7 +88,11 @@ impl PaneTree {
     ///   old reader had no such rule: it looked "TabPanel" up in the panel
     ///   registry, found nothing, and rendered an `InvalidPanel` placeholder
     ///   where an empty tab group belonged. This rule is a genuine fix, not a
-    ///   preserved behavior.
+    ///   preserved behavior;
+    /// - a node named `StackPanel` carrying `PanelInfo::Panel` is the same
+    ///   defect in the old split writer, and is read as an empty split. Inside
+    ///   a tab group it is dropped: a placeholder built from it would dump the
+    ///   defective state back unchanged, so the layout could never heal.
     pub fn from_state(
         state: &PanelState,
         root_kind: RootKind,
@@ -155,9 +159,17 @@ fn build_node(tree: &mut PaneTree, state: &PanelState, builder: &mut dyn PanelBu
                 },
             )
         }
+        // A container name carrying a leaf info means the writer that produced
+        // this file had the empty-container defect.
+        PanelInfo::Panel(_) if state.panel_name == STACK_PANEL_NAME => PaneNode::new(
+            id,
+            NodeKind::Split {
+                axis: Axis::Horizontal,
+                children: Vec::new(),
+                sizes: Vec::new(),
+            },
+        ),
         PanelInfo::Panel(_) => {
-            // A container name carrying a leaf info means the writer that
-            // produced this file had the empty-group defect.
             let panels = if state.panel_name == TAB_PANEL_NAME {
                 Vec::new()
             } else {
@@ -174,13 +186,17 @@ fn build_node(tree: &mut PaneTree, state: &PanelState, builder: &mut dyn PanelBu
     }
 }
 
+fn is_container_name(panel_name: &str) -> bool {
+    panel_name == TAB_PANEL_NAME || panel_name == STACK_PANEL_NAME
+}
+
 /// Flatten one level of tab nesting, which the old writer could produce.
 fn collect_tab_panels(children: &[PanelState], builder: &mut dyn PanelBuilder) -> Vec<PanelId> {
     children
         .iter()
         .flat_map(|child| match &child.info {
             PanelInfo::Tabs { .. } => collect_tab_panels(&child.children, builder),
-            PanelInfo::Panel(_) if child.panel_name == TAB_PANEL_NAME => Vec::new(),
+            PanelInfo::Panel(_) if is_container_name(&child.panel_name) => Vec::new(),
             _ => vec![builder.build(child, &child.info)],
         })
         .collect()
@@ -424,6 +440,8 @@ mod tests {
             include_str!("fixtures/legacy_empty_tab_group.json"),
             include_str!("fixtures/unregistered_panel.json"),
             include_str!("fixtures/zero_size_sentinel.json"),
+            include_str!("fixtures/legacy_empty_stack_panel.json"),
+            include_str!("fixtures/placeholder_wrapped_empty_stack.json"),
         ] {
             let once = canonicalize(json);
             let twice = {
@@ -458,6 +476,39 @@ mod tests {
         assert!(state.children.is_empty());
     }
 
+    #[test]
+    fn the_legacy_empty_stack_panel_is_read_as_an_empty_split() {
+        let json = include_str!("fixtures/legacy_empty_stack_panel.json");
+        let state: DockAreaState = serde_json::from_str(json).unwrap();
+        let mut panels = PreservingPanels::default();
+        let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels);
+
+        assert!(panels.states.is_empty(), "no placeholder is built for it");
+        let state = tree.to_state(&panels);
+        assert_eq!(state.panel_name, "StackPanel");
+        assert!(matches!(state.info, PanelInfo::Stack { .. }));
+        assert!(state.children.is_empty());
+    }
+
+    /// The shape a reader without the rule above saved back: the placeholder
+    /// it built sat in a tab group and dumped the defective state unchanged.
+    #[test]
+    fn an_empty_stack_panel_saved_inside_a_tab_group_is_dropped() {
+        let state = canonicalize(include_str!(
+            "fixtures/placeholder_wrapped_empty_stack.json"
+        ));
+        let group = &state.children[0];
+
+        assert_eq!(group.panel_name, "TabPanel");
+        assert_eq!(group.info, PanelInfo::tabs(0));
+        let names: Vec<_> = group
+            .children
+            .iter()
+            .map(|child| child.panel_name.as_str())
+            .collect();
+        assert_eq!(names, ["Alpha"]);
+    }
+
     #[test]
     fn nested_same_axis_splits_are_flattened_and_single_child_splits_collapse() {
         let state = canonicalize(include_str!("fixtures/nested_splits.json"));
diff --git a/third-party/gpui-kit/crates/base/src/focus_trap.rs b/third-party/gpui-kit/crates/base/src/focus_trap.rs
index b7270ccd6e..bb311f6764 100644
--- a/third-party/gpui-kit/crates/base/src/focus_trap.rs
+++ b/third-party/gpui-kit/crates/base/src/focus_trap.rs
@@ -82,16 +82,25 @@ impl FocusTrapManager {
 
     /// Find which focus trap contains the currently focused element
     fn find_active_trap(window: &Window, cx: &App) -> Option<FocusHandle> {
-        for (_id, container_handle) in Self::global(cx).traps.iter() {
+        let mut active: Option<FocusHandle> = None;
+        for container_handle in Self::global(cx).traps.values() {
             let Some(container) = container_handle.upgrade() else {
                 continue;
             };
 
-            if container.contains_focused(window, cx) {
-                return Some(container.clone());
+            if container.contains_focused(window, cx)
+                && active
+                    .as_ref()
+                    .is_none_or(|current| current.contains(&container, window))
+            {
+                // A child scope must confine navigation even if its ancestor
+                // was registered first (or visited first by the HashMap).
+                // Use the rendered focus tree so closing and reopening a
+                // retained scope cannot leave registration order in control.
+                active = Some(container);
             }
         }
-        None
+        active
     }
 
     /// Cleanup any traps with dropped handles
@@ -242,3 +251,37 @@ impl<E: InteractiveElement + ParentElement + Styled + Element + 'static> Element
 pub fn active_focus_trap(window: &Window, cx: &App) -> Option<FocusHandle> {
     FocusTrapManager::find_active_trap(window, cx)
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::TestSupportExt as _;
+    use gpui::{Role, TestAppContext, accesskit, div};
+
+    #[gpui::test]
+    fn trap_exports_inner_semantics_through_the_outer_native_hooks(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            for (role, name) in [
+                (Role::Dialog, "Edit server"),
+                (Role::AlertDialog, "Delete server?"),
+            ] {
+                let trap = div()
+                    .id("inner-host")
+                    .test_support()
+                    .role(role)
+                    .aria_label(name)
+                    .aria_description("Server settings")
+                    .focus_trap("outer-trap", &cx.focus_handle());
+                // GPUI calls hooks on the outer Element. An observed inner
+                // div alone can claim these semantics while the real element
+                // silently drops them. Check the exact native boundary.
+                assert_eq!(Element::id(&trap), Some("outer-trap".into()));
+                assert_eq!(trap.a11y_role(), Some(role));
+                let mut node = accesskit::Node::new(role);
+                trap.write_a11y_info(&mut node);
+                assert_eq!(node.label(), Some(name));
+                assert_eq!(node.description(), Some("Server settings"));
+            }
+        });
+    }
+}
diff --git a/third-party/gpui-kit/crates/base/src/input/base/blink_cursor.rs b/third-party/gpui-kit/crates/base/src/input/base/blink_cursor.rs
index dc36700910..b41f2bb5a3 100644
--- a/third-party/gpui-kit/crates/base/src/input/base/blink_cursor.rs
+++ b/third-party/gpui-kit/crates/base/src/input/base/blink_cursor.rs
@@ -4,11 +4,7 @@ use instant::Duration;
 static INTERVAL: Duration = Duration::from_millis(500);
 static PAUSE_DELAY: Duration = Duration::from_millis(300);
 
-// On Windows, Linux, we should use integer to avoid blurry cursor.
-#[cfg(not(target_os = "macos"))]
-pub(super) const CURSOR_WIDTH: Pixels = px(2.);
-#[cfg(target_os = "macos")]
-pub(super) const CURSOR_WIDTH: Pixels = px(1.5);
+pub(super) const CURSOR_WIDTH: Pixels = px(1.);
 
 /// To manage the Input cursor blinking.
 ///
@@ -34,9 +30,17 @@ impl BlinkCursor {
         }
     }
 
-    /// Start the blinking
+    /// Start the blinking from a visible cursor.
+    ///
+    /// A single focus can start the cursor more than once: `InputState::focus`
+    /// starts it and then the focus observer starts it again, and window
+    /// activation starts it too. Resuming the running cycle would toggle the
+    /// cursor that was just shown off again, so every start restarts the cycle.
     pub(crate) fn start(&mut self, cx: &mut Context<Self>) {
-        self.blink(self.epoch, cx);
+        self.paused = false;
+        self.visible = false;
+        let epoch = self.next_epoch();
+        self.blink(epoch, cx);
     }
 
     /// Stop the blinking and clear the blink state, so the next [`Self::start`]
@@ -146,6 +150,28 @@ mod tests {
         assert!(cursor.read_with(cx, |cursor, _| cursor.visible()));
     }
 
+    #[gpui::test]
+    fn starting_a_blinking_cursor_keeps_it_visible(cx: &mut TestAppContext) {
+        let cursor = cx.new(|_| BlinkCursor::new());
+
+        // Clicking into an input starts the cursor from `focus` and again from
+        // the focus observer; the second start must not hide the new cursor.
+        cursor.update(cx, |cursor, cx| cursor.start(cx));
+        cursor.update(cx, |cursor, cx| cursor.start(cx));
+        cx.run_until_parked();
+        assert!(cursor.read_with(cx, |cursor, _| cursor.visible()));
+
+        // The restart drops the first cycle's timer, so the cursor stays
+        // visible for a full interval before it blinks off.
+        cx.executor()
+            .advance_clock(INTERVAL - Duration::from_millis(1));
+        cx.run_until_parked();
+        assert!(cursor.read_with(cx, |cursor, _| cursor.visible()));
+        cx.executor().advance_clock(Duration::from_millis(1));
+        cx.run_until_parked();
+        assert!(!cursor.read_with(cx, |cursor, _| cursor.visible()));
+    }
+
     #[gpui::test]
     fn pausing_a_cursor_that_is_not_blinking_does_not_start_it(cx: &mut TestAppContext) {
         // Never focused, so `start` was never called: the epoch is still zero.
diff --git a/third-party/gpui-kit/crates/base/src/input/base/element.rs b/third-party/gpui-kit/crates/base/src/input/base/element.rs
index fdd80a2fa7..c45c583b21 100644
--- a/third-party/gpui-kit/crates/base/src/input/base/element.rs
+++ b/third-party/gpui-kit/crates/base/src/input/base/element.rs
@@ -118,6 +118,8 @@ fn compose_decoration_collections<'a>(
 struct EditorScrollbarLayout {
     bounds: Bounds<Pixels>,
     scroll_size: Size<Pixels>,
+    // Excludes editor padding from scroll range while the track still includes it.
+    scroll_viewport_size: Size<Pixels>,
 }
 
 /// What the unwrapped width of the longest line depends on. While it holds,
@@ -174,21 +176,24 @@ impl EditorScrollbarLayout {
             paddings.left + line_number_width - LINE_NUMBER_RIGHT_MARGIN
         };
 
-        Self {
-            bounds: Bounds::new(
-                point(
-                    input_bounds.origin.x + left,
-                    input_bounds.origin.y - paddings.top,
-                ),
-                size(
-                    input_bounds.size.width - left + paddings.right,
-                    input_bounds.size.height + paddings.top + paddings.bottom,
-                ),
+        let bounds = Bounds::new(
+            point(
+                input_bounds.origin.x + left,
+                input_bounds.origin.y - paddings.top,
             ),
+            size(
+                input_bounds.size.width - left + paddings.right,
+                input_bounds.size.height + paddings.top + paddings.bottom,
+            ),
+        );
+
+        Self {
+            bounds,
             scroll_size: size(
                 scroll_size.width - left + paddings.right + RIGHT_MARGIN,
                 scroll_size.height,
             ),
+            scroll_viewport_size: size(bounds.size.width, input_bounds.size.height),
         }
     }
 }
@@ -263,6 +268,7 @@ impl<M: InputModeKind> Element for EditorScrollbar<M> {
             Scrollbar::vertical(&scroll_handle)
         }
         .viewport_bounds(snapshot.layout.bounds)
+        .scroll_viewport_size(snapshot.layout.scroll_viewport_size)
         .scroll_size(snapshot.layout.scroll_size);
         if let Some(theme) = &state.scrollbar_theme {
             scrollbar = scrollbar.theme(theme.clone());
@@ -406,6 +412,24 @@ pub(super) fn cursor_surrounding_padding(
     raw.min(viewport_half)
 }
 
+/// Reveal the whole visual row without changing the usual edge clearance.
+/// Reserve the row's height below its insertion position before allocating
+/// top padding. A viewport shorter than a row can only align its start.
+pub(super) fn reveal_row(
+    offset: Pixels,
+    row_top: Pixels,
+    line_height: Pixels,
+    viewport_height: Pixels,
+    edge_padding: Pixels,
+) -> Pixels {
+    let row_height = line_height.min(viewport_height);
+    let bottom = edge_padding.max(row_height).min(viewport_height);
+    let top = edge_padding.min(viewport_height - bottom);
+    offset
+        .clamp(top - row_top, viewport_height - bottom - row_top)
+        .min(px(0.))
+}
+
 /// Pixel height of the empty area below the last line in the editor's
 /// scrollable region. Backs [`InputBaseState::scroll_beyond_last_line`].
 ///
@@ -455,6 +479,23 @@ impl<M: InputModeKind> TextElement<M> {
     }
 
     fn paint_mouse_listeners(&mut self, hitbox: &Hitbox, window: &mut Window, _: &mut App) {
+        // Text with room to scroll keeps the wheel from the scroll areas
+        // around it; its own listener scrolls it in the bubble phase.
+        window.on_mouse_event({
+            let state = self.state.clone();
+            let hitbox = hitbox.id;
+            move |event: &gpui::ScrollWheelEvent, phase, window, cx| {
+                if phase.capture()
+                    && hitbox.should_handle_scroll(window)
+                    && state
+                        .read(cx)
+                        .takes_scroll(event.delta.pixel_delta(window.line_height()))
+                {
+                    crate::scroll_bounce::claim_scroll(hitbox, cx);
+                }
+            }
+        });
+
         // Every touch is offered as a drag first; that is how a tap's mouse
         // events are later told apart from a mouse's.
         window.on_mouse_event(move |event: &TouchDragEvent, phase, _, cx| {
@@ -526,9 +567,9 @@ impl<M: InputModeKind> TextElement<M> {
         last_layout: &LastLayout,
         bounds: &mut Bounds<Pixels>,
         scroll_size: Size<Pixels>,
-        _: &mut Window,
+        window: &mut Window,
         cx: &mut App,
-    ) -> (Vec<CursorRenderInfo>, Point<Pixels>, Option<usize>) {
+    ) -> (Vec<CursorRenderInfo>, Point<Pixels>, Option<usize>, bool) {
         let state = self.state.read(cx);
 
         let line_height = last_layout.line_height;
@@ -539,6 +580,7 @@ impl<M: InputModeKind> TextElement<M> {
         let active_id = state.active_selection().id;
         let mut scroll_offset = state.scroll_handle.offset();
         let mut current_row = None;
+        let mut resize_reveal_owned = false;
         let mut cursor_infos: Vec<CursorRenderInfo> = Vec::with_capacity(state.selections.len());
 
         // Padding kept between the cursor and the viewport's top/bottom
@@ -550,6 +592,35 @@ impl<M: InputModeKind> TextElement<M> {
             line_height,
         );
 
+        // Keep a visible, focused caret visible when its viewport reflows, but
+        // do not undo deliberate scrolling away from an unchanged selection.
+        let resized = state.last_bounds.is_some_and(|old| old.size != bounds.size)
+            || state
+                .last_layout
+                .as_ref()
+                .is_some_and(|old| old.line_height != line_height);
+        let reveal_on_resize = resized
+            && state.deferred_scroll_offset.is_none()
+            && state.focus_handle.is_focused(window)
+            && state.prepaint_caret_geometry.is_some_and(|old| {
+                // An unshaped caret has provisional x until the notified
+                // repaint. Consecutive resize frames retain ownership only
+                // while no intervening scroll changed the viewport.
+                if old.resize_reveal_owned {
+                    return old.scroll_offset() == scroll_offset;
+                }
+                let caret = old.cursor_bounds();
+                let viewport = old.input_bounds();
+                let top = caret.top() + scroll_offset.y;
+                // Cached x already contains its painted scroll; account only
+                // for a wheel offset change since that geometry was produced.
+                let left = caret.left() + scroll_offset.x - old.scroll_offset().x;
+                top >= viewport.top()
+                    && top + caret.size.height <= viewport.bottom()
+                    && left >= viewport.left()
+                    && left + caret.size.width <= viewport.right()
+            });
+
         // Resolve a cursor or selection endpoint to a content-space position.
         let visible_buffer_lines = &last_layout.visible_buffer_lines;
         let caret_for = |row: usize, offset: usize, affinity: bool| -> Point<Pixels> {
@@ -565,7 +636,13 @@ impl<M: InputModeKind> TextElement<M> {
                     return line_origin + pos;
                 }
             }
-            line_origin
+            if state.masked {
+                return line_origin;
+            }
+            point(
+                px(0.),
+                line_height * state.display_map.offset_to_display_row(offset, affinity),
+            )
         };
 
         let cursor_height = 0.85 * line_height;
@@ -612,50 +689,43 @@ impl<M: InputModeKind> TextElement<M> {
 
                 let selection_changed = state.last_selected_range != Some(selected_range);
                 let auto_scrolling = state.auto_scroll.is_active();
-                if selection_changed && !is_selected_all {
-                    // For Right alignment use 0 margin: cursor is clamped to bounds separately,
-                    // so we never scroll the text for cursor-at-edge, avoiding a first-click jump.
-                    let safety_margin = match last_layout.text_align {
-                        TextAlign::Left => RIGHT_MARGIN,
-                        TextAlign::Right => px(0.),
-                        TextAlign::Center => CURSOR_WIDTH,
-                    };
-
-                    scroll_offset.x = if scroll_offset.x + cursor_pos.x
-                        > (bounds.size.width - line_number_width - safety_margin)
-                    {
-                        // cursor is out of right
-                        bounds.size.width - line_number_width - safety_margin - cursor_pos.x
-                    } else if scroll_offset.x + cursor_pos.x < px(0.) {
-                        // cursor is out of left
-                        -cursor_pos.x
-                    } else {
-                        scroll_offset.x
-                    };
+                if (selection_changed || reveal_on_resize) && !is_selected_all {
+                    // Resize-follow owns only vertical visibility. An unshaped
+                    // target has no reliable x; retain the existing x offset.
+                    if selection_changed {
+                        // Right-aligned carets are clamped instead of scrolling.
+                        let safety_margin = match last_layout.text_align {
+                            TextAlign::Left => RIGHT_MARGIN,
+                            TextAlign::Right => px(0.),
+                            TextAlign::Center => CURSOR_WIDTH,
+                        };
+                        scroll_offset.x = if scroll_offset.x + cursor_pos.x
+                            > (bounds.size.width - line_number_width - safety_margin)
+                        {
+                            bounds.size.width - line_number_width - safety_margin - cursor_pos.x
+                        } else if scroll_offset.x + cursor_pos.x < px(0.) {
+                            -cursor_pos.x
+                        } else {
+                            scroll_offset.x
+                        };
+                    }
 
                     // Vertical cursor-follow is suppressed while auto-scroll manages the y axis,
                     // to prevent fighting the background scroll task.
                     if !auto_scrolling {
-                        // Scroll straight to the caret's line. This runs only on
-                        // the frame the selection changed, so a one-line step
-                        // would leave a far-off caret (e.g. after typing at the
-                        // end of a long paste) outside the viewport.
-                        scroll_offset.y = if scroll_offset.y + cursor_pos.y
-                            > bounds.size.height - top_bottom_margin
-                        {
-                            // cursor is out of bottom
-                            bounds.size.height - top_bottom_margin - cursor_pos.y
-                        } else if scroll_offset.y + cursor_pos.y < top_bottom_margin {
-                            // cursor is out of top
-                            (top_bottom_margin - cursor_pos.y).min(px(0.))
-                        } else {
-                            scroll_offset.y
-                        };
+                        scroll_offset.y = reveal_row(
+                            scroll_offset.y,
+                            cursor_pos.y,
+                            line_height,
+                            bounds.size.height,
+                            top_bottom_margin,
+                        );
+                        resize_reveal_owned = reveal_on_resize;
                     }
 
                     // For selection to move scroll
                     if selection.reversed {
-                        if scroll_offset.x + cursor_start.x < px(0.) {
+                        if selection_changed && scroll_offset.x + cursor_start.x < px(0.) {
                             // selection start is out of left
                             scroll_offset.x = -cursor_start.x;
                         }
@@ -666,7 +736,7 @@ impl<M: InputModeKind> TextElement<M> {
                     } else {
                         // TODO: Consider to remove this part,
                         // maybe is not necessary (But selection_reversed is needed).
-                        if scroll_offset.x + cursor_end.x <= px(0.) {
+                        if selection_changed && scroll_offset.x + cursor_end.x <= px(0.) {
                             // selection end is out of left
                             scroll_offset.x = -cursor_end.x;
                         }
@@ -714,7 +784,12 @@ impl<M: InputModeKind> TextElement<M> {
 
         bounds.origin = bounds.origin + scroll_offset;
 
-        (cursor_infos, scroll_offset, current_row)
+        (
+            cursor_infos,
+            scroll_offset,
+            current_row,
+            resize_reveal_owned,
+        )
     }
 
     /// Layout the match range to a Path.
@@ -2327,6 +2402,17 @@ impl PrepaintState {
     }
 }
 
+/// Align a caret to whole device pixels.
+///
+/// Text positions are fractional, and a one-pixel caret at a fractional x is
+/// spread over two device pixels by antialiasing, which draws it faint and
+/// thin. Rounding its edges to the device grid keeps every caret crisp.
+fn snap_caret_to_device_pixels(bounds: Bounds<Pixels>, scale_factor: f32) -> Bounds<Pixels> {
+    let x = (bounds.origin.x * scale_factor).round() / scale_factor;
+    let width = (bounds.size.width * scale_factor).round().max(px(1.)) / scale_factor;
+    Bounds::new(point(x, bounds.origin.y), size(width, bounds.size.height))
+}
+
 impl<M: InputModeKind> IntoElement for TextElement<M> {
     type Element = Self;
 
@@ -2522,7 +2608,10 @@ impl<M: InputModeKind> Element for TextElement<M> {
             window.text_system().layout_width(font_id, text_size, ' ')
         };
 
-        self.state.update(cx, |state, cx| {
+        // Text stretched past an end is drawn that far from where it scrolled
+        // to, inside the frame that clips it, and springs back over the next
+        // frames. Scrolling and everything measured from it stay exact.
+        let mut stretch = self.state.update(cx, |state, cx| {
             if state
                 .selecting
                 .as_ref()
@@ -2532,6 +2621,12 @@ impl<M: InputModeKind> Element for TextElement<M> {
             }
             state.display_map.set_font(font, text_size, cx);
             state.display_map.ensure_text_prepared(&state.text, cx);
+            if cx.reduce_motion() {
+                state.overscroll.reset();
+            } else if state.overscroll.step(cx.background_executor().now()) {
+                window.request_animation_frame();
+            }
+            state.overscroll.offset()
         });
 
         let state = self.state.read(cx);
@@ -2857,8 +2952,18 @@ impl<M: InputModeKind> Element for TextElement<M> {
         // regardless of horizontal scroll position.
         let input_bounds = bounds;
         let original_x = bounds.origin.x;
+        // Resolved content and viewport sizes can change during a gesture.
+        // A non-scrolling input must not retain a drag that will no longer
+        // receive its Ended packet through the overscroll path.
+        if scroll_size.height <= input_bounds.size.height {
+            self.state.update(cx, |state, _| state.overscroll.reset());
+            stretch = 0.;
+        }
+        // Keep viewport geometry for scrollbars and hitboxes fixed; only
+        // text, its carets, selections, and gutter contents are displaced.
+        bounds.origin.y += px(stretch);
 
-        let (cursor_infos, cursor_scroll_offset, current_row) =
+        let (cursor_infos, cursor_scroll_offset, current_row, resize_reveal_owned) =
             self.layout_cursors(&last_layout, &mut bounds, scroll_size, window, cx);
         // Completion/code-action menus position at the active caret.
         last_layout.cursor_bounds = cursor_infos
@@ -2888,7 +2993,10 @@ impl<M: InputModeKind> Element for TextElement<M> {
                 input_bounds.size.width,
                 last_layout.text_align,
             );
-            PrepaintCaretGeometry::new(cursor_bounds, line_height, input_bounds, scroll_offset)
+            let mut geometry =
+                PrepaintCaretGeometry::new(cursor_bounds, line_height, input_bounds, scroll_offset);
+            geometry.resize_reveal_owned = resize_reveal_owned;
+            geometry
         });
         self.state.update(cx, |state, _| {
             state.prepaint_caret_geometry = caret_geometry;
@@ -3251,7 +3359,10 @@ impl<M: InputModeKind> Element for TextElement<M> {
         // Paint blinking cursors (shared blink state for all carets)
         if focused && show_cursor {
             for cursor_info in prepaint.cursor_infos_with_scroll() {
-                window.paint_quad(fill(cursor_info.bounds, editor_style.caret));
+                window.paint_quad(fill(
+                    snap_caret_to_device_pixels(cursor_info.bounds, window.scale_factor()),
+                    editor_style.caret,
+                ));
             }
         }
 
@@ -3665,12 +3776,115 @@ mod tests {
 
     struct TextareaHarness(Entity<crate::input::TextareaState>);
 
+    #[gpui::test]
+    fn scroll_bounce_keeps_the_scrollbar_viewport_fixed(cx: &mut TestAppContext) {
+        let text = (0..60)
+            .map(|line| format!("line {line}"))
+            .collect::<Vec<_>>()
+            .join("\n");
+        let (editor, window) = decoration_editor(cx, &text, false);
+        let mut cx = VisualTestContext::from_window(window.into(), cx);
+        cx.update(|window, cx| {
+            window.draw(cx).clear(cx);
+            let viewport = editor
+                .read(cx)
+                .editor_scrollbar_snapshot
+                .get()
+                .unwrap()
+                .layout
+                .bounds;
+            let text_origin = editor.read(cx).last_bounds.unwrap().origin;
+            editor.update(cx, |state, cx| {
+                state.set_scroll_bounce(true, cx);
+                state.on_scroll_wheel(
+                    &gpui::ScrollWheelEvent {
+                        delta: gpui::ScrollDelta::Pixels(point(px(0.), px(60.))),
+                        touch_phase: gpui::TouchPhase::Started,
+                        ..Default::default()
+                    },
+                    window,
+                    cx,
+                );
+            });
+            window.draw(cx).clear(cx);
+            let state = editor.read(cx);
+            let stretch = state.overscroll.offset();
+            assert!(stretch > 0.);
+            assert_eq!(
+                state.editor_scrollbar_snapshot.get().unwrap().layout.bounds,
+                viewport
+            );
+            assert_eq!(
+                state.last_bounds.unwrap().origin.y,
+                text_origin.y + px(stretch)
+            );
+        });
+    }
+
     impl Render for TextareaHarness {
         fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
             div().size_full().child(self.0.clone())
         }
     }
 
+    /// A textarea 100 px down a taller bouncing scroll area.
+    struct NestedTextarea(Entity<crate::input::TextareaState>, gpui::ScrollHandle);
+    use gpui::StatefulInteractiveElement as _;
+
+    impl Render for NestedTextarea {
+        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+            crate::ScrollBounce::new(
+                "outer",
+                &self.1,
+                div()
+                    .id("outer-viewport")
+                    .w(px(240.))
+                    .h(px(300.))
+                    .overflow_y_scroll()
+                    .track_scroll(&self.1)
+                    .child(div().h(px(100.)))
+                    .child(div().h(px(120.)).child(self.0.clone()))
+                    .child(div().h(px(600.))),
+            )
+            .enabled(true)
+        }
+    }
+
+    #[gpui::test]
+    fn a_textarea_keeps_its_scroll_from_the_area_around_it(cx: &mut TestAppContext) {
+        cx.update(crate::init);
+        let text = (1..=40)
+            .map(|line| format!("line {line}"))
+            .collect::<Vec<_>>()
+            .join("\n");
+        let outer = gpui::ScrollHandle::new();
+        let mut textarea = None;
+        let window = cx.open_window(size(px(240.), px(400.)), |window, cx| {
+            let state =
+                cx.new(|cx| crate::input::TextareaState::new(window, cx).default_value(text));
+            textarea = Some(state.clone());
+            NestedTextarea(state, outer.clone())
+        });
+        let textarea = textarea.unwrap();
+        let mut cx = VisualTestContext::from_window(window.into(), cx);
+        cx.update(|window, cx| window.draw(cx).clear(cx));
+        let swipe = |cx: &mut VisualTestContext, y: f32| {
+            cx.simulate_event(gpui::ScrollWheelEvent {
+                position: point(px(100.), px(y)),
+                delta: gpui::ScrollDelta::Pixels(point(px(0.), px(-40.))),
+                touch_phase: gpui::TouchPhase::Moved,
+                ..Default::default()
+            });
+            cx.update(|window, cx| window.draw(cx).clear(cx));
+        };
+        swipe(&mut cx, 160.);
+        let scrolled = cx.update(|_, cx| textarea.read(cx).scroll_handle.offset().y);
+        assert!(scrolled < px(0.), "the textarea scrolls");
+        assert_eq!(outer.offset().y, px(0.), "the area around it does not");
+        swipe(&mut cx, 40.);
+        assert_eq!(outer.offset().y, px(-40.), "outside the textarea it does");
+    }
+
     #[gpui::test]
     fn set_scroll_offset_past_the_end_is_clamped(cx: &mut TestAppContext) {
         cx.update(crate::init);
@@ -3853,7 +4067,7 @@ mod tests {
             let scroll_size = input_bounds.size;
             let element = TextElement::new(editor.clone());
             let mut bounds = input_bounds;
-            let (baseline, _, _) =
+            let (baseline, _, _, _) =
                 element.layout_cursors(&layout, &mut bounds, scroll_size, window, cx);
 
             // Both a stale offset after deletion and a deferred scroll target must
@@ -3864,7 +4078,7 @@ mod tests {
                     state.deferred_scroll_offset = deferred;
                 });
                 let mut bounds = input_bounds;
-                let (carets, offset, _) =
+                let (carets, offset, _, _) =
                     element.layout_cursors(&layout, &mut bounds, scroll_size, window, cx);
                 assert_eq!(offset.x, px(0.));
                 assert_eq!(bounds.origin.x, input_bounds.origin.x);
@@ -3907,7 +4121,7 @@ mod tests {
                         state.deferred_scroll_offset = Some(point(minimum, px(0.)));
                     });
                     let mut bounds = input_bounds;
-                    let (expected, _, _) =
+                    let (expected, _, _, _) =
                         element.layout_cursors(&layout, &mut bounds, scroll_size, window, cx);
 
                     for target in [minimum - px(100.), px(100.)] {
@@ -3915,7 +4129,7 @@ mod tests {
                             state.deferred_scroll_offset = Some(point(target, px(0.)));
                         });
                         let mut bounds = input_bounds;
-                        let (carets, offset, _) =
+                        let (carets, offset, _, _) =
                             element.layout_cursors(&layout, &mut bounds, scroll_size, window, cx);
                         let expected_offset = if target < minimum { minimum } else { px(0.) };
                         assert_eq!(offset.x, expected_offset);
@@ -3966,13 +4180,13 @@ mod tests {
                     state.deferred_scroll_offset = None;
                 });
                 let mut bounds = input_bounds;
-                let (baseline, _, _) =
+                let (baseline, _, _, _) =
                     element.layout_cursors(&layout, &mut bounds, scroll_size, window, cx);
                 editor.update(cx, |state, _| {
                     state.deferred_scroll_offset = Some(point(px(-60.), px(0.)));
                 });
                 let mut bounds = input_bounds;
-                let (carets, offset, _) =
+                let (carets, offset, _, _) =
                     element.layout_cursors(&layout, &mut bounds, scroll_size, window, cx);
                 assert_eq!(offset.x, px(-60.));
                 assert_eq!(carets.len(), 3);
@@ -4472,6 +4686,7 @@ mod tests {
             Bounds::new(point(px(51.), px(18.)), size(px(262.), px(87.)))
         );
         assert_eq!(layout.scroll_size, size(px(972.), px(200.)));
+        assert_eq!(layout.scroll_viewport_size, size(px(262.), px(80.)));
 
         let layout_without_gutter =
             EditorScrollbarLayout::new(input_bounds, px(0.), size(px(500.), px(120.)), paddings);
@@ -4481,6 +4696,10 @@ mod tests {
             Bounds::new(point(px(10.), px(18.)), size(px(303.), px(87.)))
         );
         assert_eq!(layout_without_gutter.scroll_size, size(px(513.), px(120.)));
+        assert_eq!(
+            layout_without_gutter.scroll_viewport_size,
+            size(px(303.), px(80.))
+        );
     }
 
     #[test]
@@ -4881,100 +5100,41 @@ mod tests {
     }
 
     #[test]
-    fn test_cursor_surrounding_padding_auto_grow() {
-        // Auto-grow inputs always pad by one line, regardless of any
-        // override or visible-lines count.
-        let line_height = px(20.);
-        for override_lines in [None, Some(0), Some(3), Some(99)] {
-            for visible_lines in [0_usize, 1, 8, 64] {
-                assert_eq!(
-                    cursor_surrounding_padding(true, override_lines, visible_lines, line_height,),
-                    line_height,
-                );
-            }
-        }
-    }
-
-    #[test]
-    fn test_cursor_surrounding_padding_default() {
-        // `None`: historical heuristic — `BOTTOM_MARGIN_ROWS` for normal
-        // viewports, falls back to one line on small viewports (less
-        // than `BOTTOM_MARGIN_ROWS × 8` rows tall).
+    fn row_reveal_contains_the_whole_row_and_has_a_stable_offset() {
         let line_height = px(20.);
-
-        // Small viewport → 1-line fallback.
-        let small = BOTTOM_MARGIN_ROWS * 8 - 1;
-        assert_eq!(
-            cursor_surrounding_padding(false, None, small, line_height),
-            line_height,
-        );
-
-        // Boundary at `BOTTOM_MARGIN_ROWS × 8` flips to the full margin.
-        let boundary = BOTTOM_MARGIN_ROWS * 8;
-        assert_eq!(
-            cursor_surrounding_padding(false, None, boundary, line_height),
-            BOTTOM_MARGIN_ROWS * line_height,
-        );
-
-        // Comfortably-large viewport.
-        assert_eq!(
-            cursor_surrounding_padding(false, None, 100, line_height),
-            BOTTOM_MARGIN_ROWS * line_height,
-        );
-    }
-
-    #[test]
-    fn test_cursor_surrounding_padding_explicit() {
-        // `Some(n)`: exactly `n × line_height` when the viewport has
-        // room for it; saturated against half the viewport when it
-        // doesn't.
-        let line_height = px(20.);
-
-        for lines in [0_usize, 1, 2, 5, 50] {
-            let raw = lines as f32 * line_height;
-            for visible_lines in [0_usize, 1, 8, 100] {
-                let viewport_half = (visible_lines as f32 * line_height).half();
-                assert_eq!(
-                    cursor_surrounding_padding(false, Some(lines), visible_lines, line_height,),
-                    raw.min(viewport_half),
-                );
+        let row_top = px(1000.);
+        for viewport_height in [px(20.), px(30.), px(600.)] {
+            for auto_grow in [false, true] {
+                for surrounding in [None, Some(0), Some(50)] {
+                    let padding = cursor_surrounding_padding(
+                        auto_grow,
+                        surrounding,
+                        viewport_visible_lines(viewport_height, line_height),
+                        line_height,
+                    );
+                    for previous in [px(-1500.), px(-1000.), px(0.)] {
+                        let offset =
+                            reveal_row(previous, row_top, line_height, viewport_height, padding);
+                        assert!(row_top + offset >= px(0.));
+                        assert!(row_top + offset + line_height <= viewport_height);
+                        assert_eq!(
+                            reveal_row(offset, row_top, line_height, viewport_height, padding),
+                            offset,
+                            "unchanged caret must not oscillate",
+                        );
+                    }
+                }
             }
         }
     }
 
     #[test]
-    fn test_cursor_surrounding_padding_saturates_against_viewport() {
-        // An aggressive override on a small viewport must not produce a
-        // padding larger than half the visible region — otherwise the
-        // bottom-edge auto-scroll-into-view threshold sinks below the
-        // top-edge threshold and the per-frame scroll adjustment loses
-        // a stable fixed point.
-        let line_height = px(20.);
-
-        // Override much larger than viewport → clamped to half.
-        let visible_lines = 10;
-        let viewport_half = (visible_lines as f32 * line_height).half();
-        assert_eq!(
-            cursor_surrounding_padding(false, Some(50), visible_lines, line_height),
-            viewport_half,
-        );
-
-        // Override that fits → returned unchanged.
-        let visible_lines = 40;
-        assert_eq!(
-            cursor_surrounding_padding(false, Some(3), visible_lines, line_height),
-            3.0 * line_height,
-        );
-
-        // Default heuristic still saturates if BOTTOM_MARGIN_ROWS would
-        // exceed the half-viewport bound (only possible at extreme
-        // sizes — kept for defensive completeness).
-        let visible_lines = BOTTOM_MARGIN_ROWS * 8;
-        let half = (visible_lines as f32 * line_height).half();
-        let raw = BOTTOM_MARGIN_ROWS * line_height;
+    fn undersized_row_reveal_aligns_the_start_without_oscillating() {
+        let offset = reveal_row(px(0.), px(80.), px(20.), px(10.), px(60.));
+        assert_eq!(px(80.) + offset, px(0.));
         assert_eq!(
-            cursor_surrounding_padding(false, None, visible_lines, line_height),
-            raw.min(half),
+            reveal_row(offset, px(80.), px(20.), px(10.), px(60.)),
+            offset
         );
     }
 }
diff --git a/third-party/gpui-kit/crates/base/src/input/base/state.rs b/third-party/gpui-kit/crates/base/src/input/base/state.rs
index 89d5d6e5eb..5dab8fc5f4 100644
--- a/third-party/gpui-kit/crates/base/src/input/base/state.rs
+++ b/third-party/gpui-kit/crates/base/src/input/base/state.rs
@@ -10,6 +10,7 @@ use gpui::{
     Render, ScrollHandle, ScrollWheelEvent, SharedString, Styled as _, Subscription,
     UTF16Selection, WeakFocusHandle, Window, actions, div, point, prelude::FluentBuilder as _, px,
 };
+use gpui::{Autocapitalize, TextInputAction, TextInputConfiguration};
 use ropey::{Rope, RopeSlice};
 use serde::Deserialize;
 use std::borrow::Cow;
@@ -40,6 +41,7 @@ use crate::input::movement::MoveDirection;
 use crate::input::{
     InputExtras as _, Position, RopeExt as _, element::RIGHT_MARGIN, layout::LastLayout,
 };
+use crate::overscroll::Overscroll;
 use crate::{AutoScroll, StepAction};
 
 /// A complete projection preserves every explicit colour, including transparency.
@@ -358,6 +360,8 @@ pub struct PrepaintCaretGeometry {
     line_height: Pixels,
     input_bounds: Bounds<Pixels>,
     scroll_offset: Point<Pixels>,
+    /// A resize owns visibility while its requested repaint settles.
+    pub(super) resize_reveal_owned: bool,
 }
 
 impl PrepaintCaretGeometry {
@@ -372,6 +376,7 @@ impl PrepaintCaretGeometry {
             line_height,
             input_bounds,
             scroll_offset,
+            resize_reveal_owned: false,
         }
     }
 
@@ -482,6 +487,10 @@ pub struct InputBaseState<M: InputModeKind> {
     pub(super) masked: bool,
     pub(super) clean_on_escape: bool,
     pub(super) submit_on_enter: bool,
+    /// Overrides for [`Self::text_input_configuration`].
+    pub(super) input_action: Option<TextInputAction>,
+    pub(super) autocorrect: Option<bool>,
+    pub(super) autocapitalize: Option<Autocapitalize>,
     pub(super) show_whitespaces: bool,
     /// This flag tells the renderer to prefer the end of the current visual line.
     pub(crate) cursor_line_end_affinity: bool,
@@ -496,6 +505,10 @@ pub struct InputBaseState<M: InputModeKind> {
     /// The maximum value for [`super::NumberInput`]. See [`Self::max`].
     pub(crate) number_max: Option<f64>,
     pub(crate) scroll_handle: ScrollHandle,
+    /// Whether the text stretches past its ends, see [`Self::scroll_bounce`].
+    scroll_bounce: bool,
+    /// The text's stretch past an end, while it lasts.
+    pub(super) overscroll: Overscroll,
     /// The deferred scroll offset to apply on next layout.
     pub(crate) deferred_scroll_offset: Option<Point<Pixels>>,
     /// The size of the scrollable content.
@@ -657,6 +670,49 @@ impl<M: InputModeKind> InputBaseState<M> {
         self.last_bounds
     }
 
+    /// The cached display map for querying wrapped rows and display positions.
+    ///
+    /// Reading this does not prepare text or force layout. Before the first
+    /// layout, [`DisplayMap::wrap_row_count`] can be zero; prepared empty text
+    /// has one row. The count describes content, independent of minimum rows,
+    /// auto-grow limits and viewport height.
+    ///
+    /// Text edits use the cached wrapping parameters. Layout refreshes the
+    /// cache after width or font changes; consume that count on a subsequent
+    /// render, or use [`Self::prewrap`] to prepare it before the first layout.
+    pub fn display_map(&self) -> &DisplayMap {
+        &self.display_map
+    }
+
+    /// Wrap the text for `font`, `font_size` and `width` before the first
+    /// layout, so that [`DisplayMap::wrap_row_count`] is known on the first frame.
+    ///
+    /// Call it after setting the value and the wrapping indent. `width` is the
+    /// width of the input's text area: its width less its padding, and less
+    /// the line-number gutter for an editor with line numbers. It wraps as
+    /// layout does, a little short of that width and with the state's wrapping
+    /// indent. Once the input has been laid out this does nothing, and layout
+    /// keeps the wrap up to date. The font and font size must match the input's
+    /// actual text style.
+    pub fn prewrap(
+        &mut self,
+        font: gpui::Font,
+        font_size: Pixels,
+        width: Pixels,
+        cx: &mut Context<Self>,
+    ) {
+        if self.last_layout.is_some() {
+            return;
+        }
+        let wrap_width = (width - RIGHT_MARGIN).max(px(1.));
+        self.display_map.set_font(font, font_size, cx);
+        self.display_map
+            .on_layout_changed(self.soft_wrap.then_some(wrap_width), cx);
+        self.display_map
+            .set_wrapping_indent(self.wrapping_indent, cx);
+        self.display_map.ensure_text_prepared(&self.text, cx);
+    }
+
     pub fn diagnostic_popover(&self) -> Option<Rc<crate::input::DiagnosticEntry>> {
         self.diagnostic_popover.clone()
     }
@@ -862,6 +918,9 @@ impl<M: InputModeKind> InputBaseState<M> {
             masked: false,
             clean_on_escape: false,
             submit_on_enter: false,
+            input_action: None,
+            autocorrect: None,
+            autocapitalize: None,
             show_whitespaces: false,
             loading: false,
             pattern: None,
@@ -878,6 +937,8 @@ impl<M: InputModeKind> InputBaseState<M> {
             column_select_start: None,
             last_cursor: None,
             scroll_handle: ScrollHandle::new(),
+            scroll_bounce: crate::ScrollBounce::enabled_by_default(),
+            overscroll: Overscroll::default(),
             scroll_size: gpui::size(px(0.), px(0.)),
             editor_scrollbar_snapshot: Cell::new(None),
             longest_line_width: Cell::new(None),
@@ -1302,6 +1363,8 @@ impl<M: InputModeKind> InputBaseState<M> {
     }
 
     /// Set true to clear the input by pressing Escape key.
+    ///
+    /// Not on Android, where Escape is also the back button.
     pub fn clean_on_escape(mut self) -> Self {
         self.clean_on_escape = true;
         self
@@ -1326,6 +1389,59 @@ impl<M: InputModeKind> InputBaseState<M> {
         cx.notify();
     }
 
+    /// Set the action a software keyboard shows on its enter key, like Search
+    /// or Send. Pressing it still emits [`InputEvent::PressEnter`].
+    ///
+    /// Default is [`TextInputAction::Done`] for a single-line input (and a
+    /// multi-line one that submits on enter), and [`TextInputAction::Enter`]
+    /// (a line break) for a multi-line input.
+    pub fn input_action(mut self, action: TextInputAction) -> Self {
+        self.input_action = Some(action);
+        self
+    }
+
+    /// Set whether the platform may autocorrect the text and offer word
+    /// suggestions.
+    ///
+    /// Default is `false`. Enable this explicitly for fields that accept prose;
+    /// masking or revealing text does not change the setting.
+    pub fn autocorrect(mut self, autocorrect: bool) -> Self {
+        self.autocorrect = Some(autocorrect);
+        self
+    }
+
+    /// Set how a software keyboard capitalizes the text.
+    ///
+    /// Default is [`Autocapitalize::None`]. Enable capitalization explicitly for
+    /// fields where it is appropriate, independently of autocorrection.
+    pub fn autocapitalize(mut self, autocapitalize: Autocapitalize) -> Self {
+        self.autocapitalize = Some(autocapitalize);
+        self
+    }
+
+    /// The text assistance a platform IME (a software keyboard) should give
+    /// this input, from [`Self::input_action`], [`Self::autocorrect`] and
+    /// [`Self::autocapitalize`] or the defaults they describe.
+    pub fn text_input_configuration(&self) -> TextInputConfiguration {
+        // Masking is a presentation choice, not a reliable content type: a
+        // revealed password must not acquire text assistance. Keep assistance
+        // opt-in for structured fields such as passwords, email and URLs.
+        let autocorrect = self.autocorrect.unwrap_or(false);
+        let input_action =
+            self.input_action
+                .unwrap_or(if self.is_multi_line() && !self.submit_on_enter {
+                    TextInputAction::Enter
+                } else {
+                    TextInputAction::Done
+                });
+        TextInputConfiguration {
+            autocorrect,
+            autocapitalize: self.autocapitalize.unwrap_or(Autocapitalize::None),
+            suggestions: autocorrect,
+            input_action,
+        }
+    }
+
     /// Set whether to show whitespace characters.
     #[doc(hidden)]
     pub fn show_whitespaces(mut self, show: bool) -> Self {
@@ -1376,7 +1492,10 @@ impl<M: InputModeKind> InputBaseState<M> {
     ///
     /// - `None` (default): [`BOTTOM_MARGIN_ROWS`] lines, falling back to
     ///   one line on small viewports.
-    /// - `Some(n)`: exactly `n` lines, clamped to half the viewport.
+    /// - `Some(n)`: requests `n` lines, clamped to half the viewport.
+    ///
+    /// In a constrained viewport, edge clearance yields to the cursor's
+    /// complete visual row so surrounding space cannot clip the text being edited.
     pub fn cursor_surrounding_lines(mut self, lines: Option<usize>) -> Self {
         self.cursor_surrounding_lines = lines;
         self
@@ -2278,7 +2397,9 @@ impl<M: InputModeKind> InputBaseState<M> {
             return;
         }
 
-        if self.clean_on_escape {
+        // Android delivers its back button as escape, and back leaves a field's
+        // text alone: it closes what is open or navigates.
+        if self.clean_on_escape && !cfg!(target_os = "android") {
             return self.clean(window, cx);
         }
 
@@ -2500,6 +2621,13 @@ impl<M: InputModeKind> InputBaseState<M> {
         }
         let gesture = window.input_gesture();
         self.undo_manager.break_transaction_coalescing();
+        // Treat every press like a cursor move: take focus now rather than
+        // after GPUI's focus observer runs, show the caret at once and restart
+        // the idle delay, whatever phase the blink was in.
+        if !self.disabled {
+            self.focus(window, cx);
+            self.pause_blink_cursor(cx);
+        }
         // Input has its own text selection; suppress the window-level text
         // selection (Root) so it does not start a drag from here.
         crate::global_state::GlobalState::suppress_text_selection(cx);
@@ -2684,7 +2812,57 @@ impl<M: InputModeKind> InputBaseState<M> {
         let delta = event.delta.pixel_delta(line_height);
 
         let old_offset = self.scroll_handle.offset();
-        self.update_scroll_offset(Some(old_offset + delta), cx);
+        let precise = matches!(event.delta, gpui::ScrollDelta::Pixels(_));
+        if !precise || !self.scroll_bounce || cx.reduce_motion() || !self.overflows_vertically() {
+            let displaced = self.overscroll.offset() != 0.;
+            self.overscroll.reset();
+            if displaced {
+                cx.notify();
+            }
+        }
+        let bounces = self.scroll_bounce
+            && precise
+            && !cx.reduce_motion()
+            && delta.y.abs() >= delta.x.abs()
+            && self.overflows_vertically();
+        if bounces {
+            let mut overscroll = std::mem::take(&mut self.overscroll);
+            let stretched = overscroll.packet(
+                delta.y.as_f32(),
+                event.touch_phase,
+                self.input_bounds.size.height.as_f32(),
+                cx.background_executor().now(),
+                |dy| {
+                    let before = self.scroll_handle.offset();
+                    self.update_scroll_offset(
+                        Some(point(before.x + delta.x, before.y + px(dy))),
+                        cx,
+                    );
+                    (self.scroll_handle.offset().y - before.y).as_f32()
+                },
+            );
+            self.overscroll = overscroll;
+            if stretched {
+                cx.notify();
+            }
+        } else {
+            // Axis drift must not drop the end of a vertical drag.
+            if matches!(
+                event.touch_phase,
+                gpui::TouchPhase::Ended | gpui::TouchPhase::Cancelled
+            ) && self.overscroll.offset() != 0.
+            {
+                self.overscroll.packet(
+                    0.,
+                    event.touch_phase,
+                    self.input_bounds.size.height.as_f32(),
+                    cx.background_executor().now(),
+                    |_| 0.,
+                );
+                cx.notify();
+            }
+            self.update_scroll_offset(Some(old_offset + delta), cx);
+        }
 
         // Text that has room to scroll keeps a scroll over it even where the
         // text ends, so reaching the end does not scroll the region around the
@@ -2701,19 +2879,35 @@ impl<M: InputModeKind> InputBaseState<M> {
         self.edit_menu_on_scroll(event.touch_phase, cx);
     }
 
+    /// Whether the text takes a wheel event moving by `delta` from the
+    /// scroll areas around it: it has room to scroll along the axis the
+    /// event mostly moves on. Unlike [`Self::overflows_along`], an event that
+    /// moves on neither axis, such as a gesture's start or end, goes to text
+    /// that can scroll vertically.
+    pub(super) fn takes_scroll(&self, delta: Point<Pixels>) -> bool {
+        if delta.y.abs() >= delta.x.abs() {
+            self.overflows_vertically()
+        } else {
+            self.scroll_size.width > self.input_bounds.size.width
+        }
+    }
+
     /// Whether the text has room to scroll along the axis `delta` mostly
     /// moves on. A trackpad swipe drifts a little off its axis, so the drift
     /// alone does not claim the scroll.
     fn overflows_along(&self, delta: Point<Pixels>) -> bool {
         if delta.y.abs() >= delta.x.abs() {
-            self.is_multi_line()
-                && delta.y != px(0.)
-                && self.scroll_size.height > self.input_bounds.size.height
+            delta.y != px(0.) && self.overflows_vertically()
         } else {
             self.scroll_size.width > self.input_bounds.size.width
         }
     }
 
+    /// Whether multi-line text is taller than the input.
+    fn overflows_vertically(&self) -> bool {
+        self.is_multi_line() && self.scroll_size.height > self.input_bounds.size.height
+    }
+
     pub(super) fn update_scroll_offset(
         &mut self,
         offset: Option<Point<Pixels>>,
@@ -2782,20 +2976,9 @@ impl<M: InputModeKind> InputBaseState<M> {
         let row = point.row;
 
         // Resolve the wrapped row even when the target is outside the last layout.
-        let display_pos = self
+        let display_row = self
             .display_map
-            .buffer_pos_to_display_pos(crate::input::BufferPoint::new(row, point.column));
-        let affinity = offset == self.cursor() && self.cursor_line_end_affinity;
-        let display_row = if affinity {
-            let wrap = self
-                .display_map
-                .offset_to_wrap_display_point_with_affinity(offset, true);
-            self.display_map
-                .wrap_row_to_display_row(wrap.row)
-                .unwrap_or(display_pos.row)
-        } else {
-            display_pos.row
-        };
+            .offset_to_display_row(offset, self.line_end_affinity_at(offset));
         let row_offset_y = line_height * display_row;
 
         // For Right alignment use 0 margin: the cursor indicator is clamped inside bounds
@@ -2837,15 +3020,15 @@ impl<M: InputModeKind> InputBaseState<M> {
                     line_height,
                 )
             } else {
-                line_height
+                px(0.)
             };
-        if row_offset_y - edge_height + line_height < -scroll_offset.y {
-            // Scroll up
-            scroll_offset.y = -row_offset_y + edge_height - line_height;
-        } else if row_offset_y + edge_height > -scroll_offset.y + bounds.size.height {
-            // Scroll down
-            scroll_offset.y = -(row_offset_y - bounds.size.height + edge_height);
-        }
+        scroll_offset.y = super::element::reveal_row(
+            scroll_offset.y,
+            row_offset_y,
+            line_height,
+            bounds.size.height,
+            edge_height,
+        );
 
         // Avoid necessary scroll, when it was already in the correct position.
         if direction == Some(MoveDirection::Up) {
@@ -4784,6 +4967,14 @@ impl<M: InputModeKind> EntityInputHandler for InputBaseState<M> {
 
         None
     }
+
+    fn text_input_configuration(
+        &mut self,
+        _window: &mut Window,
+        _cx: &mut Context<Self>,
+    ) -> TextInputConfiguration {
+        InputBaseState::text_input_configuration(self)
+    }
 }
 
 impl<M: InputModeKind> Focusable for InputBaseState<M> {
@@ -4974,6 +5165,76 @@ impl<M: InputModeKind> Render for InputBaseState<M> {
 mod tests {
     use super::*;
 
+    #[gpui::test]
+    fn revealing_a_password_keeps_text_assistance_disabled(cx: &mut TestAppContext) {
+        cx.update(crate::init);
+        cx.open_window(size(px(400.), px(100.)), |window, cx| {
+            let state = cx.new(|cx| crate::input::InputState::new(window, cx).masked(true));
+            let hidden = state.read(cx).text_input_configuration();
+            assert!(!hidden.autocorrect && !hidden.suggestions);
+            assert_eq!(hidden.autocapitalize, Autocapitalize::None);
+
+            // The component's reveal button uses this same state transition.
+            state.update(cx, |state, cx| state.toggle_masked(window, cx));
+            assert_eq!(state.read(cx).text_input_configuration(), hidden);
+            state.update(cx, |state, cx| state.toggle_masked(window, cx));
+            assert_eq!(state.read(cx).text_input_configuration(), hidden);
+            gpui::EmptyView
+        });
+    }
+
+    #[gpui::test]
+    fn text_input_configuration_follows_the_input(cx: &mut TestAppContext) {
+        use crate::input::{InputState, TextareaState};
+
+        cx.update(crate::init);
+        let mut states = None;
+        cx.open_window(size(px(400.), px(100.)), |window, cx| {
+            states = Some((
+                cx.new(|cx| InputState::new(window, cx)),
+                cx.new(|cx| InputState::new(window, cx).masked(true)),
+                cx.new(|cx| TextareaState::new(window, cx)),
+                cx.new(|cx| {
+                    InputState::new(window, cx)
+                        .input_action(TextInputAction::Search)
+                        .autocorrect(true)
+                        .autocapitalize(Autocapitalize::Words)
+                }),
+            ));
+            gpui::EmptyView
+        });
+        let (plain, masked, textarea, search) = states.unwrap();
+
+        cx.read(|cx| {
+            assert_eq!(
+                plain.read(cx).text_input_configuration(),
+                TextInputConfiguration {
+                    input_action: TextInputAction::Done,
+                    ..Default::default()
+                }
+            );
+            assert_eq!(
+                masked.read(cx).text_input_configuration(),
+                TextInputConfiguration {
+                    input_action: TextInputAction::Done,
+                    ..Default::default()
+                }
+            );
+            assert_eq!(
+                textarea.read(cx).text_input_configuration(),
+                TextInputConfiguration {
+                    input_action: TextInputAction::Enter,
+                    ..Default::default()
+                }
+            );
+            let search = search.read(cx).text_input_configuration();
+            assert_eq!(search.input_action, TextInputAction::Search);
+            assert_eq!(search.autocapitalize, Autocapitalize::Words);
+            assert!(search.autocorrect);
+            assert!(search.suggestions);
+        });
+    }
+
     #[gpui::test]
     fn test_multicursor_unicode_completion_uses_post_edit_start(cx: &mut TestAppContext) {
         use crate::input::{CompletionProvider, EditorState};
@@ -6160,6 +6421,254 @@ mod tests {
         }
     }
 
+    /// Sends one trackpad packet straight to the input.
+    fn swipe<M: InputModeKind>(
+        view: &InputView<M>,
+        cx: &mut VisualTestContext,
+        dy: f32,
+        phase: gpui::TouchPhase,
+    ) {
+        cx.update(|window, cx| {
+            view.input.update(cx, |state, cx| {
+                state.on_scroll_wheel(
+                    &ScrollWheelEvent {
+                        delta: gpui::ScrollDelta::Pixels(point(px(0.), px(dy))),
+                        touch_phase: phase,
+                        ..Default::default()
+                    },
+                    window,
+                    cx,
+                )
+            })
+        });
+    }
+
+    fn long_text() -> String {
+        (0..60)
+            .map(|n| format!("line {n}"))
+            .collect::<Vec<_>>()
+            .join("\n")
+    }
+
+    #[gpui::test]
+    fn test_scroll_bounce_stretches_past_the_end_and_springs_back(cx: &mut TestAppContext) {
+        use gpui::TouchPhase::{Ended, Moved, Started};
+
+        let view = InputView::build_textarea(cx, |state| {
+            state.scroll_bounce(true).default_value(long_text())
+        });
+        let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
+        cx.update(|window, cx| window.draw(cx).clear(cx));
+        let drawn_at = |cx: &mut VisualTestContext| {
+            view.input
+                .read_with(cx, |state, _| state.last_bounds.unwrap().origin.y)
+        };
+
+        swipe(&view, &mut cx, -20., Started);
+        swipe(&view, &mut cx, -100_000., Moved);
+        let (end, stretch) = view.input.read_with(&cx, |state, _| {
+            (state.scroll_handle.offset().y, state.overscroll.offset())
+        });
+        assert!(end < px(0.), "the text scrolled to its end");
+        assert!(stretch < 0., "the rest stretched the edge: {stretch}");
+        cx.update(|window, cx| window.draw(cx).clear(cx));
+        let stretched_at = drawn_at(&mut cx);
+
+        swipe(&view, &mut cx, 0., Ended);
+        for _ in 0..120 {
+            cx.executor()
+                .advance_clock(std::time::Duration::from_millis(16));
+            cx.update(|window, cx| window.draw(cx).clear(cx));
+        }
+        view.input.read_with(&cx, |state, _| {
+            assert_eq!(state.overscroll.offset(), 0., "the edge sprang back");
+            assert_eq!(
+                state.scroll_handle.offset().y,
+                end,
+                "scrolling stayed exact"
+            );
+        });
+        let moved = (stretched_at - drawn_at(&mut cx)).as_f32();
+        assert!(
+            (moved - stretch).abs() < 0.001,
+            "the stretch only moved where the text was drawn: {moved} vs {stretch}"
+        );
+    }
+
+    /// Swipes far past the end of the text and returns where the text
+    /// scrolled to and how far its edge stretched.
+    fn swipe_past_the_end(
+        view: &InputView<TextareaMode>,
+        cx: &mut VisualTestContext,
+    ) -> (Pixels, f32) {
+        use gpui::TouchPhase::{Moved, Started};
+
+        cx.update(|window, cx| window.draw(cx).clear(cx));
+        swipe(view, cx, -20., Started);
+        swipe(view, cx, -100_000., Moved);
+        view.input.read_with(cx, |state, _| {
+            (state.scroll_handle.offset().y, state.overscroll.offset())
+        })
+    }
+
+    #[gpui::test]
+    fn test_scroll_bounce_stays_put_under_reduced_motion(cx: &mut TestAppContext) {
+        cx.update(|cx| cx.set_reduce_motion(true));
+        let view = InputView::build_textarea(cx, |state| {
+            state.scroll_bounce(true).default_value(long_text())
+        });
+        let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
+        let (end, stretch) = swipe_past_the_end(&view, &mut cx);
+        assert!(end < px(0.), "the text still scrolls");
+        assert_eq!(stretch, 0., "the edge does not stretch");
+    }
+
+    #[gpui::test]
+    fn test_scroll_bounce_follows_the_platform_by_default(cx: &mut TestAppContext) {
+        let view = InputView::build_textarea(cx, |state| state.default_value(long_text()));
+        let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
+        let (end, stretch) = swipe_past_the_end(&view, &mut cx);
+        assert!(end < px(0.), "the text scrolls");
+        assert_eq!(
+            stretch != 0.,
+            crate::ScrollBounce::enabled_by_default(),
+            "the edge stretches where the platform's scroll views do"
+        );
+    }
+
+    #[gpui::test]
+    fn test_scroll_bounce_leaves_text_that_fits(cx: &mut TestAppContext) {
+        let view = InputView::build_textarea(cx, |state| {
+            state.scroll_bounce(true).default_value("one line")
+        });
+        let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
+        let (end, stretch) = swipe_past_the_end(&view, &mut cx);
+        assert_eq!((end, stretch), (px(0.), 0.));
+    }
+
+    #[gpui::test]
+    fn test_scroll_bounce_resets_when_replacement_text_fits(cx: &mut TestAppContext) {
+        let view = InputView::build_textarea(cx, |state| {
+            state.scroll_bounce(true).default_value(long_text())
+        });
+        let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
+        let (_, stretch) = swipe_past_the_end(&view, &mut cx);
+        assert!(stretch < 0.);
+        cx.update(|window, cx| {
+            view.input
+                .update(cx, |state, cx| state.set_value("short", window, cx));
+            window.draw(cx).clear(cx);
+        });
+        swipe(&view, &mut cx, 0., gpui::TouchPhase::Ended);
+        view.input.read_with(&cx, |state, _| {
+            assert_eq!(state.overscroll.offset(), 0.);
+            assert_eq!(state.scroll_handle.offset().y, px(0.));
+        });
+    }
+
+    #[gpui::test]
+    fn test_scroll_bounce_releases_when_the_end_packet_is_horizontal(cx: &mut TestAppContext) {
+        let view = InputView::build_textarea(cx, |state| {
+            state.scroll_bounce(true).default_value(long_text())
+        });
+        let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
+        let (_, stretch) = swipe_past_the_end(&view, &mut cx);
+        assert!(stretch < 0.);
+        cx.update(|window, cx| {
+            view.input.update(cx, |state, cx| {
+                state.on_scroll_wheel(
+                    &ScrollWheelEvent {
+                        delta: gpui::ScrollDelta::Pixels(point(px(1.), px(0.))),
+                        touch_phase: gpui::TouchPhase::Ended,
+                        ..Default::default()
+                    },
+                    window,
+                    cx,
+                );
+            });
+        });
+        for _ in 0..120 {
+            cx.executor()
+                .advance_clock(std::time::Duration::from_millis(16));
+            cx.update(|window, cx| window.draw(cx).clear(cx));
+        }
+        assert_eq!(
+            view.input
+                .read_with(&cx, |state, _| state.overscroll.offset()),
+            0.
+        );
+    }
+
+    #[gpui::test]
+    fn test_scroll_bounce_ordinary_mouse_wheel_does_not_stretch(cx: &mut TestAppContext) {
+        let view = InputView::build_textarea(cx, |state| {
+            state.scroll_bounce(true).default_value(long_text())
+        });
+        let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
+        cx.update(|window, cx| {
+            window.draw(cx).clear(cx);
+            view.input.update(cx, |state, cx| {
+                state.on_scroll_wheel(
+                    &ScrollWheelEvent {
+                        delta: gpui::ScrollDelta::Lines(point(0., -100_000.)),
+                        touch_phase: gpui::TouchPhase::Moved,
+                        ..Default::default()
+                    },
+                    window,
+                    cx,
+                );
+                assert!(state.scroll_handle.offset().y < px(0.));
+                assert_eq!(state.overscroll.offset(), 0.);
+            });
+        });
+    }
+
+    #[gpui::test]
+    fn test_scroll_bounce_new_wheel_input_interrupts_the_return(cx: &mut TestAppContext) {
+        let view = InputView::build_textarea(cx, |state| {
+            state.scroll_bounce(true).default_value(long_text())
+        });
+        let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
+        let (end, stretch) = swipe_past_the_end(&view, &mut cx);
+        assert!(stretch < 0.);
+        swipe(&view, &mut cx, 0., gpui::TouchPhase::Ended);
+        cx.executor()
+            .advance_clock(std::time::Duration::from_millis(16));
+        cx.update(|window, cx| {
+            window.draw(cx).clear(cx);
+            view.input.update(cx, |state, cx| {
+                state.on_scroll_wheel(
+                    &ScrollWheelEvent {
+                        delta: gpui::ScrollDelta::Lines(point(0., 2.)),
+                        touch_phase: gpui::TouchPhase::Moved,
+                        ..Default::default()
+                    },
+                    window,
+                    cx,
+                );
+                assert!(state.scroll_handle.offset().y > end);
+                assert_eq!(state.overscroll.offset(), 0.);
+            });
+        });
+    }
+
+    #[gpui::test]
+    fn test_turning_scroll_bounce_off_drops_the_stretch(cx: &mut TestAppContext) {
+        let view = InputView::build_textarea(cx, |state| {
+            state.scroll_bounce(true).default_value(long_text())
+        });
+        let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
+        let (_, stretch) = swipe_past_the_end(&view, &mut cx);
+        assert!(stretch < 0., "the edge stretched: {stretch}");
+        view.input
+            .update(&mut cx, |state, cx| state.set_scroll_bounce(false, cx));
+        assert_eq!(
+            view.input
+                .read_with(&cx, |state, _| state.overscroll.offset()),
+            0.
+        );
+    }
+
     /// The frame is laid out by the application, which should not have to
     /// center a single line inside it.
     #[gpui::test]
@@ -9005,6 +9514,54 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    fn test_textarea_prewrap_prepares_content_rows_before_layout(cx: &mut TestAppContext) {
+        cx.update(crate::init);
+        for (value, expected_rows) in [("", 1), ("one\ntwo", 2)] {
+            cx.open_window(size(px(400.), px(100.)), |window, cx| {
+                let font = window.text_style().font();
+                cx.new(|cx| {
+                    let mut state = crate::input::TextareaState::new(window, cx)
+                        .rows(12)
+                        .default_value(value);
+                    assert_eq!(state.display_map().wrap_row_count(), 0);
+                    state.prewrap(font, px(14.), px(120.), cx);
+                    assert_eq!(state.display_map().wrap_row_count(), expected_rows);
+                    assert_eq!(state.value(), value);
+                    state
+                });
+                gpui::EmptyView
+            });
+        }
+    }
+
+    #[gpui::test]
+    fn test_textarea_prewrap_respects_soft_wrap_before_layout(cx: &mut TestAppContext) {
+        cx.update(crate::init);
+        let value = "select id, name, email, phone from users where active\nlimit 10";
+        for soft_wrap in [true, false] {
+            cx.open_window(size(px(400.), px(100.)), |window, cx| {
+                let font = window.text_style().font();
+                cx.new(|cx| {
+                    let mut state = crate::input::TextareaState::new(window, cx)
+                        .rows(1)
+                        .soft_wrap(soft_wrap)
+                        .default_value(value);
+                    state.prewrap(font, px(14.), px(120.), cx);
+                    let rows = state.display_map().wrap_row_count();
+                    if soft_wrap {
+                        assert!(rows > 2, "the first logical line must wrap");
+                    } else {
+                        assert_eq!(rows, 2);
+                    }
+                    assert_eq!(state.value(), value);
+                    state
+                });
+                gpui::EmptyView
+            });
+        }
+    }
+
     #[gpui::test]
     fn test_prewrap_counts_wrapped_rows_before_the_first_paint(cx: &mut TestAppContext) {
         cx.update(crate::init);
@@ -9416,6 +9973,48 @@ mod tests {
         }
     }
 
+    #[gpui::test]
+    fn test_mouse_down_shows_cursor_like_a_cursor_move(cx: &mut TestAppContext) {
+        cx.update(crate::init);
+        let view = InputView::<EditorMode>::new(cx);
+        let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
+        setup_cursors(&mut cx, &view.input, "abcd|efgh");
+        let position = |offset: Pixels, cx: &mut VisualTestContext| {
+            view.input.read_with(cx, |state, _| {
+                let layout = state.last_layout.as_ref().unwrap();
+                state.last_bounds.unwrap().origin
+                    + point(layout.line_number_width + offset, layout.line_height * 0.5)
+            })
+        };
+        let visible = |cx: &mut VisualTestContext| {
+            view.input
+                .read_with(cx, |state, cx| state.blink_cursor.read(cx).visible())
+        };
+
+        // The press itself shows the caret, without waiting for the focus
+        // observer, and the idle delay holds it there like a cursor move.
+        let first = position(px(2.), &mut cx);
+        cx.simulate_mouse_down(first, MouseButton::Left, Default::default());
+        assert!(visible(&mut cx));
+        cx.simulate_mouse_up(first, MouseButton::Left, Default::default());
+        cx.run_until_parked();
+
+        // Pressing elsewhere in the focused input, in the hidden phase of the
+        // blink, shows the caret again at the press.
+        cx.executor()
+            .advance_clock(std::time::Duration::from_millis(400));
+        cx.run_until_parked();
+        assert!(!visible(&mut cx));
+        let second = position(px(30.), &mut cx);
+        cx.simulate_mouse_down(second, MouseButton::Left, Default::default());
+        assert!(visible(&mut cx));
+        cx.executor()
+            .advance_clock(std::time::Duration::from_millis(250));
+        cx.run_until_parked();
+        assert!(visible(&mut cx));
+        cx.simulate_mouse_up(second, MouseButton::Left, Default::default());
+    }
+
     #[gpui::test]
     fn test_multi_cursor_actions_reveal_hidden_carets(cx: &mut TestAppContext) {
         let view = InputView::<EditorMode>::new(cx);
@@ -11772,6 +12371,29 @@ impl InputBaseState<crate::input::InputMode> {
 /// Methods shared by the two multi-line modes, and reachable on neither a
 /// single-line input nor anything else.
 impl<M: crate::input::MultiLineMode> InputBaseState<M> {
+    /// Set whether text taller than the input stretches past its top and
+    /// bottom and springs back when precise trackpad or Magic Mouse scrolling
+    /// or its momentum runs into an end. Ordinary line-wheel scrolling does
+    /// not bounce. The default follows
+    /// [`crate::ScrollBounce::enabled_by_default`]: on on iOS, Android and
+    /// macOS, off elsewhere. Under reduced motion the text stops at its ends
+    /// without stretching.
+    pub fn scroll_bounce(mut self, scroll_bounce: bool) -> Self {
+        self.scroll_bounce = scroll_bounce;
+        self
+    }
+
+    /// Update whether the text stretches past its ends, see
+    /// [`InputBaseState::scroll_bounce`]. Turning it off drops a stretch in
+    /// progress.
+    pub fn set_scroll_bounce(&mut self, scroll_bounce: bool, cx: &mut Context<Self>) {
+        if self.scroll_bounce != scroll_bounce {
+            self.scroll_bounce = scroll_bounce;
+            self.overscroll.reset();
+            cx.notify();
+        }
+    }
+
     /// Whether the built-in search panel and its shortcut are enabled. Off by
     /// default, on for the code editor.
     ///
@@ -12013,41 +12635,6 @@ impl InputBaseState<crate::input::EditorMode> {
         self
     }
 
-    /// The editor's display map for querying wrapped rows and display positions.
-    ///
-    /// Text is prepared during layout or by [`Self::prewrap`].
-    pub fn display_map(&self) -> &DisplayMap {
-        &self.display_map
-    }
-
-    /// Wrap the text for `font`, `font_size` and `width` before the first
-    /// layout, so that [`DisplayMap::wrap_row_count`] is known on the first frame.
-    ///
-    /// Call it after setting the value and the wrapping indent. `width` is the
-    /// width of the editor's text area: its width less its padding, and less
-    /// the line-number gutter when line numbers are shown. It wraps as layout
-    /// does, a little short of that width and with the state's wrapping
-    /// indent. Once the editor has been laid out this does nothing, and layout
-    /// keeps the wrap up to date.
-    pub fn prewrap(
-        &mut self,
-        font: gpui::Font,
-        font_size: Pixels,
-        width: Pixels,
-        cx: &mut Context<Self>,
-    ) {
-        if self.last_layout.is_some() {
-            return;
-        }
-        let wrap_width = (width - RIGHT_MARGIN).max(px(1.));
-        self.display_map.set_font(font, font_size, cx);
-        self.display_map
-            .on_layout_changed(self.soft_wrap.then_some(wrap_width), cx);
-        self.display_map
-            .set_wrapping_indent(self.wrapping_indent, cx);
-        self.display_map.ensure_text_prepared(&self.text, cx);
-    }
-
     /// Set line number.
     pub fn set_line_number(&mut self, line_number: bool, _: &mut Window, cx: &mut Context<Self>) {
         if let LayoutMode::CodeEditor { line_number: l, .. } = &mut self.mode {
diff --git a/third-party/gpui-kit/crates/base/src/input/editor/display_map/display_map.rs b/third-party/gpui-kit/crates/base/src/input/editor/display_map/display_map.rs
index 650cd71802..3bd072a31f 100644
--- a/third-party/gpui-kit/crates/base/src/input/editor/display_map/display_map.rs
+++ b/third-party/gpui-kit/crates/base/src/input/editor/display_map/display_map.rs
@@ -302,6 +302,13 @@ impl DisplayMap {
             .offset_to_display_point_with_affinity(offset, line_end_affinity)
     }
 
+    /// Resolve the caret's visual row through both wrapping and folded regions.
+    pub(crate) fn offset_to_display_row(&self, offset: usize, line_end_affinity: bool) -> usize {
+        let point = self.offset_to_wrap_display_point_with_affinity(offset, line_end_affinity);
+        self.wrap_row_to_display_row(point.row)
+            .unwrap_or_else(|| self.nearest_visible_display_row(point.row))
+    }
+
     /// Convert wrap display point to byte offset.
     #[inline]
     pub(crate) fn wrap_display_point_to_offset(&self, point: WrapDisplayPoint) -> usize {
diff --git a/third-party/gpui-kit/crates/base/src/input/editor/lsp/completions.rs b/third-party/gpui-kit/crates/base/src/input/editor/lsp/completions.rs
index 8c4ebfba15..a0b13d02a2 100644
--- a/third-party/gpui-kit/crates/base/src/input/editor/lsp/completions.rs
+++ b/third-party/gpui-kit/crates/base/src/input/editor/lsp/completions.rs
@@ -14,10 +14,116 @@ use crate::input::{InputBaseState, ShowCompletions};
 /// Default debounce duration for inline completions.
 const DEFAULT_INLINE_COMPLETION_DEBOUNCE: Duration = Duration::from_millis(300);
 
+/// Decides which bytes of a completion label the menu paints as the match.
+///
+/// `query` is the word typed since the menu opened. Returned ranges are byte
+/// offsets into [`CompletionItem::label`].
+pub type CompletionHighlighter = dyn Fn(&str, &CompletionItem) -> Vec<Range<usize>>;
+
+/// Highlight a prefix of the completion label, matching today's menu.
+///
+/// The painted length is [`CompletionItem::filter_text`] when the item sets
+/// one, otherwise the query. It is shortened so the range never splits a
+/// character. An empty length paints nothing.
+pub fn highlight_prefix(query: &str, item: &CompletionItem) -> Vec<Range<usize>> {
+    let matched_len = item
+        .filter_text
+        .as_ref()
+        .map(|text| text.len())
+        .unwrap_or(query.len())
+        .min(item.label.len());
+    let end = item.label.floor_char_boundary(matched_len);
+    if end == 0 { Vec::new() } else { vec![0..end] }
+}
+
+/// Highlight the first case-insensitive occurrence of `query` in the label.
+///
+/// An empty query, or a query that does not occur as a contiguous span,
+/// paints nothing.
+pub fn highlight_contains(query: &str, item: &CompletionItem) -> Vec<Range<usize>> {
+    let query: Vec<char> = query.chars().collect();
+    if query.is_empty() {
+        return Vec::new();
+    }
+
+    let label: Vec<(usize, char)> = item.label.char_indices().collect();
+    let Some(last_start) = label.len().checked_sub(query.len()) else {
+        return Vec::new();
+    };
+
+    for start in 0..=last_start {
+        let matched = query
+            .iter()
+            .zip(label[start..].iter())
+            .all(|(query_char, (_, label_char))| chars_eq_ignore_case(*query_char, *label_char));
+        if !matched {
+            continue;
+        }
+
+        let byte_start = label[start].0;
+        let (last_index, last_char) = label[start + query.len() - 1];
+        return vec![byte_start..last_index + last_char.len_utf8()];
+    }
+    Vec::new()
+}
+
+/// Highlight each character of `query` in order, skipping characters in between.
+///
+/// Matching is case-insensitive. Adjacent hits merge into one range, so a
+/// contiguous match still paints as a single span. A query that cannot be
+/// matched in full paints nothing.
+pub fn highlight_characters(query: &str, item: &CompletionItem) -> Vec<Range<usize>> {
+    if query.is_empty() {
+        return Vec::new();
+    }
+
+    let mut ranges: Vec<Range<usize>> = Vec::new();
+    let mut rest = item.label.as_str();
+    let mut base = 0usize;
+    for query_char in query.chars() {
+        let Some((index, matched)) = rest
+            .char_indices()
+            .find(|(_, label_char)| chars_eq_ignore_case(query_char, *label_char))
+        else {
+            return Vec::new();
+        };
+
+        let start = base + index;
+        let end = start + matched.len_utf8();
+        let next = index + matched.len_utf8();
+        base += next;
+        rest = &rest[next..];
+        if let Some(last) = ranges.last_mut() {
+            if last.end == start {
+                last.end = end;
+                continue;
+            }
+        }
+        ranges.push(start..end);
+    }
+    ranges
+}
+
+fn chars_eq_ignore_case(left: char, right: char) -> bool {
+    left == right
+        || left.eq_ignore_ascii_case(&right)
+        || left.to_lowercase().eq(right.to_lowercase())
+}
+
 /// Display options for the LSP completion popover.
 ///
 /// Accessed through [`super::Lsp::completion_menu`] so embedders can tweak the
-/// popover without growing the [`InputBaseState`] API.
+/// popover without growing the [`InputBaseState`] API. Label highlighting is
+/// configured separately, on [`super::Lsp::completion_highlighter`].
+///
+/// ```
+/// use gpui_base::input::CompletionMenuOptions;
+/// let options = CompletionMenuOptions {
+///     max_width: gpui::px(480.),
+/// };
+/// let _copied = options;
+/// let _again = options;
+/// ```
 #[derive(Debug, Clone, Copy)]
 pub struct CompletionMenuOptions {
     /// Maximum width of the popover.
@@ -453,3 +559,71 @@ impl InputBaseState<EditorMode> {
         true
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn item(label: &str, filter_text: Option<&str>) -> CompletionItem {
+        CompletionItem {
+            label: label.to_string(),
+            filter_text: filter_text.map(str::to_string),
+            ..Default::default()
+        }
+    }
+
+    #[test]
+    fn prefix_uses_the_query_length_unless_filter_text_is_set() {
+        assert_eq!(highlight_prefix("fl", &item("flag", None)), vec![0..2]);
+        assert_eq!(highlight_prefix("fl", &item("flag", Some("f"))), vec![0..1]);
+        assert_eq!(
+            highlight_prefix("flagpole", &item("flag", None)),
+            vec![0..4]
+        );
+        assert!(highlight_prefix("", &item("flag", None)).is_empty());
+    }
+
+    #[test]
+    fn prefix_does_not_split_a_character() {
+        assert_eq!(highlight_prefix("你", &item("你好", None)), vec![0..3]);
+        assert!(highlight_prefix("a", &item("你", None)).is_empty());
+    }
+
+    #[test]
+    fn contains_highlights_the_first_contiguous_match() {
+        // "dialog" itself ends in "log", so that hit comes before the suffix.
+        assert_eq!(
+            highlight_contains("log", &item("dialog_log", None)),
+            vec![3..6]
+        );
+        assert_eq!(
+            highlight_contains("LOG", &item("dialog_log", None)),
+            vec![3..6]
+        );
+        assert_eq!(
+            highlight_contains("log", &item("catalog", None)),
+            vec![4..7]
+        );
+        assert_eq!(
+            highlight_contains("好世", &item("你好世界", None)),
+            vec![3..9]
+        );
+        assert!(highlight_contains("log", &item("flag", None)).is_empty());
+        assert!(highlight_contains("", &item("flag", None)).is_empty());
+    }
+
+    #[test]
+    fn characters_highlight_gaps_and_merge_adjacent_hits() {
+        assert_eq!(highlight_characters("fl", &item("flag", None)), vec![0..2]);
+        assert_eq!(
+            highlight_characters("fl", &item("file_loader", None)),
+            vec![0..1, 2..3]
+        );
+        assert_eq!(
+            highlight_characters("你世", &item("你好世界", None)),
+            vec![0..3, 6..9]
+        );
+        assert!(highlight_characters("fx", &item("flag", None)).is_empty());
+        assert!(highlight_characters("", &item("flag", None)).is_empty());
+    }
+}
diff --git a/third-party/gpui-kit/crates/base/src/input/editor/lsp/mod.rs b/third-party/gpui-kit/crates/base/src/input/editor/lsp/mod.rs
index b194f44c5e..c5daa6a2a9 100644
--- a/third-party/gpui-kit/crates/base/src/input/editor/lsp/mod.rs
+++ b/third-party/gpui-kit/crates/base/src/input/editor/lsp/mod.rs
@@ -58,6 +58,12 @@ pub struct Lsp {
     /// Display options for the completion popover.
     pub completion_menu: CompletionMenuOptions,
 
+    /// Decides which bytes of each completion label the menu paints.
+    ///
+    /// Defaults to [`highlight_prefix`]. Replace it to paint a contiguous
+    /// match, a character-by-character match, or ranges the host computed.
+    completion_highlighter: Rc<CompletionHighlighter>,
+
     pub(crate) document_colors: Vec<(lsp_types::Range, Hsla)>,
     /// Cached semantic tokens as absolute position ranges + theme token-type
     /// names. Color is resolved from the name at paint time so theme switches
@@ -78,6 +84,7 @@ impl Default for Lsp {
             definition_provider: None,
             document_color_provider: None,
             completion_menu: CompletionMenuOptions::default(),
+            completion_highlighter: Rc::new(highlight_prefix),
             semantic_tokens_provider: None,
             show_document: None,
             document_colors: vec![],
@@ -91,6 +98,24 @@ impl Default for Lsp {
 }
 
 impl Lsp {
+    /// Set the completion label highlighter when constructing the LSP configuration.
+    pub fn with_completion_highlighter(mut self, highlighter: Rc<CompletionHighlighter>) -> Self {
+        self.set_completion_highlighter(highlighter);
+        self
+    }
+
+    /// Return the completion label highlighter.
+    pub fn completion_highlighter(&self) -> &Rc<CompletionHighlighter> {
+        &self.completion_highlighter
+    }
+
+    /// Replace the completion label highlighter.
+    ///
+    /// Notify the editor after updating its LSP configuration to repaint an open menu.
+    pub fn set_completion_highlighter(&mut self, highlighter: Rc<CompletionHighlighter>) {
+        self.completion_highlighter = highlighter;
+    }
+
     /// Update the LSP when the text changes.
     pub(crate) fn update(
         &mut self,
@@ -130,3 +155,19 @@ impl InputBaseState<EditorMode> {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_lsp_builder() {
+        let highlighter: Rc<CompletionHighlighter> = Rc::new(highlight_contains);
+        let mut lsp = Lsp::default().with_completion_highlighter(highlighter.clone());
+        assert!(Rc::ptr_eq(lsp.completion_highlighter(), &highlighter));
+
+        let replacement: Rc<CompletionHighlighter> = Rc::new(highlight_characters);
+        lsp.set_completion_highlighter(replacement.clone());
+        assert!(Rc::ptr_eq(lsp.completion_highlighter(), &replacement));
+    }
+}
diff --git a/third-party/gpui-kit/crates/base/src/input/editor/search.rs b/third-party/gpui-kit/crates/base/src/input/editor/search.rs
index c4f1c97d12..64680353cb 100644
--- a/third-party/gpui-kit/crates/base/src/input/editor/search.rs
+++ b/third-party/gpui-kit/crates/base/src/input/editor/search.rs
@@ -169,15 +169,31 @@ impl<M: InputModeKind> InputBaseState<M> {
     /// [`InputBaseState::previous_search_match`], read the count and the
     /// current index from [`InputBaseState::search_session`], and end the
     /// search with [`InputBaseState::close_search`].
+    ///
+    /// A changed query or case mode starts at the current viewport. Setting
+    /// the same query and case mode again retains the current occurrence.
     pub fn set_search_query(
         &mut self,
         query: impl Into<String>,
         case_insensitive: bool,
         cx: &mut Context<Self>,
     ) {
+        let query = query.into();
+        let query_changed = query != self.search_session.query
+            || case_insensitive != self.search_session.case_insensitive;
+        self.search_session.anchor_offset = if query_changed {
+            self.last_layout
+                .as_ref()
+                .map(|layout| layout.visible_range_offset.start)
+        } else {
+            None
+        };
         self.search_session.activate();
         self.search_session.update_query(query, case_insensitive);
         self.search_session.matcher.update(&self.text);
+        if query_changed && let Some(anchor) = self.search_session.anchor_offset {
+            self.search_session.matcher.update_cursor_by_offset(anchor);
+        }
         cx.notify();
     }
 
diff --git a/third-party/gpui-kit/crates/base/src/input/mod.rs b/third-party/gpui-kit/crates/base/src/input/mod.rs
index 23ec550e83..4ae8d6bfd5 100644
--- a/third-party/gpui-kit/crates/base/src/input/mod.rs
+++ b/third-party/gpui-kit/crates/base/src/input/mod.rs
@@ -112,10 +112,11 @@ pub use language::{LanguageProvider, set_language_config, set_language_provider}
 pub(crate) use language_config::LanguageConfig;
 pub use language_config::{AutoClosingPair, BracketPair, IndentationRules};
 pub use lsp::{
-    CodeActionItem, CodeActionMenuState, CodeActionProvider, CompletionMenuOptions,
-    CompletionMenuState, CompletionProvider, DefinitionProvider, DocumentColorProvider,
-    DocumentRangeSemanticTokensProvider, HoverPopoverState, HoverProvider, InputOverlayKind, Lsp,
-    ShowDocumentHandler,
+    CodeActionItem, CodeActionMenuState, CodeActionProvider, CompletionHighlighter,
+    CompletionMenuOptions, CompletionMenuState, CompletionProvider, DefinitionProvider,
+    DocumentColorProvider, DocumentRangeSemanticTokensProvider, HoverPopoverState, HoverProvider,
+    InputOverlayKind, Lsp, ShowDocumentHandler, highlight_characters, highlight_contains,
+    highlight_prefix,
 };
 pub use lsp_types::Position;
 pub use mask_pattern::MaskPattern;
diff --git a/third-party/gpui-kit/crates/base/src/lib.rs b/third-party/gpui-kit/crates/base/src/lib.rs
index 0749787ae4..6f960fb494 100644
--- a/third-party/gpui-kit/crates/base/src/lib.rs
+++ b/third-party/gpui-kit/crates/base/src/lib.rs
@@ -9,6 +9,7 @@ extern crate gpui_fast as gpui;
 #[cfg(all(feature = "gpui-fast", test))]
 extern crate gpui_fast_platform as gpui_platform;
 
+mod a11y_disabled;
 mod accordion;
 pub mod actions;
 mod alert_dialog;
@@ -46,6 +47,7 @@ mod nav_stack;
 mod number_input;
 mod observe;
 mod otp_input;
+mod overscroll;
 mod pagination;
 pub mod plot;
 mod popover;
diff --git a/third-party/gpui-kit/crates/base/src/link.rs b/third-party/gpui-kit/crates/base/src/link.rs
index bbcfe13804..56aa4de827 100644
--- a/third-party/gpui-kit/crates/base/src/link.rs
+++ b/third-party/gpui-kit/crates/base/src/link.rs
@@ -8,7 +8,7 @@ use gpui::{
 };
 use smallvec::SmallVec;
 
-use crate::{StateStyle, StyledExt as _};
+use crate::{StateStyle, StyledExt as _, a11y_disabled::A11yDisabledExt as _};
 
 type ActivationHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
 type OpenHandler = Rc<dyn Fn(&str, &ClickEvent, &mut Window, &mut App)>;
@@ -199,6 +199,7 @@ impl RenderOnce for Link {
             })
             .children(self.children)
             .refine_style(&style)
+            .a11y_disabled(disabled)
     }
 }
 
@@ -461,6 +462,7 @@ mod tests {
 
         assert_eq!(disabled.role(), Role::Link);
         assert!(!disabled.supports_action(accesskit::Action::Click));
-        assert!(!disabled.is_disabled());
+        assert!(!enabled.is_disabled());
+        assert!(disabled.is_disabled());
     }
 }
diff --git a/third-party/gpui-kit/crates/base/src/motion.rs b/third-party/gpui-kit/crates/base/src/motion.rs
index 3c92f914d5..998ddfe4f5 100644
--- a/third-party/gpui-kit/crates/base/src/motion.rs
+++ b/third-party/gpui-kit/crates/base/src/motion.rs
@@ -331,7 +331,7 @@ where
         } else {
             1.0
         };
-        let duration = policy.duration.mul_f32(reversing_factor);
+        let duration = policy.duration.mul_f64(f64::from(reversing_factor));
         state.update(cx, |state, _| {
             state.from = sampled.clone();
             state.target = target.clone();
diff --git a/third-party/gpui-kit/crates/base/src/motion/presence.rs b/third-party/gpui-kit/crates/base/src/motion/presence.rs
index 5522a83d3f..68bce29d0d 100644
--- a/third-party/gpui-kit/crates/base/src/motion/presence.rs
+++ b/third-party/gpui-kit/crates/base/src/motion/presence.rs
@@ -88,7 +88,10 @@ impl Presence {
             } else {
                 1.0
             };
-            let duration = self.transition.duration.mul_f32(reversing_factor);
+            let duration = self
+                .transition
+                .duration
+                .mul_f64(f64::from(reversing_factor));
             state.update(cx, |state, _| {
                 state.from = sampled;
                 state.target = target;
diff --git a/third-party/gpui-kit/crates/base/src/number_input.rs b/third-party/gpui-kit/crates/base/src/number_input.rs
index b3c53dc19a..bd3a9bc46b 100644
--- a/third-party/gpui-kit/crates/base/src/number_input.rs
+++ b/third-party/gpui-kit/crates/base/src/number_input.rs
@@ -4,8 +4,8 @@ use std::rc::Rc;
 use gpui::Focusable;
 use gpui::{
     AnyElement, App, Entity, EventEmitter, InteractiveElement as _, IntoElement, KeyBinding,
-    ParentElement, RenderOnce, Role, StatefulInteractiveElement as _, StyleRefinement, Styled,
-    Window, actions, div, prelude::FluentBuilder as _,
+    ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
+    StyleRefinement, Styled, Window, actions, div, prelude::FluentBuilder as _,
 };
 
 use crate::{Button, InputBase, StyledExt as _};
@@ -105,6 +105,7 @@ pub struct NumberInput {
     increment_button: Option<ButtonDecorator>,
     input: Option<AnyElement>,
     controls_right: bool,
+    accessibility_label: Option<SharedString>,
 }
 
 /// The built-in text region of a [`NumberInput`].
@@ -166,6 +167,7 @@ impl NumberInput {
             increment_button: None,
             input: None,
             controls_right: false,
+            accessibility_label: None,
         }
     }
 
@@ -205,6 +207,15 @@ impl NumberInput {
         self.controls_right = true;
         self
     }
+
+    /// Sets the name exposed to accessibility clients.
+    ///
+    /// A number input has no visible text of its own to be named by, so set
+    /// this unless the input is named some other way.
+    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
+        self.accessibility_label = Some(label.into());
+        self
+    }
 }
 
 impl Styled for NumberInput {
@@ -302,6 +313,9 @@ impl RenderOnce for NumberInput {
             .items_center()
             .disabled(disabled)
             .role(Role::SpinButton)
+            .when_some(self.accessibility_label, |this, label| {
+                this.aria_label(label)
+            })
             .when_some(value, |this, value| this.aria_numeric_value(value))
             .key_context(CONTEXT)
             .on_action({
diff --git a/third-party/gpui-kit/crates/base/src/overscroll.rs b/third-party/gpui-kit/crates/base/src/overscroll.rs
new file mode 100644
index 0000000000..9c9389966f
--- /dev/null
+++ b/third-party/gpui-kit/crates/base/src/overscroll.rs
@@ -0,0 +1,463 @@
+//! Boundary displacement shared by inputs and scroll viewports.
+//!
+//! Viewport owners keep their logical scroll position clamped and apply this
+//! displacement only to content geometry. Inputs drive it directly, while
+//! `ScrollBounce` can own a list or scroll area's vertical wheel handling.
+
+use crate::scroll_bounce::Physics;
+use gpui::TouchPhase;
+use std::time::Duration;
+#[cfg(not(target_family = "wasm"))]
+use std::time::Instant;
+#[cfg(target_family = "wasm")]
+use web_time::Instant;
+
+/// A pause without packets that ends a gesture.
+///
+/// Momentum arrives once per frame with no phase of its own, and some precise
+/// scrolling devices send no phases, so a pause marks the end of their stream.
+/// `ScrollBounce` waits the same time for the same reason.
+const GESTURE_GAP: Duration = Duration::from_millis(250);
+
+/// The fraction of the text's speed the edge carries when the text runs into
+/// it or a finger lets go of it. Measured on `NSScrollView`, and the same as
+/// WebKit's rubber-band amplitude: a fling arriving at 5000 px/s throws the
+/// edge about 45 px out, 80 ms before it turns back.
+const CARRY: f32 = 0.31;
+
+/// A finger that sends nothing for this long before lifting has stopped:
+/// it lets go of the edge with no speed.
+const STILL: Duration = Duration::from_millis(50);
+
+/// The interval assumed for the first packet of a stream.
+const FRAME: f32 = 1. / 60.;
+
+/// How far back the stream's speed is read. Packets carry no timestamp, so a
+/// frame that takes long hands over several at once; read over a span, their
+/// distance still spreads over the time they took, instead of each one
+/// arriving at once and reading as a huge speed. That happens most on a fling
+/// through a long list, just as it runs into the top.
+const SPEED_SPAN: Duration = Duration::from_millis(50);
+
+#[derive(Default)]
+pub(crate) struct Overscroll {
+    physics: Physics,
+    sampled_at: Option<Instant>,
+    last_packet_at: Option<Instant>,
+    /// When the stream last moved, and how fast, in pixels per second.
+    moved_at: Option<Instant>,
+    velocity: f32,
+    /// The packets that moved within the last `SPEED_SPAN`, and the one
+    /// before them, which marks where the span starts.
+    moves: Vec<(Instant, f32)>,
+    /// A finger rests on the trackpad but has not moved since it touched.
+    resting: bool,
+    /// The edge already answered the rest of this gesture: a fling ran into
+    /// it, or a finger let go of it stretched. The momentum that follows
+    /// still scrolls the text back from the edge, but no longer moves the
+    /// edge.
+    spent: bool,
+}
+
+impl Overscroll {
+    pub(crate) fn set_motion(&mut self, motion: crate::ScrollBounceMotion) {
+        self.physics.set_motion(motion);
+    }
+    /// How far the text is drawn from where it scrolled to.
+    pub(crate) fn offset(&self) -> f32 {
+        self.physics.offset()
+    }
+
+    /// A new gesture starts: it may stretch the edge again.
+    fn start_gesture(&mut self) {
+        self.spent = false;
+        self.velocity = 0.;
+        self.moves.clear();
+    }
+
+    /// Track the stream's speed from packets that moved: the distance they
+    /// covered over the time since the span started.
+    fn sample(&mut self, dy: f32, now: Instant) {
+        if dy == 0. {
+            return;
+        }
+        if self.moves.last().is_some_and(|&(_, last)| last * dy < 0.) {
+            self.moves.clear();
+        }
+        self.moves.push((now, dy));
+        let start = self
+            .moves
+            .iter()
+            .rposition(|&(at, _)| now.saturating_duration_since(at) > SPEED_SPAN)
+            .unwrap_or(0);
+        self.moves.drain(..start);
+        let (first, _) = self.moves[0];
+        let seconds = now.saturating_duration_since(first).as_secs_f32();
+        self.velocity = if seconds > 0. && self.moves.len() > 1 {
+            self.moves[1..].iter().map(|&(_, dy)| dy).sum::<f32>() / seconds
+        } else {
+            // The first packet, or several handed over at once.
+            self.moves.iter().map(|&(_, dy)| dy).sum::<f32>() / FRAME
+        };
+        self.moved_at = Some(now);
+    }
+
+    /// Handle one vertical packet of `dy` over a viewport `extent` tall.
+    /// `scroll` moves the text by a delta and returns how much of it the text
+    /// used. Returns whether the stretch changed.
+    pub(crate) fn packet(
+        &mut self,
+        dy: f32,
+        phase: TouchPhase,
+        extent: f32,
+        now: Instant,
+        mut scroll: impl FnMut(f32) -> f32,
+    ) -> bool {
+        let before = self.offset();
+        let mut thrown = false;
+        let gap = self
+            .last_packet_at
+            .replace(now)
+            .map(|at| now.saturating_duration_since(at))
+            .filter(|gap| *gap < GESTURE_GAP);
+        // A finger resting on the trackpad sends nothing, however long it
+        // holds the edge; only its own `Ended` lets go. A pause ends the
+        // other streams.
+        if gap.is_none() && !self.physics.is_dragging() {
+            self.start_gesture();
+            self.moved_at = None;
+        }
+        self.sample(dy, now);
+        if phase == TouchPhase::Started {
+            // Grab the edge, a returning one included, where it is.
+            self.physics.begin(extent);
+            self.spent = false;
+            self.resting = dy == 0.;
+        } else if dy != 0. {
+            self.resting = false;
+        } else if self.resting && self.physics.is_dragging() {
+            // A finger that touches and lifts without moving cancels its
+            // gesture, and GPUI reports the cancel as a move of nothing: it
+            // lets go of the edge where it held it.
+            self.resting = false;
+            self.physics.let_go(0.);
+            self.spent = self.offset() != 0.;
+            if self.spent {
+                self.sampled_at = Some(now);
+            }
+            return self.physics.is_moving();
+        }
+        if self.physics.is_dragging() {
+            // A stretched edge takes the packet first; only what crosses back
+            // over the edge scrolls the text.
+            let rest = if self.offset() != 0. {
+                self.physics.pull(dy)
+            } else {
+                dy
+            };
+            if rest != 0. {
+                let unused = rest - scroll(rest);
+                if unused != 0. {
+                    self.physics.pull(unused);
+                }
+            }
+            if matches!(phase, TouchPhase::Ended | TouchPhase::Cancelled) {
+                let moving = self
+                    .moved_at
+                    .is_some_and(|at| now.saturating_duration_since(at) < STILL);
+                let velocity = if moving { self.velocity } else { 0. };
+                self.physics.let_go(velocity * CARRY);
+                thrown = true;
+                self.spent = self.offset() != 0.;
+            }
+        } else if self.spent {
+            // Momentum after the edge answered: back into the text it
+            // scrolls; against the edge it has nothing left to move.
+            scroll(dy);
+        } else {
+            // Momentum or phaseless precise scrolling: it scrolls the text,
+            // and when the text runs into the edge it throws the edge as
+            // fast as it arrived, once.
+            let unused = dy - scroll(dy);
+            if unused != 0. {
+                self.physics.let_go(self.velocity * CARRY);
+                thrown = true;
+                self.spent = true;
+            }
+        }
+        let changed = (thrown && self.physics.is_moving()) || self.offset() != before;
+        // Start the clock when the edge starts moving, and again when it is
+        // let go, however long it was held; a packet that arrives while it
+        // moves must not lose the time since the last frame.
+        if thrown || (changed && self.sampled_at.is_none()) {
+            self.sampled_at = Some(now);
+        }
+        changed
+    }
+
+    /// Advance the return to `now`. Returns whether it is still moving.
+    pub(crate) fn step(&mut self, now: Instant) -> bool {
+        let elapsed = self
+            .sampled_at
+            .replace(now)
+            .map_or(0., |at| now.saturating_duration_since(at).as_secs_f32());
+        let moving = self.physics.step(elapsed);
+        if !moving {
+            self.sampled_at = None;
+        }
+        moving
+    }
+
+    /// Drop any stretch at once, as when bouncing is turned off.
+    pub(crate) fn reset(&mut self) {
+        let mut physics = std::mem::take(&mut self.physics);
+        physics.reset();
+        *self = Self {
+            physics,
+            ..Self::default()
+        };
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::time::Duration;
+
+    /// Text that can scroll `room` further toward its end.
+    fn text(room: &mut f32) -> impl FnMut(f32) -> f32 + '_ {
+        move |dy: f32| {
+            let used = dy.max(-*room);
+            *room += used;
+            used
+        }
+    }
+
+    #[test]
+    fn a_swipe_past_the_end_stretches_and_springs_back() {
+        let now = Instant::now();
+        let mut overscroll = Overscroll::default();
+        let mut room = 30.;
+        overscroll.packet(-10., TouchPhase::Started, 400., now, text(&mut room));
+        overscroll.packet(-100., TouchPhase::Moved, 400., now, text(&mut room));
+        // The text used the 30px it had left; the rest stretches the edge.
+        assert_eq!(room, 0.);
+        let stretched = overscroll.offset();
+        assert!(stretched < 0. && stretched > -80., "{stretched}");
+
+        overscroll.packet(0., TouchPhase::Ended, 400., now, text(&mut room));
+        // Momentum after the release is answered by the spring alone.
+        assert!(!overscroll.packet(-40., TouchPhase::Moved, 400., now, text(&mut room)));
+        let mut later = now;
+        while overscroll.step(later) {
+            later += Duration::from_millis(16);
+        }
+        assert_eq!(overscroll.offset(), 0.);
+    }
+
+    #[test]
+    fn pulling_back_unstretches_before_scrolling_the_text() {
+        let now = Instant::now();
+        let mut overscroll = Overscroll::default();
+        let mut room = 0.;
+        overscroll.packet(0., TouchPhase::Started, 400., now, text(&mut room));
+        overscroll.packet(-50., TouchPhase::Moved, 400., now, text(&mut room));
+        assert!(overscroll.offset() < 0.);
+        // Back the other way: the first 50px return the edge, the next 20px
+        // scroll the text back from its end.
+        let mut scrolled = 0.;
+        overscroll.packet(70., TouchPhase::Moved, 400., now, |dy| {
+            scrolled += dy;
+            dy
+        });
+        assert_eq!(overscroll.offset(), 0.);
+        assert_eq!(scrolled, 20.);
+    }
+
+    /// A momentum stream of `speed` px/s decaying as AppKit's does, against
+    /// text already at its end. Returns the edge's offset at each frame.
+    fn fling(speed: f32) -> Vec<f32> {
+        fling_through(speed, 0., 0)
+    }
+
+    /// The same stream through `room` px of text first, with every packet
+    /// from frame `stall` to the impact handed over in one go, as when the
+    /// frames that scroll the text take long to draw.
+    fn fling_through(speed: f32, mut room: f32, stall: u32) -> Vec<f32> {
+        let start = Instant::now();
+        let frame = Duration::from_micros(16_667);
+        let mut overscroll = Overscroll::default();
+        let mut dy = -speed / 60.;
+        let mut held = Vec::new();
+        let mut path = Vec::new();
+        for n in 0..120 {
+            let at = start + frame * n;
+            // Momentum keeps arriving well after the edge has settled.
+            if n < 100 {
+                held.push(dy);
+                dy *= 0.95;
+                if stall == 0 || n < stall || room == 0. || -held.iter().sum::<f32>() > room {
+                    for dy in held.drain(..) {
+                        overscroll.packet(dy, TouchPhase::Moved, 300., at, text(&mut room));
+                    }
+                }
+            }
+            overscroll.step(at);
+            path.push(overscroll.offset());
+        }
+        path
+    }
+
+    #[cfg(not(any(target_os = "ios", target_os = "android")))]
+    #[test]
+    fn a_fling_throws_the_edge_as_far_as_nsscrollview_does() {
+        // Measured on NSScrollView: a fling arriving at 1500 px/s peaks about
+        // 14 px out, roughly 80 ms after impact, and settles within 0.65 s.
+        let path = fling(1500.);
+        let peak = path.iter().copied().fold(0., f32::min);
+        assert!((-16. ..=-12.).contains(&peak), "peak {peak}");
+        let turned = path.iter().position(|&offset| offset == peak).unwrap();
+        assert!((4..=6).contains(&turned), "turned at frame {turned}");
+        assert_eq!(path[50], 0., "the edge settles");
+        // The momentum still arriving after impact never throws it again.
+        assert!(path[turned..].windows(2).all(|pair| pair[1] >= pair[0]));
+        assert!(path[50..].iter().all(|&offset| offset == 0.));
+        // Twice the speed throws it about twice as far.
+        let faster = fling(3000.).iter().copied().fold(0., f32::min);
+        assert!((faster / peak - 2.).abs() < 0.15, "{faster} vs {peak}");
+        // But no fling throws it past the limit, as none does in Finder.
+        let hardest = fling(20_000.).iter().copied().fold(0., f32::min);
+        assert!((-36. ..=-34.).contains(&hardest), "{hardest}");
+    }
+
+    #[cfg(not(any(target_os = "ios", target_os = "android")))]
+    #[test]
+    fn packets_handed_over_at_once_do_not_throw_the_edge_further() {
+        let peak = |path: Vec<f32>| path.iter().copied().fold(0., f32::min);
+        let even = peak(fling_through(4000., 500., 0));
+        let stalled = peak(fling_through(4000., 500., 3));
+        assert!(even < -10., "{even}");
+        // Read per packet, the burst threw it out to the limit. Read over
+        // the span, only the longer span's slower decay shows.
+        assert!((stalled / even - 1.).abs() < 0.25, "{stalled} vs {even}");
+    }
+
+    #[test]
+    fn a_touch_that_lifts_without_moving_lets_go_of_the_edge() {
+        let start = Instant::now();
+        let mut overscroll = Overscroll::default();
+        let mut room = 0.;
+        overscroll.packet(-80., TouchPhase::Moved, 400., start, text(&mut room));
+        let later = start + Duration::from_millis(30);
+        overscroll.step(later);
+        let held = overscroll.offset();
+        // Touch, then lift: GPUI reports `MayBegin` as `Started` and the
+        // cancel as a `Moved` of nothing.
+        overscroll.packet(0., TouchPhase::Started, 400., later, text(&mut room));
+        assert!(overscroll.packet(0., TouchPhase::Moved, 400., later, text(&mut room)));
+        let mut at = later;
+        while overscroll.step(at) {
+            at += Duration::from_millis(16);
+        }
+        assert!(held < 0.);
+        assert_eq!(overscroll.offset(), 0.);
+    }
+
+    #[cfg(not(any(target_os = "ios", target_os = "android")))]
+    #[test]
+    fn a_stretch_follows_the_travel_as_nsscrollview_does() {
+        // Measured on a 300 px NSScrollView: 100 px of travel past the end
+        // stretches it 7 px. Further on, AppKit reads unaccelerated travel
+        // and Finder never goes past 36 px; the stretch eases into that.
+        let stretch = |travel: f32| {
+            let start = Instant::now();
+            let mut overscroll = Overscroll::default();
+            let mut room = 0.;
+            overscroll.packet(0., TouchPhase::Started, 300., start, text(&mut room));
+            overscroll.packet(-travel, TouchPhase::Moved, 300., start, text(&mut room));
+            -overscroll.offset()
+        };
+        assert!((6.5..=8.).contains(&stretch(100.)), "{}", stretch(100.));
+        assert!((28. ..=34.).contains(&stretch(764.)), "{}", stretch(764.));
+        assert!((35. ..36.).contains(&stretch(2400.)), "{}", stretch(2400.));
+        assert!(stretch(100_000.) < 36.);
+    }
+
+    #[cfg(not(any(target_os = "ios", target_os = "android")))]
+    #[test]
+    fn a_still_release_returns_at_full_speed() {
+        // NSScrollView lets go of a stretch held still and halves it in
+        // 55 ms, starting at once rather than easing out.
+        let start = Instant::now();
+        let mut overscroll = Overscroll::default();
+        let mut room = 0.;
+        overscroll.packet(0., TouchPhase::Started, 300., start, text(&mut room));
+        overscroll.packet(-800., TouchPhase::Moved, 300., start, text(&mut room));
+        let held = overscroll.offset();
+        let lifted = start + Duration::from_millis(200);
+        overscroll.packet(0., TouchPhase::Ended, 300., lifted, text(&mut room));
+        overscroll.step(lifted + Duration::from_millis(8));
+        assert!(overscroll.offset() / held < 0.92, "starts at once");
+        overscroll.step(lifted + Duration::from_millis(55));
+        let half = overscroll.offset() / held;
+        assert!((0.45..=0.55).contains(&half), "{half}");
+    }
+
+    #[test]
+    fn after_a_pause_the_next_flick_throws_the_edge_again() {
+        let start = Instant::now();
+        let mut overscroll = Overscroll::default();
+        let mut room = 0.;
+        assert!(overscroll.packet(-40., TouchPhase::Moved, 400., start, text(&mut room)));
+        let mut at = start;
+        while overscroll.step(at) {
+            at += Duration::from_millis(16);
+        }
+        let later = at + GESTURE_GAP;
+        assert!(overscroll.packet(-40., TouchPhase::Moved, 400., later, text(&mut room)));
+    }
+
+    #[test]
+    fn momentum_back_into_the_text_scrolls_while_the_edge_returns() {
+        let start = Instant::now();
+        let mut overscroll = Overscroll::default();
+        let mut room = 0.;
+        overscroll.packet(0., TouchPhase::Started, 400., start, text(&mut room));
+        overscroll.packet(-800., TouchPhase::Moved, 400., start, text(&mut room));
+        let lifted = start + Duration::from_millis(100);
+        overscroll.packet(0., TouchPhase::Ended, 400., lifted, text(&mut room));
+        let later = lifted + Duration::from_millis(16);
+        overscroll.step(later);
+        let before = overscroll.offset();
+        assert!(before < 0.);
+        overscroll.packet(20., TouchPhase::Moved, 400., later, text(&mut room));
+        assert_eq!(room, 20.);
+        assert_eq!(overscroll.offset(), before, "the edge does not jump");
+        overscroll.step(later + Duration::from_millis(16));
+        assert!(overscroll.offset() > before && overscroll.offset() < 0.);
+    }
+
+    #[test]
+    fn a_new_precise_gesture_takes_over_without_jumping() {
+        let start = Instant::now();
+        let mut overscroll = Overscroll::default();
+        let mut room = 0.;
+        overscroll.packet(-80., TouchPhase::Moved, 400., start, text(&mut room));
+        overscroll.step(start + Duration::from_millis(16));
+        let later = start + Duration::from_millis(200);
+        overscroll.step(later);
+        assert!(overscroll.offset() < 0.);
+        let before = overscroll.offset();
+        overscroll.packet(0., TouchPhase::Started, 400., later, text(&mut room));
+        assert!(
+            (overscroll.offset() - before).abs() < 0.001,
+            "interruption does not jump"
+        );
+        assert!(overscroll.packet(-40., TouchPhase::Moved, 400., later, text(&mut room)));
+        overscroll.step(later + Duration::from_millis(16));
+        assert!(
+            overscroll.offset() < before,
+            "the new input pushes outward again"
+        );
+    }
+}
diff --git a/third-party/gpui-kit/crates/base/src/popover.rs b/third-party/gpui-kit/crates/base/src/popover.rs
index 1d814e78dc..8a5cdfac1b 100644
--- a/third-party/gpui-kit/crates/base/src/popover.rs
+++ b/third-party/gpui-kit/crates/base/src/popover.rs
@@ -13,12 +13,19 @@ use crate::{
 };
 
 const CONTEXT: &str = "Popover";
+const CONTENT_CONTEXT: &str = "PopoverContent";
 
 pub(crate) fn init(cx: &mut App) {
     cx.bind_keys([
         KeyBinding::new("escape", Cancel, Some(CONTEXT)),
         KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
         KeyBinding::new("space", Confirm { secondary: false }, Some(CONTEXT)),
+        KeyBinding::new("escape", Cancel, Some(CONTENT_CONTEXT)),
+        // The content is still a descendant of the trigger's dispatch node.
+        // Leave activation to its controls, not the trigger or an outer dialog.
+        // More deeply scoped input bindings retain precedence.
+        KeyBinding::new("enter", gpui::NoAction, Some(CONTENT_CONTEXT)),
+        KeyBinding::new("space", gpui::NoAction, Some(CONTENT_CONTEXT)),
     ]);
 }
 
@@ -168,6 +175,10 @@ type ContentBuilder =
     Box<dyn FnOnce(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> AnyElement>;
 
 /// An unstyled popover owning trigger interaction, open state, dismissal, and focus.
+///
+/// Enter and Space on the trigger toggle the popover. Inside its content, those
+/// keys belong to the focused control instead; Escape dismisses the popover and
+/// restores the previous focus.
 #[derive(IntoElement)]
 pub struct Popover {
     id: ElementId,
@@ -357,7 +368,7 @@ impl RenderOnce for Popover {
             .occlude()
             .tab_group()
             .track_focus(&focus_handle)
-            .key_context(CONTEXT)
+            .key_context(CONTENT_CONTEXT)
             .on_action(window.listener_for(&state, PopoverState::on_action_cancel))
             .when_some(self.content, |this, content| {
                 this.child(state.update(cx, |state, cx| (content)(state, window, cx)))
diff --git a/third-party/gpui-kit/crates/base/src/radio.rs b/third-party/gpui-kit/crates/base/src/radio.rs
index 1940f655c0..dc9424dbc6 100644
--- a/third-party/gpui-kit/crates/base/src/radio.rs
+++ b/third-party/gpui-kit/crates/base/src/radio.rs
@@ -8,7 +8,7 @@ use gpui::{
 };
 use smallvec::SmallVec;
 
-use crate::{StateStyle, StyledExt as _, TestSupportExt as _};
+use crate::{StateStyle, StyledExt as _, TestSupportExt as _, a11y_disabled::A11yDisabledExt as _};
 
 type ChangeHandler = Rc<dyn Fn(bool, &ClickEvent, &mut Window, &mut App)>;
 
@@ -234,6 +234,7 @@ impl RenderOnce for Radio {
             )
             .children(self.children)
             .refine_style(&style)
+            .a11y_disabled(disabled)
     }
 }
 
diff --git a/third-party/gpui-kit/crates/base/src/root.rs b/third-party/gpui-kit/crates/base/src/root.rs
index cee3940567..35d190c38b 100644
--- a/third-party/gpui-kit/crates/base/src/root.rs
+++ b/third-party/gpui-kit/crates/base/src/root.rs
@@ -7,6 +7,29 @@ use gpui::{
 };
 use std::{any::TypeId, rc::Rc};
 
+/// Scan at most one cycle of the rendered tab order, preserving containment
+/// regardless of how many controls exist behind a modal. GPUI exposes traversal
+/// but not candidate enumeration. This scan is synchronous: only the final
+/// focus path is published at the next draw, not the background candidates.
+fn advance_focus(window: &mut Window, cx: &mut App, advance: fn(&mut Window, &mut App)) {
+    let Some(container) = crate::active_focus_trap(window, cx) else {
+        advance(window, cx);
+        return;
+    };
+
+    advance(window, cx);
+    let first_candidate = window.focused(cx);
+    while !container.contains_focused(window, cx) {
+        advance(window, cx);
+        if window.focused(cx) == first_candidate {
+            // The trap has no rendered tab stops. Its own handle remains a
+            // safe focus target even when excluded from ordinary tab order.
+            container.focus(window, cx);
+            break;
+        }
+    }
+}
+
 /// A presentation layer's retained, per-window facilities.
 ///
 /// Register during explicit application initialization, before creating windows.
@@ -252,73 +275,11 @@ impl Root {
     }
 
     fn focus_next(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        // Check if we're inside a focus trap
-        if let Some(container_focus_handle) = crate::active_focus_trap(window, cx) {
-            // We're in a focus trap - try to focus next, then check if we're still inside
-            let before_focus = window.focused(cx);
-
-            // Try normal focus navigation
-            window.focus_next(cx);
-
-            // Check if we're still in the trap
-            if !container_focus_handle.contains_focused(window, cx) {
-                // We jumped out of the trap - need to cycle back to the beginning
-                // Find the first focusable element in the trap by continuing to focus_next
-                let mut attempts = 0;
-                const MAX_ATTEMPTS: usize = 100; // Prevent infinite loop
-
-                while !container_focus_handle.contains_focused(window, cx)
-                    && attempts < MAX_ATTEMPTS
-                {
-                    window.focus_next(cx);
-                    attempts += 1;
-
-                    // If we cycled back to where we started, restore original focus
-                    if window.focused(cx) == before_focus {
-                        break;
-                    }
-                }
-            }
-            return;
-        }
-
-        // Normal tab navigation
-        window.focus_next(cx);
+        advance_focus(window, cx, Window::focus_next);
     }
 
     fn focus_prev(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        // Check if we're inside a focus trap
-        if let Some(container_focus_handle) = crate::active_focus_trap(window, cx) {
-            // We're in a focus trap - try to focus previous, then check if we're still inside
-            let before_focus = window.focused(cx);
-
-            // Try normal focus navigation
-            window.focus_prev(cx);
-
-            // Check if we're still in the trap
-            if !container_focus_handle.contains_focused(window, cx) {
-                // We jumped out of the trap - need to cycle back to the end
-                // Find the last focusable element in the trap by continuing to focus_prev
-                let mut attempts = 0;
-                const MAX_ATTEMPTS: usize = 100; // Prevent infinite loop
-
-                while !container_focus_handle.contains_focused(window, cx)
-                    && attempts < MAX_ATTEMPTS
-                {
-                    window.focus_prev(cx);
-                    attempts += 1;
-
-                    // If we cycled back to where we started, restore original focus
-                    if window.focused(cx) == before_focus {
-                        break;
-                    }
-                }
-            }
-            return;
-        }
-
-        // Normal tab navigation
-        window.focus_prev(cx);
+        advance_focus(window, cx, Window::focus_prev);
     }
 
     fn copy_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
@@ -371,7 +332,183 @@ impl Render for Root {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use gpui::TestAppContext;
+    use crate::FocusTrapElement as _;
+    use gpui::{FocusHandle, Subscription, TestAppContext, VisualTestContext, px};
+    use std::cell::Cell;
+
+    struct TrapContent {
+        background: Vec<FocusHandle>,
+        container: FocusHandle,
+        controls: Vec<FocusHandle>,
+        _focus_subscriptions: Vec<Subscription>,
+    }
+
+    impl Render for TrapContent {
+        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+            div()
+                .tab_group()
+                .size_full()
+                .children(self.background.iter().enumerate().map(|(index, handle)| {
+                    div()
+                        .id(("background", index))
+                        .track_focus(handle)
+                        .tab_index(0)
+                        .size(px(1.))
+                }))
+                .child(
+                    div()
+                        .id("trap")
+                        .track_focus(&self.container)
+                        .tab_stop(false)
+                        .children(self.controls.iter().enumerate().map(|(index, handle)| {
+                            div()
+                                .id(("control", index))
+                                .track_focus(handle)
+                                .tab_index(0)
+                                .size(px(1.))
+                        }))
+                        .focus_trap("modal", &self.container),
+                )
+        }
+    }
+
+    fn trap_harness(
+        cx: &mut TestAppContext,
+        control_count: usize,
+    ) -> (
+        &mut VisualTestContext,
+        FocusHandle,
+        Vec<FocusHandle>,
+        Vec<FocusHandle>,
+        Rc<Cell<usize>>,
+    ) {
+        cx.update(crate::init);
+        let (container, controls, background) = cx.update(|cx| {
+            (
+                cx.focus_handle(),
+                (0..control_count)
+                    .map(|_| cx.focus_handle().tab_stop(true))
+                    .collect::<Vec<_>>(),
+                // Regression: traversing past the modal must cross more than
+                // the former 100-attempt limit before reaching its other end.
+                (0..256)
+                    .map(|_| cx.focus_handle().tab_stop(true))
+                    .collect::<Vec<_>>(),
+            )
+        });
+        let background_focuses = Rc::new(Cell::new(0));
+        let (_, cx) = cx.add_window_view({
+            let container = container.clone();
+            let controls = controls.clone();
+            let background = background.clone();
+            let background_focuses = background_focuses.clone();
+            move |window, cx| {
+                let content = cx.new(|cx| {
+                    let subscriptions = background
+                        .iter()
+                        .map(|handle| {
+                            let focuses = background_focuses.clone();
+                            cx.on_focus(handle, window, move |_, _, _| {
+                                focuses.set(focuses.get() + 1)
+                            })
+                        })
+                        .collect();
+                    TrapContent {
+                        background,
+                        container,
+                        controls,
+                        _focus_subscriptions: subscriptions,
+                    }
+                });
+                Root::new(content, window, cx)
+            }
+        });
+        cx.update(|window, _| window.activate_window());
+        cx.executor().run_until_parked();
+        cx.update(|window, cx| window.draw(cx).clear(cx));
+        (cx, container, controls, background, background_focuses)
+    }
+
+    fn assert_trapped_focus(
+        cx: &mut VisualTestContext,
+        container: &FocusHandle,
+        expected: &FocusHandle,
+    ) {
+        cx.update(|window, cx| {
+            window.draw(cx).clear(cx);
+            assert_eq!(window.focused(cx).as_ref(), Some(expected));
+            assert!(container.contains_focused(window, cx));
+        });
+    }
+
+    #[gpui::test]
+    fn focus_trap_wraps_forward_past_more_than_100_background_stops(cx: &mut TestAppContext) {
+        let (cx, container, controls, _, background_focuses) = trap_harness(cx, 2);
+        cx.update(|window, cx| {
+            controls[1].focus(window, cx);
+            window.draw(cx).clear(cx);
+        });
+
+        cx.dispatch_keystrokes("tab");
+        assert_trapped_focus(cx, &container, &controls[0]);
+        cx.dispatch_keystrokes("tab");
+        assert_trapped_focus(cx, &container, &controls[1]);
+        assert_eq!(background_focuses.get(), 0);
+    }
+
+    #[gpui::test]
+    fn focus_trap_wraps_backward_past_more_than_100_background_stops(cx: &mut TestAppContext) {
+        let (cx, container, controls, _, background_focuses) = trap_harness(cx, 2);
+        cx.update(|window, cx| {
+            controls[0].focus(window, cx);
+            window.draw(cx).clear(cx);
+        });
+
+        cx.dispatch_keystrokes("shift-tab");
+        assert_trapped_focus(cx, &container, &controls[1]);
+        cx.dispatch_keystrokes("shift-tab");
+        assert_trapped_focus(cx, &container, &controls[0]);
+        assert_eq!(background_focuses.get(), 0);
+    }
+
+    #[gpui::test]
+    fn focus_trap_without_tab_stops_keeps_its_container_focused(cx: &mut TestAppContext) {
+        let (cx, container, _, _, background_focuses) = trap_harness(cx, 0);
+        cx.update(|window, cx| {
+            container.focus(window, cx);
+            window.draw(cx).clear(cx);
+        });
+
+        cx.dispatch_keystrokes("tab");
+        assert_trapped_focus(cx, &container, &container);
+        cx.dispatch_keystrokes("shift-tab");
+        assert_trapped_focus(cx, &container, &container);
+        assert_eq!(background_focuses.get(), 0);
+    }
+
+    #[gpui::test]
+    fn inactive_focus_trap_does_not_capture_background_navigation(cx: &mut TestAppContext) {
+        let (cx, _, _, background, background_focuses) = trap_harness(cx, 2);
+        cx.update(|window, cx| {
+            background[0].focus(window, cx);
+            window.draw(cx).clear(cx);
+        });
+        // Prove that the observers used by the containment regressions really
+        // receive native focus notifications when a background control is used.
+        assert_eq!(background_focuses.get(), 1);
+
+        cx.dispatch_keystrokes("tab");
+        cx.update(|window, cx| {
+            window.draw(cx).clear(cx);
+            assert_eq!(window.focused(cx), Some(background[1].clone()));
+        });
+        cx.dispatch_keystrokes("shift-tab");
+        cx.update(|window, cx| {
+            window.draw(cx).clear(cx);
+            assert_eq!(window.focused(cx), Some(background[0].clone()));
+        });
+        assert_eq!(background_focuses.get(), 3);
+    }
 
     struct Content;
     impl Render for Content {
@@ -578,6 +715,91 @@ mod tests {
         }
         assert_ne!(ids[0], ids[1]);
     }
+
+    mod nested_traps {
+        use super::*;
+
+        struct NestedTraps {
+            containers: Vec<FocusHandle>,
+            controls: Vec<[FocusHandle; 2]>,
+            depth: usize,
+        }
+
+        impl Render for NestedTraps {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                let mut child = div().into_any_element();
+                for index in (0..self.depth).rev() {
+                    let control = |side: usize| {
+                        div()
+                            .id(("nested-control", index * 2 + side))
+                            .track_focus(&self.controls[index][side])
+                            .tab_index(0)
+                            .size(px(10.))
+                    };
+                    child = div()
+                        .tab_stop(false)
+                        .child(control(0))
+                        .child(child)
+                        .child(control(1))
+                        .focus_trap(("nested-trap", index), &self.containers[index])
+                        .into_any_element();
+                }
+                div().tab_group().size_full().child(child)
+            }
+        }
+
+        #[gpui::test]
+        fn nested_traps_keep_the_innermost_scope_across_close_and_reopen(cx: &mut TestAppContext) {
+            cx.update(crate::init);
+            let (containers, controls) = cx.update(|cx| {
+                (
+                    (0..6).map(|_| cx.focus_handle()).collect::<Vec<_>>(),
+                    (0..6)
+                        .map(|_| {
+                            [
+                                cx.focus_handle().tab_stop(true),
+                                cx.focus_handle().tab_stop(true),
+                            ]
+                        })
+                        .collect::<Vec<_>>(),
+                )
+            });
+            let (root, cx) = cx.add_window_view({
+                let containers = containers.clone();
+                let controls = controls.clone();
+                move |window, cx| {
+                    let view = cx.new(|_| NestedTraps {
+                        containers,
+                        controls,
+                        depth: 6,
+                    });
+                    Root::new(view, window, cx)
+                }
+            });
+            let view = root.read_with(cx, |root, _| {
+                root.view.clone().downcast::<NestedTraps>().unwrap()
+            });
+
+            // Retain every container handle while inner scopes disappear and
+            // return. Rendered containment, not registration order or ownership
+            // lifetime, must decide which remaining scope handles Tab.
+            for depth in [6, 5, 4, 3, 2, 1, 2, 3, 4, 5, 6] {
+                view.update(cx, |view, cx| {
+                    view.depth = depth;
+                    cx.notify();
+                });
+                let index = depth - 1;
+                cx.update(|window, cx| {
+                    controls[index][1].focus(window, cx);
+                    window.draw(cx).clear(cx);
+                });
+                cx.dispatch_keystrokes("tab");
+                assert_trapped_focus(cx, &containers[index], &controls[index][0]);
+                cx.dispatch_keystrokes("shift-tab");
+                assert_trapped_focus(cx, &containers[index], &controls[index][1]);
+            }
+        }
+    }
 }
 
 #[cfg(test)]
diff --git a/third-party/gpui-kit/crates/base/src/scroll_bounce.rs b/third-party/gpui-kit/crates/base/src/scroll_bounce.rs
index 78ff285a05..77a2184b1f 100644
--- a/third-party/gpui-kit/crates/base/src/scroll_bounce.rs
+++ b/third-party/gpui-kit/crates/base/src/scroll_bounce.rs
@@ -1,10 +1,11 @@
 //! Boundary displacement only: the list keeps its clamped logical position.
 
+use crate::overscroll::Overscroll;
 use crate::{OngoingScrollExt as _, ScrollbarHandle};
 use gpui::{
-    AnyElement, App, Bounds, ContentMask, DispatchPhase, Element, ElementId, GlobalElementId,
-    Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId, OngoingScroll, Pixels,
-    ScrollDelta, ScrollWheelEvent, TouchPhase, Window, point, px,
+    AnyElement, App, Bounds, ContentMask, DispatchPhase, Element, ElementId, Global,
+    GlobalElementId, Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, LayoutId,
+    OngoingScroll, Pixels, ScrollDelta, ScrollWheelEvent, TouchPhase, Window, point, px,
 };
 #[cfg(not(target_family = "wasm"))]
 use std::time::Instant;
@@ -16,19 +17,38 @@ use web_time::Instant;
 /// past an edge, and how quickly a released edge returns.
 ///
 /// Base plays the stretch and the return; the feel belongs to the caller.
-/// The default is tuned to feel like a `UIScrollView` bounce.
+/// The default is tuned to feel like a `UIScrollView` bounce on iOS and
+/// Android, and like an `NSScrollView` bounce elsewhere.
 #[derive(Clone, Copy, Debug, PartialEq)]
 pub struct ScrollBounceMotion {
     tracking: f32,
     response: Duration,
+    max_stretch: Option<f32>,
 }
 
 impl Default for ScrollBounceMotion {
-    /// Tuned to feel like a `UIScrollView` bounce; these are not UIKit constants.
+    /// Tuned to feel like a `UIScrollView` bounce on touch platforms; these
+    /// are not UIKit constants. Elsewhere the input is a trackpad, whose
+    /// accelerated deltas run far past a finger's travel, so the default is
+    /// fitted to an `NSScrollView` measured on macOS: the stretch follows
+    /// about 0.075 of the travel, the return decays at 12.5 per second, and
+    /// the edge stops 36 px out. AppKit stretches by the unaccelerated
+    /// travel, which GPUI does not report; the limit keeps a hard swipe as
+    /// short as Finder's.
     fn default() -> Self {
-        Self {
-            tracking: 0.55,
-            response: Duration::from_millis(524),
+        if cfg!(any(target_os = "ios", target_os = "android")) {
+            Self {
+                tracking: 0.55,
+                response: Duration::from_millis(524),
+                max_stretch: None,
+            }
+        } else {
+            Self {
+                tracking: 0.075,
+                // 2π / 12.5
+                response: Duration::from_micros(502_655),
+                max_stretch: Some(36.),
+            }
         }
     }
 }
@@ -64,6 +84,22 @@ impl ScrollBounceMotion {
         self
     }
 
+    /// The farthest the edge goes past its end, in pixels, whether stretched
+    /// or thrown by a fling. The stretch eases into it rather than stopping
+    /// at it.
+    ///
+    /// # Panics
+    ///
+    /// Panics when `max_stretch` is not finite or not positive.
+    pub fn with_max_stretch(mut self, max_stretch: f32) -> Self {
+        assert!(
+            max_stretch.is_finite() && max_stretch > 0.,
+            "scroll bounce max stretch must be finite and positive"
+        );
+        self.max_stretch = Some(max_stretch);
+        self
+    }
+
     /// Fraction of finger travel the stretched edge follows at first.
     pub fn tracking(&self) -> f32 {
         self.tracking
@@ -74,6 +110,12 @@ impl ScrollBounceMotion {
         self.response
     }
 
+    /// The farthest the edge goes past its end, or `None` when only the
+    /// viewport height bounds it.
+    pub fn max_stretch(&self) -> Option<f32> {
+        self.max_stretch
+    }
+
     /// Undamped angular frequency of the return, or `None` when it snaps.
     fn omega(&self) -> Option<f32> {
         let seconds = self.response.as_secs_f32();
@@ -88,15 +130,22 @@ impl ScrollBounceMotion {
 /// The stable `id` owns gesture and spring state. Change it when replacing the
 /// document. Put fixed chrome (scrollbars, toolbars) outside this wrapper.
 ///
-/// Enabled by default on iOS and Android. Other platforms pass through unless
-/// explicitly enabled; their input must emit `Ended` at finger release, before momentum.
-/// Reduced motion disables displacement. Keyboard, focus, and line-wheel input
-/// remain owned by the child. No colors, padding, or dimensions are imposed.
+/// Enabled by default where the platform's own scroll views bounce: iOS,
+/// Android and macOS. Reduced motion disables displacement. Keyboard and
+/// focus remain owned by the child. No colors, padding, or dimensions are
+/// imposed.
+///
+/// Nested scroll areas follow the platform: the innermost one under the
+/// pointer takes the whole gesture, at its ends too, and bounces there
+/// rather than handing the rest to the area around it. A disabled
+/// `ScrollBounce` still scrolls its child and keeps the gesture from the
+/// areas around it; it only stops at the ends instead of stretching.
 pub struct ScrollBounce {
     id: ElementId,
     handle: Rc<dyn ScrollbarHandle>,
     child: AnyElement,
     enabled: bool,
+    scroll_wheel: bool,
     motion: ScrollBounceMotion,
     on_scroll: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
 }
@@ -111,18 +160,41 @@ impl ScrollBounce {
             id: id.into(),
             handle: Rc::new(handle.clone()),
             child: child.into_any_element(),
-            enabled: cfg!(any(target_os = "ios", target_os = "android")),
+            enabled: Self::enabled_by_default(),
+            scroll_wheel: true,
             motion: ScrollBounceMotion::default(),
             on_scroll: None,
         }
     }
 
-    /// Opt in on a platform with compatible touch phase semantics.
+    /// Whether scroll areas bounce on this platform unless told otherwise:
+    /// on iOS, Android and macOS, whose own scroll views do.
+    pub const fn enabled_by_default() -> bool {
+        cfg!(any(
+            target_os = "ios",
+            target_os = "android",
+            target_os = "macos"
+        ))
+    }
+
+    /// Turn the bounce on or off. Defaults to [`Self::enabled_by_default`].
     pub fn enabled(mut self, enabled: bool) -> Self {
         self.enabled = enabled;
         self
     }
 
+    /// Own vertical scrolling using the shared input overscroll engine.
+    /// Precise pixel input can bounce; ordinary line-wheel input only scrolls
+    /// and cancels any existing displacement. Keep fixed chrome outside it.
+    ///
+    /// On by default. Turned off, the child scrolls itself and only its
+    /// unused deltas stretch the edge; that needs input that emits `Ended`
+    /// at finger release, before momentum, as touch does.
+    pub fn scroll_wheel(mut self, enabled: bool) -> Self {
+        self.scroll_wheel = enabled;
+        self
+    }
+
     /// Set how far a drag stretches past an edge and how the edge returns.
     pub fn motion(mut self, motion: ScrollBounceMotion) -> Self {
         self.motion = motion;
@@ -141,6 +213,7 @@ impl ScrollBounce {
 #[derive(Default)]
 struct State {
     physics: Physics,
+    overscroll: Overscroll,
     sampled_at: Option<Instant>,
     ongoing_scroll: OngoingScroll,
     short_drag_distance: Option<f32>,
@@ -173,6 +246,29 @@ const CATCH_DRAG_SLOP: f32 = 8.;
 // otherwise ends suppression, and would stay locked after one bounce.
 const MOMENTUM_GAP: Duration = Duration::from_millis(250);
 
+/// Which scroll area under the pointer takes the current wheel event.
+///
+/// Capture runs from the outside in, so every area that could scroll writes
+/// itself here before its child paints, and the innermost one is the last
+/// to write. Each then acts, from the inside out, only if it is still the
+/// one written.
+#[derive(Default)]
+struct ScrollOwner(Option<HitboxId>);
+
+impl Global for ScrollOwner {}
+
+/// Take the current wheel event from the scroll areas around `hitbox`.
+/// Call it in the capture phase, from a listener registered before the
+/// children paint, whenever the area would scroll for the event.
+pub(crate) fn claim_scroll(hitbox: HitboxId, cx: &mut App) {
+    cx.default_global::<ScrollOwner>().0 = Some(hitbox);
+}
+
+fn owns_scroll(hitbox: HitboxId, cx: &App) -> bool {
+    cx.try_global::<ScrollOwner>()
+        .is_some_and(|owner| owner.0 == Some(hitbox))
+}
+
 /// `ScrollbarHandle` has no `max_offset`; recover it from the definition
 /// `content_size = viewport + max_offset`. Both dispatch phases clamp against
 /// this bound and must agree on it.
@@ -235,16 +331,27 @@ impl Element for ScrollBounce {
             if !self.enabled || cx.reduce_motion() {
                 *state = State::default();
             }
-            state.physics.motion = self.motion;
-            let now = Instant::now();
-            let elapsed = state
-                .sampled_at
-                .replace(now)
-                .map_or(0., |at| now.duration_since(at).as_secs_f32());
-            if state.physics.step(elapsed) {
-                window.request_animation_frame();
+            if self.scroll_wheel {
+                state.overscroll.set_motion(self.motion);
+                if max_scroll_extent(self.handle.as_ref()) <= px(0.) {
+                    state.overscroll.reset();
+                }
+                if state.overscroll.step(cx.background_executor().now()) {
+                    window.request_animation_frame();
+                }
+                state.overscroll.offset()
+            } else {
+                state.physics.motion = self.motion;
+                let now = Instant::now();
+                let elapsed = state
+                    .sampled_at
+                    .replace(now)
+                    .map_or(0., |at| now.duration_since(at).as_secs_f32());
+                if state.physics.step(elapsed) {
+                    window.request_animation_frame();
+                }
+                state.physics.offset()
             }
-            state.physics.offset()
         };
         let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
         window.with_content_mask(Some(ContentMask { bounds }), |window| {
@@ -252,6 +359,16 @@ impl Element for ScrollBounce {
                 self.child.prepaint(window, cx);
             });
         });
+        // The child's fresh metrics can remove overflow during a drag.
+        // Reset against those metrics as well, and repaint any displaced frame.
+        if self.scroll_wheel && max_scroll_extent(self.handle.as_ref()) <= px(0.) {
+            let mut state = state.borrow_mut();
+            let displaced = state.overscroll.offset() != 0.;
+            state.overscroll.reset();
+            if displaced {
+                window.request_animation_frame();
+            }
+        }
         ScrollBouncePrepaintState { state, hitbox }
     }
 
@@ -265,7 +382,19 @@ impl Element for ScrollBounce {
         window: &mut Window,
         cx: &mut App,
     ) {
-        if self.enabled && !cx.reduce_motion() {
+        if self.scroll_wheel {
+            let hitbox = prepaint.hitbox.id;
+            let handle = self.handle.clone();
+            window.on_mouse_event(move |_: &ScrollWheelEvent, phase, window, cx| {
+                if phase == DispatchPhase::Capture
+                    && hitbox.should_handle_scroll(window)
+                    && max_scroll_extent(handle.as_ref()) > px(0.)
+                {
+                    claim_scroll(hitbox, cx);
+                }
+            });
+        }
+        if self.enabled && !self.scroll_wheel && !cx.reduce_motion() {
             let state = prepaint.state.clone();
             let hitbox = prepaint.hitbox.id;
             let handle = self.handle.clone();
@@ -275,6 +404,15 @@ impl Element for ScrollBounce {
             let mut allow_end_bounce = false;
             window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
                 let ScrollDelta::Pixels(mut delta) = event.delta else {
+                    if phase == DispatchPhase::Capture && hitbox.should_handle_scroll(window) {
+                        let mut state = state.borrow_mut();
+                        let displaced = state.physics.offset() != 0.;
+                        *state = State::default();
+                        drop(state);
+                        if displaced {
+                            cx.notify(view);
+                        }
+                    }
                     return;
                 };
                 if !hitbox.should_handle_scroll(window) {
@@ -408,11 +546,104 @@ impl Element for ScrollBounce {
         window.with_content_mask(Some(ContentMask { bounds }), |window| {
             self.child.paint(window, cx)
         });
+        // After the children, so that the areas inside act first.
+        if self.scroll_wheel {
+            let bounces = self.enabled && !cx.reduce_motion();
+            let state = prepaint.state.clone();
+            let hitbox = prepaint.hitbox.id;
+            let handle = self.handle.clone();
+            let view = window.current_view();
+            let on_scroll = self.on_scroll.clone();
+            window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
+                if phase != DispatchPhase::Capture
+                    || !hitbox.should_handle_scroll(window)
+                    || !owns_scroll(hitbox, cx)
+                {
+                    return;
+                }
+                let mut delta = event.delta.pixel_delta(window.line_height());
+                let mut state = state.borrow_mut();
+                state
+                    .ongoing_scroll
+                    .lock_axis(&mut delta, event.touch_phase);
+                let max = max_scroll_extent(handle.as_ref());
+                if max <= px(0.) {
+                    state.overscroll.reset();
+                    return;
+                }
+                if delta.x.abs() > delta.y.abs() {
+                    return;
+                }
+                let before = handle.offset();
+                let changed = if !bounces || matches!(event.delta, ScrollDelta::Lines(_)) {
+                    let displaced = state.overscroll.offset() != 0.;
+                    state.overscroll.reset();
+                    let mut offset = before;
+                    offset.y = (before.y + delta.y).clamp(-max, px(0.));
+                    handle.set_offset(offset);
+                    displaced
+                } else {
+                    state.overscroll.packet(
+                        delta.y.as_f32(),
+                        event.touch_phase,
+                        bounds.size.height.as_f32(),
+                        cx.background_executor().now(),
+                        |dy| {
+                            let before = handle.offset();
+                            let mut offset = before;
+                            offset.y = (before.y + px(dy)).clamp(-max, px(0.));
+                            handle.set_offset(offset);
+                            (offset.y - before.y).as_f32()
+                        },
+                    )
+                };
+                let scrolled = handle.offset() != before;
+                drop(state);
+                cx.stop_propagation();
+                if changed || scrolled {
+                    cx.notify(view);
+                }
+                if scrolled && let Some(handler) = &on_scroll {
+                    handler(window, cx);
+                }
+            });
+        }
     }
 }
 
+/// The largest outward speed, at most `speed`, that lets an edge at
+/// `position` go no farther out than `max`.
+///
+/// The edge then follows `(position + speed * t) * e^(-ωt)`, which peaks at
+/// `speed / ω * e^(ω * position / speed - 1)` when the speed is enough to
+/// carry it outward at all.
+fn limit_throw(position: f32, speed: f32, omega: f32, max: f32) -> f32 {
+    let peak = |speed: f32| {
+        if speed <= omega * position {
+            position
+        } else {
+            speed / omega * (omega * position / speed - 1.).exp()
+        }
+    };
+    if peak(speed) <= max {
+        return speed;
+    }
+    let (mut low, mut high) = (0., speed);
+    for _ in 0..24 {
+        let mid = (low + high) / 2.;
+        if peak(mid) <= max {
+            low = mid;
+        } else {
+            high = mid;
+        }
+    }
+    low
+}
+
+/// The stretch past an edge and its return, shared with the input's own
+/// scrolling.
 #[derive(Default)]
-struct Physics {
+pub(crate) struct Physics {
     position: f32,
     velocity: f32,
     dragging: bool,
@@ -422,35 +653,63 @@ struct Physics {
 }
 
 impl Physics {
-    fn offset(&self) -> f32 {
+    pub(crate) fn set_motion(&mut self, motion: ScrollBounceMotion) {
+        self.motion = motion;
+    }
+
+    pub(crate) fn reset(&mut self) {
+        *self = Self {
+            motion: self.motion,
+            ..Self::default()
+        };
+    }
+    pub(crate) fn is_dragging(&self) -> bool {
+        self.dragging
+    }
+
+    /// Whether a released edge is still on its way.
+    pub(crate) fn is_moving(&self) -> bool {
+        !self.dragging && (self.position != 0. || self.velocity != 0.)
+    }
+
+    pub(crate) fn offset(&self) -> f32 {
         if self.dragging {
             let d = self.extent.max(1.);
             let tracking = self.motion.tracking;
-            self.position * tracking / (1. + tracking * self.position.abs() / d)
+            let stretch = self.position * tracking / (1. + tracking * self.position.abs() / d);
+            match self.motion.max_stretch {
+                // Follows the stretch closely at first, then eases into the limit.
+                Some(max) => max * (stretch / max).tanh(),
+                None => stretch,
+            }
         } else {
             self.position
         }
     }
 
-    fn begin(&mut self, extent: f32) {
+    pub(crate) fn begin(&mut self, extent: f32) {
         let offset = self.offset();
         // A displaced edge keeps the extent it was stretched under. The
         // rubber-band curve saturates at the extent, so re-reading a viewport
         // that shrank mid-return (rotation, keyboard) could not place the
         // finger where the edge is: it would snap, then need a long pull back.
-        if offset == 0. {
+        if offset == 0. || self.extent <= 0. {
             self.extent = extent.max(1.);
         }
-        // Invert the rubber-band curve so grabbing a returning edge is continuous.
+        // Invert the curves so grabbing a returning edge is continuous.
+        let stretch = match self.motion.max_stretch {
+            Some(max) => max * (offset / max).clamp(-0.999, 0.999).atanh(),
+            None => offset,
+        };
         let tracking = self.motion.tracking;
-        self.position = offset / (tracking * (1. - offset.abs() / self.extent).max(0.01));
+        self.position = stretch / (tracking * (1. - stretch.abs() / self.extent).max(0.01));
         self.velocity = 0.;
         self.dragging = true;
         self.suppress_momentum = false;
     }
 
     /// Apply finger displacement. Return the part that crosses back into the list.
-    fn pull(&mut self, delta: f32) -> f32 {
+    pub(crate) fn pull(&mut self, delta: f32) -> f32 {
         let previous = self.position;
         let next = previous + delta;
         if previous != 0. && previous.signum() != next.signum() {
@@ -462,7 +721,7 @@ impl Physics {
         }
     }
 
-    fn release(&mut self) {
+    pub(crate) fn release(&mut self) {
         self.position = self.offset();
         self.dragging = false;
         if self.position != 0. {
@@ -470,9 +729,35 @@ impl Physics {
         }
     }
 
+    /// Let go of the edge the way AppKit does: from wherever it is, it
+    /// returns at full speed, and it carries `velocity` outward on the way.
+    /// Its path is `(x + velocity * t) * e^(-ωt)`, so an edge at rest is
+    /// thrown out and back, and an edge let go while still being pushed
+    /// returns a little later. It never crosses the boundary: velocity
+    /// pointing back inward is dropped.
+    pub(crate) fn let_go(&mut self, velocity: f32) {
+        self.position = self.offset();
+        self.dragging = false;
+        let Some(omega) = self.motion.omega() else {
+            self.position = 0.;
+            self.velocity = 0.;
+            return;
+        };
+        let mut outward = if self.position * velocity >= 0. {
+            velocity
+        } else {
+            0.
+        };
+        if let Some(max) = self.motion.max_stretch {
+            outward =
+                outward.signum() * limit_throw(self.position.abs(), outward.abs(), omega, max);
+        }
+        self.velocity = outward - omega * self.position;
+    }
+
     /// Exact critically damped spring integration, independent of refresh rate.
-    fn step(&mut self, seconds: f32) -> bool {
-        if self.dragging || self.position == 0. {
+    pub(crate) fn step(&mut self, seconds: f32) -> bool {
+        if self.dragging || (self.position == 0. && self.velocity == 0.) {
             return false;
         }
         let Some(omega) = self.motion.omega() else {
@@ -521,7 +806,8 @@ mod tests {
                         .track_scroll(&self.handle)
                         .child(div().h(px(600.)).w_full()),
                 )
-                .enabled(self.enabled),
+                .enabled(self.enabled)
+                .scroll_wheel(false),
             )
         }
     }
@@ -539,6 +825,180 @@ mod tests {
         });
     }
 
+    struct WheelViewport(ScrollHandle);
+
+    impl Render for WheelViewport {
+        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+            ScrollBounce::new(
+                "wheel-bounce",
+                &self.0,
+                div()
+                    .id("wheel-viewport")
+                    .w(px(200.))
+                    .h(px(200.))
+                    .overflow_y_scroll()
+                    .track_scroll(&self.0)
+                    .child(div().h(px(600.)).w_full()),
+            )
+            .enabled(true)
+            .scroll_wheel(true)
+        }
+    }
+
+    #[gpui::test]
+    fn ordinary_mouse_wheel_keeps_the_shared_viewport_undisplaced(cx: &mut TestAppContext) {
+        let handle = ScrollHandle::new();
+        let (_, cx) = cx.add_window_view({
+            let handle = handle.clone();
+            move |_, _| WheelViewport(handle)
+        });
+        draw(cx);
+        let origin = handle.bounds().origin;
+        cx.simulate_event(ScrollWheelEvent {
+            position: point(px(100.), px(100.)),
+            delta: ScrollDelta::Lines(point(0., -100.)),
+            ..Default::default()
+        });
+        draw(cx);
+        cx.executor().advance_clock(Duration::from_millis(16));
+        draw(cx);
+        assert_eq!(handle.offset().y, -handle.max_offset().y);
+        assert_eq!(handle.bounds().origin, origin);
+    }
+
+    #[gpui::test]
+    fn wheel_scrolling_interrupts_the_shared_return(cx: &mut TestAppContext) {
+        let handle = ScrollHandle::new();
+        let (_, cx) = cx.add_window_view({
+            let handle = handle.clone();
+            move |_, _| WheelViewport(handle)
+        });
+        draw(cx);
+        let wheel = |cx: &mut VisualTestContext, dy| {
+            cx.simulate_event(ScrollWheelEvent {
+                position: point(px(100.), px(100.)),
+                delta: ScrollDelta::Lines(point(0., dy)),
+                ..Default::default()
+            });
+        };
+        scroll(cx, -1000., TouchPhase::Started);
+        scroll(cx, 0., TouchPhase::Ended);
+        draw(cx);
+        assert_eq!(handle.offset().y, -handle.max_offset().y);
+        cx.executor().advance_clock(Duration::from_millis(200));
+        draw(cx);
+        let end = handle.offset().y;
+        wheel(cx, 2.);
+        assert!(
+            handle.offset().y > end,
+            "the new wheel input scrolls before the next frame"
+        );
+        draw(cx);
+    }
+
+    /// A scroll area with a shorter one inside it, 100 px down.
+    struct Nested {
+        outer: ScrollHandle,
+        inner: ScrollHandle,
+        inner_enabled: bool,
+    }
+
+    impl Render for Nested {
+        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+            ScrollBounce::new(
+                "outer",
+                &self.outer,
+                div()
+                    .id("outer-viewport")
+                    .w(px(200.))
+                    .h(px(300.))
+                    .overflow_y_scroll()
+                    .track_scroll(&self.outer)
+                    .child(div().h(px(100.)).w_full())
+                    .child(
+                        ScrollBounce::new(
+                            "inner",
+                            &self.inner,
+                            div()
+                                .id("inner-viewport")
+                                .w(px(200.))
+                                .h(px(100.))
+                                .overflow_y_scroll()
+                                .track_scroll(&self.inner)
+                                .child(div().h(px(400.)).w_full()),
+                        )
+                        .enabled(self.inner_enabled),
+                    )
+                    .child(div().h(px(600.)).w_full()),
+            )
+            .enabled(true)
+        }
+    }
+
+    fn nested(cx: &mut TestAppContext, inner_enabled: bool) -> (Nested, &mut VisualTestContext) {
+        let (outer, inner) = (ScrollHandle::new(), ScrollHandle::new());
+        let (_, cx) = cx.add_window_view({
+            let (outer, inner) = (outer.clone(), inner.clone());
+            move |_, _| Nested {
+                outer,
+                inner,
+                inner_enabled,
+            }
+        });
+        draw(cx);
+        (
+            Nested {
+                outer,
+                inner,
+                inner_enabled,
+            },
+            cx,
+        )
+    }
+
+    fn scroll_at(cx: &mut VisualTestContext, y: f32, delta: f32, phase: TouchPhase) {
+        cx.simulate_event(ScrollWheelEvent {
+            position: point(px(100.), px(y)),
+            delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
+            touch_phase: phase,
+            ..Default::default()
+        });
+        draw(cx);
+    }
+
+    #[gpui::test]
+    fn the_innermost_area_takes_the_whole_gesture(cx: &mut TestAppContext) {
+        let (areas, cx) = nested(cx, true);
+        let inner_origin = areas.inner.bounds().origin.y;
+        scroll_at(cx, 150., 0., TouchPhase::Started);
+        scroll_at(cx, 150., -200., TouchPhase::Moved);
+        assert_eq!(areas.inner.offset().y, px(-200.), "the inner area scrolls");
+        assert_eq!(areas.outer.offset().y, px(0.), "the outer one does not");
+        // At its end the inner area stretches instead of handing over.
+        scroll_at(cx, 150., -200., TouchPhase::Moved);
+        assert_eq!(areas.inner.offset().y, px(-300.));
+        assert_eq!(areas.outer.offset().y, px(0.));
+        assert!(areas.inner.bounds().origin.y < inner_origin);
+        // Outside the inner area, the outer one scrolls.
+        scroll_at(cx, 150., 0., TouchPhase::Ended);
+        scroll_at(cx, 20., -50., TouchPhase::Moved);
+        assert_eq!(areas.outer.offset().y, px(-50.));
+    }
+
+    #[gpui::test]
+    fn a_disabled_inner_area_still_keeps_its_scroll(cx: &mut TestAppContext) {
+        let (areas, cx) = nested(cx, false);
+        let inner_origin = areas.inner.bounds().origin.y;
+        scroll_at(cx, 150., -50., TouchPhase::Moved);
+        assert_eq!(areas.inner.offset().y, px(-50.), "the inner area scrolls");
+        assert_eq!(areas.outer.offset().y, px(0.), "the outer one does not");
+        // At its end it stops without stretching.
+        scroll_at(cx, 150., -500., TouchPhase::Moved);
+        assert_eq!(areas.inner.offset().y, px(-300.));
+        assert_eq!(areas.inner.bounds().origin.y, inner_origin);
+        assert_eq!(areas.outer.offset().y, px(0.));
+    }
+
     struct ListTest(gpui::ListState);
 
     impl Render for ListTest {
@@ -553,6 +1013,7 @@ mod tests {
                 .h(px(200.)),
             )
             .enabled(true)
+            .scroll_wheel(false)
         }
     }
 
@@ -929,9 +1390,11 @@ mod tests {
     fn motion_builder_configures_tracking_and_response() {
         let motion = ScrollBounceMotion::default()
             .with_tracking(0.4)
-            .with_response(Duration::from_millis(300));
+            .with_response(Duration::from_millis(300))
+            .with_max_stretch(24.);
         assert_eq!(motion.tracking(), 0.4);
         assert_eq!(motion.response(), Duration::from_millis(300));
+        assert_eq!(motion.max_stretch(), Some(24.));
     }
 
     #[test]
diff --git a/third-party/gpui-kit/crates/base/src/scrollable_mask.rs b/third-party/gpui-kit/crates/base/src/scrollable_mask.rs
index 68740755fc..7da56f6c8d 100644
--- a/third-party/gpui-kit/crates/base/src/scrollable_mask.rs
+++ b/third-party/gpui-kit/crates/base/src/scrollable_mask.rs
@@ -4,7 +4,7 @@ use std::rc::Rc;
 use gpui::{
     App, Axis, BorderStyle, Bounds, ContentMask, Edges, Element, ElementId, GlobalElementId,
     Hitbox, Hsla, InteractiveElement as _, IntoElement, IsZero as _, LayoutId, OngoingScroll,
-    PaintQuad, ParentElement as _, Point, Position, ScrollHandle, ScrollWheelEvent,
+    PaintQuad, ParentElement as _, Point, Position, ScrollDelta, ScrollHandle, ScrollWheelEvent,
     StatefulInteractiveElement as _, Style, StyleRefinement, Styled as _, Window, div, px,
     relative,
 };
@@ -297,10 +297,17 @@ fn corner_notch_path(
 /// `overscroll-behavior: auto` chaining), while a horizontal mask keeps
 /// consuming it — a bubbled horizontal delta would get mapped onto a
 /// vertical-only ancestor by gpui's own wheel listener (see #2468).
+///
+/// A mouse wheel has no horizontal axis, so a horizontal mask leaves it to the
+/// parent. Over a native horizontal scroller, [`Self::vertical_wheel`] scrolls
+/// sideways with it instead.
 pub struct ScrollableMask<H: ScrollbarHandle + Clone = ScrollHandle> {
     axis: Axis,
     id: ElementId,
     scroll_handle: H,
+    /// Only turn a plain wheel's vertical lines into horizontal scrolling,
+    /// leaving every other event to the scroller below.
+    vertical_wheel: bool,
     debug: Option<Hsla>,
 }
 
@@ -312,10 +319,28 @@ impl<H: ScrollbarHandle + Clone> ScrollableMask<H> {
             scroll_handle: scroll_handle.clone(),
             axis,
             id: caller_id(),
+            vertical_wheel: false,
             debug: None,
         }
     }
 
+    /// Create a mask over a native horizontal scroller — one with
+    /// `overflow_x_scroll` and a locked scroll axis, such as a strip of tabs —
+    /// that scrolls it sideways with a plain mouse wheel's vertical lines, the
+    /// only axis such a wheel has.
+    ///
+    /// Once the scroller cannot move that way, the wheel goes on to the parent,
+    /// so a page still scrolls past the strip. Trackpad gestures and
+    /// horizontal wheels are left to the scroller, with its own axis lock and
+    /// bounce.
+    #[track_caller]
+    pub fn vertical_wheel(scroll_handle: &H) -> Self {
+        Self {
+            vertical_wheel: true,
+            ..Self::new(Axis::Horizontal, scroll_handle)
+        }
+    }
+
     /// Set a specific element id, default is the [`std::panic::Location::caller`].
     ///
     /// Only needed when one call site creates several masks of the same axis,
@@ -368,8 +393,12 @@ impl<H: ScrollbarHandle + Clone> Element for ScrollableMask<H> {
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
         let mut style = Style::default();
-        // Set the layout style relative to the table view to get same size.
+        // Cover the parent whatever its layout: pinned to its top-left corner
+        // rather than left at a static position that depends on whether the
+        // parent stacks its children in a column or a row.
         style.position = Position::Absolute;
+        style.inset.top = px(0.).into();
+        style.inset.left = px(0.).into();
         style.flex_grow = 1.0;
         style.flex_shrink = 1.0;
         style.size.width = relative(1.).into();
@@ -387,16 +416,7 @@ impl<H: ScrollbarHandle + Clone> Element for ScrollableMask<H> {
         window: &mut Window,
         _: &mut App,
     ) -> Self::PrepaintState {
-        // Move y to bounds height to cover the parent view.
-        let cover_bounds = Bounds {
-            origin: Point {
-                x: bounds.origin.x,
-                y: bounds.origin.y - bounds.size.height,
-            },
-            size: bounds.size,
-        };
-
-        window.insert_hitbox(cover_bounds, gpui::HitboxBehavior::Normal)
+        window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal)
     }
 
     fn paint(
@@ -410,6 +430,7 @@ impl<H: ScrollbarHandle + Clone> Element for ScrollableMask<H> {
         _: &mut App,
     ) {
         let is_horizontal = self.axis.is_horizontal();
+        let vertical_wheel = self.vertical_wheel;
         let line_height = window.line_height();
         let bounds = hitbox.bounds;
         let ongoing_scroll = global_id
@@ -457,6 +478,32 @@ impl<H: ScrollbarHandle + Clone> Element for ScrollableMask<H> {
                     let mut offset = scroll_handle.offset();
                     let mut delta = event.delta.pixel_delta(line_height);
 
+                    // A plain mouse wheel's vertical lines move the scroller
+                    // sideways; once it is at its edge the parent scrolls
+                    // instead, as for a vertical mask.
+                    if vertical_wheel {
+                        let ScrollDelta::Lines(lines) = event.delta else {
+                            return;
+                        };
+                        if lines.x != 0. || lines.y == 0. || !lines.y.is_finite() {
+                            return;
+                        }
+                        let axis_max = (scroll_handle.content_size().width
+                            - scroll_handle.viewport_bounds().size.width)
+                            .max(px(0.));
+                        let current = offset.x.clamp(-axis_max, px(0.));
+                        let new_offset = (current + delta.y).clamp(-axis_max, px(0.));
+                        if new_offset == current {
+                            return;
+                        }
+
+                        offset.x = new_offset;
+                        scroll_handle.set_offset(offset);
+                        cx.notify(view_id);
+                        cx.stop_propagation();
+                        return;
+                    }
+
                     // Lock the gesture to the axis it started on, so a diagonal
                     // trackpad swipe cannot flip which mask consumes it from one
                     // event to the next. Line deltas carry no touch phase.
@@ -1175,4 +1222,101 @@ mod tests {
         let scroll_top = list_state.logical_scroll_top();
         assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
     }
+
+    /// A 100px strip of 300px content, locked to its axis like a tab strip,
+    /// with a vertical-wheel mask, inside a vertical scroller.
+    struct VerticalWheelTest {
+        strip: ScrollHandle,
+        page: ScrollHandle,
+    }
+
+    impl Render for VerticalWheelTest {
+        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+            div()
+                .id("page")
+                .w(px(100.))
+                .h(px(60.))
+                .overflow_y_scroll()
+                .track_scroll(&self.page)
+                .child(
+                    div()
+                        .relative()
+                        .w(px(100.))
+                        .h(px(40.))
+                        .child(
+                            div()
+                                .id("strip")
+                                .size_full()
+                                .flex()
+                                .overflow_x_scroll()
+                                .restrict_scroll_to_axis()
+                                .track_scroll(&self.strip)
+                                .child(div().flex_none().w(px(300.)).h(px(40.))),
+                        )
+                        .child(ScrollableMask::vertical_wheel(&self.strip)),
+                )
+                .child(div().h(px(200.)))
+        }
+    }
+
+    fn vertical_wheel_test(
+        cx: &mut TestAppContext,
+    ) -> (&mut VisualTestContext, ScrollHandle, ScrollHandle) {
+        let (strip, page) = (ScrollHandle::new(), ScrollHandle::new());
+        let (_, cx) = cx.add_window_view({
+            let (strip, page) = (strip.clone(), page.clone());
+            move |_, _| VerticalWheelTest { strip, page }
+        });
+        cx.run_until_parked();
+        cx.update(|window, cx| window.draw(cx).clear(cx));
+        (cx, strip, page)
+    }
+
+    fn wheel(cx: &mut VisualTestContext, delta: ScrollDelta) {
+        cx.simulate_event(ScrollWheelEvent {
+            position: point(px(10.), px(10.)),
+            delta,
+            ..Default::default()
+        });
+        cx.update(|window, cx| window.draw(cx).clear(cx));
+    }
+
+    #[gpui::test]
+    fn vertical_wheel_scrolls_the_strip_and_then_the_parent(cx: &mut TestAppContext) {
+        let (cx, strip, page) = vertical_wheel_test(cx);
+        let line_height = cx.update(|window, _| window.line_height());
+
+        wheel(cx, ScrollDelta::Lines(point(0., -1.)));
+        assert_eq!(strip.offset().x, -line_height);
+        assert_eq!(page.offset().y, px(0.));
+
+        // Past the strip's end, the wheel scrolls the page.
+        wheel(cx, ScrollDelta::Lines(point(0., -100.)));
+        assert_eq!(strip.offset().x, px(-200.));
+        assert_eq!(page.offset().y, px(0.));
+        wheel(cx, ScrollDelta::Lines(point(0., -1.)));
+        assert_eq!(strip.offset().x, px(-200.));
+        assert!(page.offset().y < px(0.));
+
+        // A delta that is no number does not move the strip.
+        wheel(cx, ScrollDelta::Lines(point(0., f32::NAN)));
+        assert_eq!(strip.offset().x, px(-200.));
+    }
+
+    #[gpui::test]
+    fn vertical_wheel_leaves_trackpads_and_horizontal_wheels_to_the_scroller(
+        cx: &mut TestAppContext,
+    ) {
+        let (cx, strip, page) = vertical_wheel_test(cx);
+        let line_height = cx.update(|window, _| window.line_height());
+
+        // A vertical trackpad swipe keeps the strip's axis lock.
+        wheel(cx, ScrollDelta::Pixels(point(px(0.), px(-20.))));
+        assert_eq!(strip.offset().x, px(0.));
+        assert_eq!(page.offset().y, px(-20.));
+
+        // A horizontal wheel reaches the strip's own listener.
+        wheel(cx, ScrollDelta::Lines(point(-1., 0.)));
+        assert_eq!(strip.offset().x, -line_height);
+    }
 }
diff --git a/third-party/gpui-kit/crates/base/src/scrollbar.rs b/third-party/gpui-kit/crates/base/src/scrollbar.rs
index db7cc5e645..c502861fb1 100644
--- a/third-party/gpui-kit/crates/base/src/scrollbar.rs
+++ b/third-party/gpui-kit/crates/base/src/scrollbar.rs
@@ -839,6 +839,8 @@ pub struct Scrollbar {
     scroll_handle: Rc<dyn ScrollbarHandle>,
     scroll_size: Option<Size<Pixels>>,
     viewport_bounds: Option<Bounds<Pixels>>,
+    // Scroll range may use a smaller viewport than the padded track bounds.
+    scroll_viewport_size: Option<Size<Pixels>>,
     use_layout_bounds: bool,
     /// Maximum frames per second for scrolling by drag. Default is 120 FPS.
     ///
@@ -864,6 +866,7 @@ impl Scrollbar {
             max_fps: 120,
             scroll_size: None,
             viewport_bounds: None,
+            scroll_viewport_size: None,
             use_layout_bounds: false,
             styles: ScrollbarStyles::default(),
             theme: None,
@@ -932,6 +935,11 @@ impl Scrollbar {
         self
     }
 
+    pub(crate) fn scroll_viewport_size(mut self, size: Size<Pixels>) -> Self {
+        self.scroll_viewport_size = Some(size);
+        self
+    }
+
     /// Use the scrollbar element's layout bounds as its viewport.
     ///
     /// This is useful for composite widgets whose scrollbar viewport excludes
@@ -1243,21 +1251,22 @@ struct ThumbGeometry {
 impl ThumbGeometry {
     fn new(
         origin: Pixels,
-        container: Pixels,
+        track_size: Pixels,
+        viewport_size: Pixels,
         content: Pixels,
         margin_end: Pixels,
         inset: Pixels,
         min_length: Pixels,
     ) -> Self {
-        let track = (container - margin_end).max(px(0.));
-        let logical_length = (container / content * container).max(min_length).min(track);
+        let track = (track_size - margin_end).max(px(0.));
+        let logical_length = (viewport_size / content * track).max(min_length).min(track);
         let inset = inset.clamp(px(0.), logical_length / 2.);
         Self {
             origin,
             inset,
             length: logical_length - inset * 2.,
             travel: track - logical_length,
-            extent: content - container,
+            extent: content - viewport_size,
         }
     }
 
@@ -1424,6 +1433,7 @@ impl Element for Scrollbar {
         let scroll_size = self
             .scroll_size
             .unwrap_or(self.scroll_handle.content_size());
+        let scroll_viewport_size = self.scroll_viewport_size.unwrap_or(hitbox.size);
 
         for axis in self.axis.all().into_iter() {
             let is_vertical = axis.is_vertical();
@@ -1433,19 +1443,22 @@ impl Element for Scrollbar {
                 .width
                 .or(self.resolved_theme(cx).scrollbar.styles().track.width)
                 .unwrap_or(WIDTH);
-            let (scroll_area_size, container_size, scroll_position) = if is_vertical {
-                (
-                    scroll_size.height,
-                    hitbox.size.height,
-                    self.scroll_handle.offset().y,
-                )
-            } else {
-                (
-                    scroll_size.width,
-                    hitbox.size.width,
-                    self.scroll_handle.offset().x,
-                )
-            };
+            let (scroll_area_size, container_size, scroll_viewport_size, scroll_position) =
+                if is_vertical {
+                    (
+                        scroll_size.height,
+                        hitbox.size.height,
+                        scroll_viewport_size.height,
+                        self.scroll_handle.offset().y,
+                    )
+                } else {
+                    (
+                        scroll_size.width,
+                        hitbox.size.width,
+                        scroll_viewport_size.width,
+                        self.scroll_handle.offset().x,
+                    )
+                };
 
             // The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible.
             let margin_end = if has_both && !is_vertical {
@@ -1455,7 +1468,7 @@ impl Element for Scrollbar {
             };
 
             // Hide scrollbar, if the scroll area is smaller than the container.
-            if scroll_area_size <= container_size {
+            if scroll_area_size <= scroll_viewport_size {
                 has_both = false;
                 continue;
             }
@@ -1538,6 +1551,7 @@ impl Element for Scrollbar {
             let geometry = ThumbGeometry::new(
                 origin,
                 container_size,
+                scroll_viewport_size,
                 scroll_area_size,
                 margin_end,
                 inset,
@@ -1547,6 +1561,7 @@ impl Element for Scrollbar {
             let active_geometry = ThumbGeometry::new(
                 origin,
                 container_size,
+                scroll_viewport_size,
                 scroll_area_size,
                 margin_end,
                 active_inset,
@@ -1644,7 +1659,6 @@ impl Element for Scrollbar {
                         radius = px(0.);
                     }
                     radius = clamp_thumb_radius(radius, state.thumb_fill_bounds);
-                    let bounds = state.bounds;
                     let thumb_bounds = state.thumb_bounds;
                     let geometry = state.geometry;
                     let active_geometry = state.active_geometry;
@@ -1764,11 +1778,15 @@ impl Element for Scrollbar {
 
                     if is_visible {
                         window.on_mouse_event({
+                            // The hitbox rather than the bounds, so that an
+                            // element above the bar (a menu, a dialog) keeps
+                            // the press.
+                            let bar_hitbox = state.bar_hitbox.clone();
                             let state = scrollbar_state.clone();
                             let scroll_handle = self.scroll_handle.clone();
 
-                            move |event: &MouseDownEvent, phase, _, cx| {
-                                if phase.bubble() && bounds.contains(&event.position) {
+                            move |event: &MouseDownEvent, phase, window, cx| {
+                                if phase.bubble() && bar_hitbox.is_hovered(window) {
                                     cx.stop_propagation();
 
                                     if thumb_bounds.contains(&event.position) {
@@ -1805,6 +1823,7 @@ impl Element for Scrollbar {
                     }
 
                     window.on_mouse_event({
+                        let bar_hitbox = state.bar_hitbox.clone();
                         let scroll_handle = self.scroll_handle.clone();
                         let state = scrollbar_state.clone();
                         let max_fps_duration = Duration::from_secs_f64(1. / self.max_fps as f64);
@@ -1815,7 +1834,8 @@ impl Element for Scrollbar {
                             // we need to update the hovered state and increase the last_scroll_time.
                             let need_hover_to_update = is_hover_to_show || is_visible;
                             // Update hovered state for scrollbar
-                            if bounds.contains(&event.position) && need_hover_to_update {
+                            let bar_hovered = bar_hitbox.is_hovered(window);
+                            if bar_hovered && need_hover_to_update {
                                 let hover_changed = state.get().hovered_axis != Some(axis);
                                 state.set(state.get().with_hovered(Some(axis), Instant::now()));
                                 notify |= hover_changed;
@@ -1826,6 +1846,7 @@ impl Element for Scrollbar {
 
                             // Update hovered state for scrollbar thumb
                             if tracks_thumb_hover(mode, is_visible)
+                                && bar_hovered
                                 && thumb_bounds.contains(&event.position)
                             {
                                 if state.get().hovered_on_thumb != Some(axis) {
@@ -1904,8 +1925,8 @@ mod tests {
     use std::cell::{Cell, RefCell};
 
     use gpui::{
-        AppContext as _, Context, Modifiers, MouseButton, ParentElement as _, Render, Styled as _,
-        TestAppContext, VisualTestContext, div,
+        AppContext as _, Context, InteractiveElement as _, Modifiers, MouseButton,
+        ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, div,
     };
 
     struct LocalProbe {
@@ -2638,14 +2659,17 @@ mod tests {
         handle: TestHandle,
         axis: ScrollbarAxis,
         mode: ScrollbarMode,
+        scroll_viewport_size: Option<Size<Pixels>>,
     }
 
     impl Render for ScrollbarHarness {
         fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
-            div()
-                .relative()
-                .size(px(100.))
-                .child(Scrollbar::new(&self.handle).axis(self.axis).mode(self.mode))
+            let mut scrollbar = Scrollbar::new(&self.handle).axis(self.axis).mode(self.mode);
+            if let Some(size) = self.scroll_viewport_size {
+                scrollbar = scrollbar.scroll_viewport_size(size);
+            }
+
+            div().relative().size(px(100.)).child(scrollbar)
         }
     }
 
@@ -2658,12 +2682,52 @@ mod tests {
         let handle = TestHandle::new(content_size);
         let (_, cx) = cx.add_window_view({
             let handle = handle.clone();
-            move |_, _| ScrollbarHarness { handle, axis, mode }
+            move |_, _| ScrollbarHarness {
+                handle,
+                axis,
+                mode,
+                scroll_viewport_size: None,
+            }
         });
         cx.update(|window, cx| window.draw(cx).clear(cx));
         (cx, handle)
     }
 
+    #[gpui::test]
+    fn thumb_drag_reaches_content_end_when_track_includes_padding(cx: &mut TestAppContext) {
+        let handle = TestHandle::new(size(px(100.), px(160.)));
+        let (_, cx) = cx.add_window_view({
+            let handle = handle.clone();
+            move |_, _| ScrollbarHarness {
+                handle,
+                axis: ScrollbarAxis::Vertical,
+                mode: ScrollbarMode::Always,
+                scroll_viewport_size: Some(size(px(100.), px(80.))),
+            }
+        });
+        cx.update(|window, cx| window.draw(cx).clear(cx));
+
+        cx.simulate_mouse_down(
+            point(px(95.), px(20.)),
+            MouseButton::Left,
+            Modifiers::default(),
+        );
+        cx.update(|window, cx| window.draw(cx).clear(cx));
+        cx.simulate_mouse_move(
+            point(px(95.), px(99.)),
+            Some(MouseButton::Left),
+            Modifiers::default(),
+        );
+        cx.update(|window, cx| window.draw(cx).clear(cx));
+        cx.simulate_mouse_up(
+            point(px(95.), px(99.)),
+            MouseButton::Left,
+            Modifiers::default(),
+        );
+
+        assert_eq!(handle.offset().y, px(-80.));
+    }
+
     #[test]
     fn explicit_viewport_bounds_override_handle_bounds() {
         let expected = Bounds::new(point(px(12.), px(24.)), size(px(240.), px(96.)));
@@ -2897,6 +2961,78 @@ mod tests {
         assert_eq!(handle.offset(), Point::default());
     }
 
+    /// A vertical scrollbar with an occluding element over part of it, as a
+    /// menu or dialog would be.
+    struct CoveredHarness {
+        handle: TestHandle,
+        mode: ScrollbarMode,
+        cover: Bounds<Pixels>,
+    }
+
+    impl Render for CoveredHarness {
+        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+            div()
+                .relative()
+                .size(px(100.))
+                .child(
+                    Scrollbar::new(&self.handle)
+                        .axis(ScrollbarAxis::Vertical)
+                        .mode(self.mode),
+                )
+                .child(
+                    div()
+                        .absolute()
+                        .left(self.cover.left())
+                        .top(self.cover.top())
+                        .w(self.cover.size.width)
+                        .h(self.cover.size.height)
+                        .occlude(),
+                )
+        }
+    }
+
+    fn covered_harness(
+        cx: &mut TestAppContext,
+        mode: ScrollbarMode,
+        cover: Bounds<Pixels>,
+    ) -> (&mut VisualTestContext, TestHandle) {
+        let handle = TestHandle::new(size(px(100.), px(500.)));
+        let (_, cx) = cx.add_window_view({
+            let handle = handle.clone();
+            move |_, _| CoveredHarness {
+                handle,
+                mode,
+                cover,
+            }
+        });
+        cx.update(|window, cx| window.draw(cx).clear(cx));
+        (cx, handle)
+    }
+
+    #[gpui::test]
+    fn a_press_on_an_element_over_the_track_is_left_to_it(cx: &mut TestAppContext) {
+        let cover = Bounds::new(point(px(50.), px(60.)), size(px(50.), px(40.)));
+        let (cx, handle) = covered_harness(cx, ScrollbarMode::Always, cover);
+        cx.simulate_click(point(px(95.), px(80.)), Modifiers::default());
+        assert_eq!(handle.offset(), Point::default());
+
+        // The track left uncovered still takes a press.
+        cx.simulate_click(point(px(95.), px(50.)), Modifiers::default());
+        assert!(handle.offset().y < px(0.));
+    }
+
+    #[gpui::test]
+    fn hovering_an_element_over_a_hover_scrollbar_does_not_reveal_it(cx: &mut TestAppContext) {
+        let cover = Bounds::new(point(px(50.), px(0.)), size(px(50.), px(50.)));
+        let (cx, handle) = covered_harness(cx, ScrollbarMode::Hover, cover);
+        cx.simulate_mouse_move(point(px(95.), px(20.)), None, Modifiers::default());
+        cx.run_until_parked();
+
+        // Uncovered, but the bar never showed, so it takes no press.
+        cx.simulate_click(point(px(95.), px(80.)), Modifiers::default());
+        assert_eq!(handle.offset(), Point::default());
+    }
+
     #[gpui::test]
     fn hovering_reveals_scrollbar_for_track_interaction(cx: &mut TestAppContext) {
         let (cx, handle) = harness(
@@ -2922,6 +3058,7 @@ mod tests {
                 handle,
                 axis: ScrollbarAxis::Vertical,
                 mode: ScrollbarMode::Always,
+                scroll_viewport_size: None,
             }
         });
         cx.update(|window, cx| window.draw(cx).clear(cx));
@@ -2987,7 +3124,8 @@ mod tests {
 
     #[test]
     fn full_track_thumb_does_not_reset_the_scroll_offset() {
-        let geometry = ThumbGeometry::new(px(20.), px(40.), px(500.), px(0.), px(4.), px(64.));
+        let geometry =
+            ThumbGeometry::new(px(20.), px(40.), px(40.), px(500.), px(0.), px(4.), px(64.));
         let offset = point(px(0.), px(-100.));
         assert_eq!(
             geometry.drag_offset(
@@ -3000,6 +3138,26 @@ mod tests {
         );
     }
 
+    #[test]
+    fn thumb_geometry_maps_content_viewport_to_padded_track() {
+        let geometry =
+            ThumbGeometry::new(px(20.), px(96.), px(80.), px(160.), px(0.), px(0.), px(0.));
+
+        assert_eq!(geometry.length, px(48.));
+        assert_eq!(geometry.travel, px(48.));
+        assert_eq!(geometry.extent, px(80.));
+        assert_eq!(geometry.offset(px(68.), px(0.)), px(-80.));
+
+        let barely_overflowing =
+            ThumbGeometry::new(px(20.), px(96.), px(80.), px(85.), px(0.), px(0.), px(0.));
+        assert_eq!(barely_overflowing.extent, px(5.));
+        assert!(barely_overflowing.travel > px(0.));
+        assert_eq!(
+            barely_overflowing.offset(px(20.) + barely_overflowing.travel, px(0.)),
+            px(-5.)
+        );
+    }
+
     #[gpui::test]
     fn repeated_touch_and_mouse_drags_keep_the_painted_grab_point(cx: &mut TestAppContext) {
         struct Probe {
diff --git a/third-party/gpui-kit/crates/base/src/select.rs b/third-party/gpui-kit/crates/base/src/select.rs
index 71f9b88249..79253ad559 100644
--- a/third-party/gpui-kit/crates/base/src/select.rs
+++ b/third-party/gpui-kit/crates/base/src/select.rs
@@ -8,13 +8,18 @@ use gpui::{
 };
 
 use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
-use crate::{StyledExt as _, TestSupportExt as _};
+use crate::{StyledExt as _, TestSupportExt as _, a11y_disabled::A11yDisabledExt as _};
 
 const CONTEXT: &str = "Select";
 
 #[doc(hidden)]
 pub fn init(cx: &mut App) {
     cx.bind_keys([
+        KeyBinding::new(
+            "space",
+            Confirm { secondary: false },
+            Some("Select && !Input"),
+        ),
         KeyBinding::new("up", SelectUp, Some(CONTEXT)),
         KeyBinding::new("down", SelectDown, Some(CONTEXT)),
         KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
@@ -277,7 +282,6 @@ impl RenderOnce for Select {
                         return;
                     }
 
-                    cx.propagate();
                     if open {
                         if let Some(handler) = on_confirm.as_ref() {
                             handler(window, cx);
@@ -302,14 +306,17 @@ impl RenderOnce for Select {
             })
             .children(self.children)
             .refine_style(&self.style)
+            .a11y_disabled(disabled)
     }
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::input::InputState;
     use gpui::{
-        Context, Element as _, Focusable, Render, TestAppContext, VisualTestContext, accesskit, px,
+        AppContext as _, Context, Element as _, Entity, Focusable, Render, TestAppContext,
+        VisualTestContext, accesskit, px,
     };
     use std::sync::{Arc, Mutex};
 
@@ -318,7 +325,10 @@ mod tests {
         disabled: bool,
         focus_handle: FocusHandle,
         content_focus_handle: FocusHandle,
+        search_input: Option<Entity<InputState>>,
         changes: Arc<Mutex<Vec<bool>>>,
+        parent_confirms: Arc<Mutex<usize>>,
+        confirmations: Arc<Mutex<usize>>,
         /// Every step of a close, in the order it ran.
         closing: Arc<Mutex<Vec<&'static str>>>,
     }
@@ -330,8 +340,11 @@ mod tests {
                 disabled,
                 focus_handle: cx.focus_handle(),
                 content_focus_handle: cx.focus_handle(),
+                search_input: None,
                 changes: Arc::new(Mutex::new(Vec::new())),
                 closing: Arc::new(Mutex::new(Vec::new())),
+                parent_confirms: Arc::new(Mutex::new(0)),
+                confirmations: Arc::new(Mutex::new(0)),
             }
         }
     }
@@ -348,25 +361,41 @@ mod tests {
             let changes = self.changes.clone();
             let opened = self.closing.clone();
             let dismissed = self.closing.clone();
-
-            Select::new("select")
-                .open(self.open)
-                .disabled(self.disabled)
-                .focus_handle(&self.focus_handle)
-                .content_focus_handle(&self.content_focus_handle)
-                .on_open_change(move |open, _, cx| {
-                    changes.lock().unwrap().push(open);
-                    opened
-                        .lock()
-                        .unwrap()
-                        .push(if open { "open" } else { "close" });
-                    state.update(cx, |state, cx| {
-                        state.open = open;
-                        cx.notify();
-                    });
-                })
-                .on_dismiss(move |_, _| dismissed.lock().unwrap().push("dismiss"))
-                .child(div().track_focus(&self.content_focus_handle).size(px(20.)))
+            let confirmed = self.closing.clone();
+
+            let parent_confirms = self.parent_confirms.clone();
+            let confirmations = self.confirmations.clone();
+            div()
+                .on_action(move |_: &Confirm, _, _| *parent_confirms.lock().unwrap() += 1)
+                .child(
+                    Select::new("select")
+                        .open(self.open)
+                        .disabled(self.disabled)
+                        .focus_handle(&self.focus_handle)
+                        .content_focus_handle(&self.content_focus_handle)
+                        .on_open_change(move |open, _, cx| {
+                            changes.lock().unwrap().push(open);
+                            opened
+                                .lock()
+                                .unwrap()
+                                .push(if open { "open" } else { "close" });
+                            state.update(cx, |state, cx| {
+                                state.open = open;
+                                cx.notify();
+                            });
+                        })
+                        .on_dismiss(move |_, _| dismissed.lock().unwrap().push("dismiss"))
+                        .on_confirm(move |_, _| {
+                            confirmed.lock().unwrap().push("confirm");
+                            *confirmations.lock().unwrap() += 1;
+                        })
+                        .when_some(self.search_input.clone(), |this, input| {
+                            this.child(crate::Input::new(&input))
+                        })
+                        .when(self.search_input.is_none(), |this| {
+                            this.child(div().track_focus(&self.content_focus_handle).size(px(20.)))
+                        }),
+                )
         }
     }
 
@@ -412,6 +441,56 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    fn space_opens_and_confirms_a_non_searchable_select(cx: &mut TestAppContext) {
+        let (cx, state) = harness(cx, false);
+        cx.simulate_keystrokes("space");
+        cx.update(|window, cx| {
+            assert!(state.read(cx).open);
+            assert!(state.read(cx).content_focus_handle.is_focused(window));
+            window.draw(cx).clear(cx);
+        });
+        cx.simulate_keystrokes("space");
+        assert_eq!(
+            &*state
+                .read_with(cx, |state, _| state.closing.clone())
+                .lock()
+                .unwrap(),
+            &["open", "confirm"]
+        );
+    }
+
+    #[gpui::test]
+    fn space_in_a_select_search_input_remains_text(cx: &mut TestAppContext) {
+        let (cx, state) = harness(cx, false);
+        let input = cx.update(|window, cx| {
+            let input = cx.new(|cx| InputState::new(window, cx));
+            state.update(cx, |state, cx| {
+                state.content_focus_handle = input.focus_handle(cx);
+                state.search_input = Some(input.clone());
+                cx.notify();
+            });
+            window.draw(cx).clear(cx);
+            input
+        });
+        cx.simulate_keystrokes("space");
+        cx.update(|window, cx| {
+            assert!(state.read(cx).open);
+            assert!(input.focus_handle(cx).is_focused(window));
+            window.draw(cx).clear(cx);
+        });
+        cx.simulate_keystrokes("t w o space w o r d s");
+        assert_eq!(input.read_with(cx, |input, _| input.value()), "two words");
+        assert_eq!(
+            &*state
+                .read_with(cx, |state, _| state.closing.clone())
+                .lock()
+                .unwrap(),
+            &["open"]
+        );
+        assert!(state.read_with(cx, |state, _| state.open));
+    }
+
     #[gpui::test]
     fn escape_closes_and_restores_trigger_focus(cx: &mut TestAppContext) {
         let (cx, state) = harness(cx, false);
@@ -459,7 +538,7 @@ mod tests {
     fn disabled_select_is_not_keyboard_interactive(cx: &mut TestAppContext) {
         let (cx, state) = harness(cx, true);
 
-        cx.simulate_keystrokes("down enter");
+        cx.simulate_keystrokes("down enter space");
         assert!(!state.read_with(cx, |state, _| state.open));
         assert!(
             state
@@ -504,4 +583,17 @@ mod tests {
             assert!(!disabled.supports_action(accesskit::Action::Click));
         });
     }
+
+    #[gpui::test]
+    fn confirm_is_consumed_when_opening_and_accepting(cx: &mut TestAppContext) {
+        let (cx, state) = harness(cx, false);
+        cx.simulate_keystrokes("enter");
+        cx.update(|window, cx| window.draw(cx).clear(cx));
+        cx.simulate_keystrokes("enter");
+        state.read_with(cx, |state, _| {
+            assert!(state.open);
+            assert_eq!(*state.confirmations.lock().unwrap(), 1);
+            assert_eq!(*state.parent_confirms.lock().unwrap(), 0);
+        });
+    }
 }
diff --git a/third-party/gpui-kit/crates/base/src/slider.rs b/third-party/gpui-kit/crates/base/src/slider.rs
index 36b006be68..6d00348d1f 100644
--- a/third-party/gpui-kit/crates/base/src/slider.rs
+++ b/third-party/gpui-kit/crates/base/src/slider.rs
@@ -9,7 +9,7 @@ use gpui::{
     TouchDragEvent, TouchPhase, Window, div, prelude::FluentBuilder as _, px,
 };
 
-use crate::{element_ext::ElementExt, geometry::AxisExt};
+use crate::{a11y_disabled::A11yDisabledExt as _, element_ext::ElementExt, geometry::AxisExt};
 
 /// Events emitted by the [`SliderState`].
 pub enum SliderEvent {
@@ -522,28 +522,30 @@ impl RenderOnce for Slider {
             } else {
                 Orientation::Horizontal
             })
-            .on_a11y_action(AccessibleAction::Increment, {
-                let state = slider_state.clone();
-                move |_, window, cx| {
-                    state.update(cx, |state, cx| {
-                        let value =
-                            (state.value().end() + state.step_value()).min(state.max_value());
-                        state.set_value(value, window, cx);
-                    });
-                }
-            })
-            .on_a11y_action(AccessibleAction::Decrement, {
-                let state = slider_state.clone();
-                move |_, window, cx| {
-                    state.update(cx, |state, cx| {
-                        let value =
-                            (state.value().end() - state.step_value()).max(state.min_value());
-                        state.set_value(value, window, cx);
-                    });
-                }
-            })
+            // A disabled slider takes no value, from the pointer or from
+            // assistive technology.
             .when(!self.disabled, |this| {
-                this.on_mouse_up(
+                this.on_a11y_action(AccessibleAction::Increment, {
+                    let state = slider_state.clone();
+                    move |_, window, cx| {
+                        state.update(cx, |state, cx| {
+                            let value =
+                                (state.value().end() + state.step_value()).min(state.max_value());
+                            state.set_value(value, window, cx);
+                        });
+                    }
+                })
+                .on_a11y_action(AccessibleAction::Decrement, {
+                    let state = slider_state.clone();
+                    move |_, window, cx| {
+                        state.update(cx, |state, cx| {
+                            let value =
+                                (state.value().end() - state.step_value()).max(state.min_value());
+                            state.set_value(value, window, cx);
+                        });
+                    }
+                })
+                .on_mouse_up(
                     MouseButton::Left,
                     window.listener_for(&self.state, |state, _, _, cx| state.handle_release(cx)),
                 )
@@ -553,6 +555,7 @@ impl RenderOnce for Slider {
                 )
             })
             .children(self.children)
+            .a11y_disabled(self.disabled)
     }
 }
 
@@ -779,7 +782,14 @@ impl RenderOnce for SliderIndicator {
             .children(self.children)
             .on_prepaint({
                 let state = self.state;
-                move |bounds, _, cx| state.update(cx, |state, _| state.set_bounds(bounds))
+                // Only when they moved: with retained views, an update while
+                // the window draws renders every view that reads the state
+                // again.
+                move |bounds, _, cx| {
+                    if state.read(cx).bounds != bounds {
+                        state.update(cx, |state, _| state.set_bounds(bounds));
+                    }
+                }
             })
     }
 }
@@ -935,4 +945,88 @@ mod tests {
     fn legacy_logarithmic_validation_is_preserved() {
         let _ = SliderState::new().scale(SliderScale::Logarithmic);
     }
+
+    /// A slider's bar beside a view that reads its state, under retained views.
+    #[cfg(feature = "gpui-fast")]
+    mod retained {
+        use std::{cell::Cell, rc::Rc};
+
+        use gpui::{
+            AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _,
+            TestAppContext, Window, div, px,
+        };
+
+        use super::super::{SliderIndicator, SliderState};
+
+        /// A view that shows a slider's value, counting its renders.
+        struct Readout {
+            state: Entity<SliderState>,
+            renders: Rc<Cell<usize>>,
+        }
+
+        impl Render for Readout {
+            fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+                self.renders.set(self.renders.get() + 1);
+                div().child(self.state.read(cx).value().to_string())
+            }
+        }
+
+        /// A slider's bar in a view of its own.
+        struct Bar {
+            state: Entity<SliderState>,
+        }
+
+        impl Render for Bar {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                div()
+                    .w(px(120.))
+                    .h(px(24.))
+                    .child(SliderIndicator::new(&self.state).size_full())
+            }
+        }
+
+        /// The bar and the readout side by side.
+        struct Pair {
+            bar: Entity<Bar>,
+            readout: Entity<Readout>,
+        }
+
+        impl Render for Pair {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                div()
+                    .flex()
+                    .child(self.bar.clone())
+                    .child(self.readout.clone())
+            }
+        }
+
+        /// Drawing a slider's bar again where it was changes nothing in the
+        /// slider's state: a view beside it that reads the state is not built
+        /// again for it.
+        #[gpui::test]
+        fn drawing_a_slider_again_leaves_its_readers_retained(cx: &mut TestAppContext) {
+            let renders = Rc::new(Cell::new(0));
+            let (view, cx) = cx.add_window_view({
+                let renders = renders.clone();
+                move |_, cx| {
+                    let state = cx.new(|_| SliderState::new());
+                    Pair {
+                        bar: cx.new(|_| Bar {
+                            state: state.clone(),
+                        }),
+                        readout: cx.new(|_| Readout { state, renders }),
+                    }
+                }
+            });
+            cx.update(|window, cx| window.draw(cx).clear(cx));
+            cx.update(|window, cx| window.draw(cx).clear(cx));
+            let rendered = renders.get();
+            for _ in 0..3 {
+                view.update(cx, |pair, cx| pair.bar.update(cx, |_, cx| cx.notify()));
+                cx.update(|window, cx| window.draw(cx).clear(cx));
+            }
+            cx.update(|window, cx| window.draw(cx).clear(cx));
+            assert_eq!(renders.get(), rendered, "the readout was built again");
+        }
+    }
 }
diff --git a/third-party/gpui-kit/crates/base/src/switch.rs b/third-party/gpui-kit/crates/base/src/switch.rs
index ff72fe2ff6..cdd881dbd5 100644
--- a/third-party/gpui-kit/crates/base/src/switch.rs
+++ b/third-party/gpui-kit/crates/base/src/switch.rs
@@ -8,7 +8,7 @@ use gpui::{
 };
 use smallvec::SmallVec;
 
-use crate::{StateStyle, StyledExt as _, TestSupportExt as _};
+use crate::{StateStyle, StyledExt as _, TestSupportExt as _, a11y_disabled::A11yDisabledExt as _};
 
 type ChangeHandler = Rc<dyn Fn(bool, &ClickEvent, &mut Window, &mut App)>;
 
@@ -299,6 +299,9 @@ impl Switch {
     }
 
     /// Sets the name exposed to accessibility clients.
+    ///
+    /// A switch without text children has no accessible name, so set this
+    /// whenever it is drawn without visible text.
     pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
         self.accessibility_label = Some(label.into());
         self
@@ -392,6 +395,7 @@ impl RenderOnce for Switch {
             )
             .children(self.children)
             .refine_style(&style)
+            .a11y_disabled(disabled)
     }
 }
 
@@ -732,8 +736,7 @@ mod tests {
         assert_eq!(disabled.role(), Role::Switch);
         assert_eq!(disabled.toggled(), Some(Toggled::False));
         assert!(!disabled.supports_action(accesskit::Action::Click));
-        // GPUI currently has no aria-disabled setter. Keep the limitation
-        // explicit instead of claiming an AccessKit disabled state.
-        assert!(!disabled.is_disabled());
+        assert!(!enabled.is_disabled());
+        assert!(disabled.is_disabled());
     }
 }
diff --git a/third-party/gpui-kit/crates/base/src/tabs.rs b/third-party/gpui-kit/crates/base/src/tabs.rs
index 782438c737..a27cf67cff 100644
--- a/third-party/gpui-kit/crates/base/src/tabs.rs
+++ b/third-party/gpui-kit/crates/base/src/tabs.rs
@@ -8,7 +8,7 @@ use gpui::{
 };
 use smallvec::SmallVec;
 
-use crate::{StateStyle, StyledExt as _, TestSupportExt as _};
+use crate::{StateStyle, StyledExt as _, TestSupportExt as _, a11y_disabled::A11yDisabledExt as _};
 
 type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
 
@@ -179,6 +179,7 @@ impl RenderOnce for Tab {
             )
             .children(self.children)
             .refine_style(&style)
+            .a11y_disabled(disabled)
     }
 }
 
diff --git a/third-party/gpui-kit/crates/base/src/test_support.rs b/third-party/gpui-kit/crates/base/src/test_support.rs
index 57ccc4e724..2368f827ed 100644
--- a/third-party/gpui-kit/crates/base/src/test_support.rs
+++ b/third-party/gpui-kit/crates/base/src/test_support.rs
@@ -266,6 +266,15 @@ impl<E: Element> Observed<E> {
         }
     }
 
+    /// Replaces the observed element while keeping its observation, so a
+    /// wrapper inside it is seen by the snapshot.
+    pub(crate) fn map_inner<F>(self, f: impl FnOnce(E) -> F) -> Observed<F> {
+        Observed {
+            inner: f(self.inner),
+            focus: self.focus,
+        }
+    }
+
     pub(crate) fn new(inner: E) -> Self {
         assert!(
             Element::id(&inner).is_some(),
diff --git a/third-party/gpui-kit/crates/base/src/text/document.rs b/third-party/gpui-kit/crates/base/src/text/document.rs
index f28ae4ae6a..362357fbc0 100644
--- a/third-party/gpui-kit/crates/base/src/text/document.rs
+++ b/third-party/gpui-kit/crates/base/src/text/document.rs
@@ -10,7 +10,7 @@ use std::{
 
 use crate::text::{
     SelectionFormat,
-    node::{BlockNode, NodeContext, SourceRangeSelection},
+    node::{BlockNode, NodeContext, SelectionStyles, SourceRangeSelection},
 };
 
 /// The parsed document AST.
@@ -53,8 +53,11 @@ pub(crate) enum PrevBlock {
 /// that shows anything, and the visible block before it. Link definitions
 /// and other blocks that render nothing are skipped, so a document opening
 /// with `[ref]: url` still starts flush.
-pub(crate) fn flow_position(blocks: &[BlockNode], ix: usize) -> (bool, PrevBlock) {
-    let prev = blocks[..ix].iter().rev().find(|block| {
+pub(crate) fn flow_position<B: std::borrow::Borrow<BlockNode>>(
+    blocks: &[B],
+    ix: usize,
+) -> (bool, PrevBlock) {
+    let prev = blocks[..ix].iter().rev().map(B::borrow).find(|block| {
         !matches!(
             block,
             BlockNode::Definition { .. } | BlockNode::Break { .. } | BlockNode::Unknown
@@ -91,16 +94,13 @@ impl ParsedDocument {
     /// top-level blocks are joined with a blank line so the result re-renders
     /// with the same block structure.
     ///
-    /// A block only learns its selection when it is painted, so in a scrollable
-    /// (virtualized) view every block the user scrolled past reports nothing.
-    /// The selection is one continuous range, so blocks it spans that came up
-    /// empty are inside it and are emitted whole rather than dropped. `blocks`
-    /// bounds that span; it comes from the selection endpoints, which hold on to
-    /// their block index even after it scrolls out of view (the painted blocks
-    /// alone cannot bound the span, because the press that starts a drag leaves
-    /// an empty selection that never reaches paint). Without it, fall back to
-    /// the painted blocks. (Source mode is only used by non-scrollable views,
-    /// where `blocks` is `None` and every block paints.)
+    /// Paint and synchronous Copy project the current gesture onto retained
+    /// native layouts. In a scrollable (virtualized) view some covered blocks
+    /// have never been laid out and report nothing. The selection is continuous,
+    /// so its interior blocks are emitted whole rather than dropped. `blocks`
+    /// bounds that span; it comes from the selection endpoints, which hold on
+    /// to their block index even after scrolling out of view. Without it, fall
+    /// back to the blocks with projected selections.
     ///
     /// A standalone image (a paragraph that is only an image) has no selectable
     /// text run, so it never carries a selection of its own. It is therefore
@@ -132,6 +132,20 @@ impl ParsedDocument {
             None => (painted_first, painted_last),
         };
 
+        // A selection within one style copies as the text it renders; one
+        // that is only partly painted cannot tell, and keeps its source.
+        let format = match format {
+            SelectionFormat::SourceAcrossStyles
+                if first == last
+                    && painted[first]
+                    && self.blocks[first].selection_styles() != SelectionStyles::Mixed =>
+            {
+                SelectionFormat::Plain
+            }
+            SelectionFormat::SourceAcrossStyles => SelectionFormat::Source,
+            format => format,
+        };
+
         if format == SelectionFormat::Plain {
             let mut text = String::new();
             for (ix, block) in self.blocks.iter().enumerate().take(last + 1).skip(first) {
diff --git a/third-party/gpui-kit/crates/base/src/text/editor_host.rs b/third-party/gpui-kit/crates/base/src/text/editor_host.rs
new file mode 100644
index 0000000000..f908a4349f
--- /dev/null
+++ b/third-party/gpui-kit/crates/base/src/text/editor_host.rs
@@ -0,0 +1,1324 @@
+//! Host API for external editors.
+//!
+//! An editor that lives outside gpui-kit keeps its own document model and
+//! hands gpui-kit a [`BlockNode`] tree, the same tree [`TextView`] renders.
+//! gpui-kit renders it and gives back, per text leaf, the geometry the editor
+//! needs for hit-testing, caret placement and platform input (IME) bounds.
+//!
+//! The boundary is deliberately narrow:
+//!
+//! - **In:** a [`BlockNode`] tree whose text leaves carry a host-chosen `u64`
+//!   key (see [`Paragraph::with_leaf_key`] and [`CodeBlock::with_leaf_key`]),
+//!   a [`RenderContext`], and per-key decoration ranges.
+//! - **Out:** one opaque [`LeafGeometry`] per key from
+//!   [`collect_leaf_geometry`].
+//!
+//! Nothing lower is exposed: no `TextLayout`, no layout internals, no sink
+//! trait, no retained-layout or projection caches.
+//!
+//! The host owns editing semantics, document revisions, accessibility and native
+//! input integration. This module provides horizontal TextView block rendering;
+//! it is not a complete visual-caret navigation or pagination API. Extend the
+//! public boundary additively while keeping rendering internals private.
+//!
+//! [`TextView`]: super::TextView
+
+use std::{cell::RefCell, collections::HashMap, ops::Range, rc::Rc, sync::Arc};
+
+use gpui::{
+    AnyElement, App, Bounds, ClickEvent, ElementId, HighlightStyle, Hsla, ImageSource,
+    InteractiveElement as _, IntoElement as _, ParentElement as _, Pixels, Point, SharedString,
+    SharedUri, TextLayout, Window, WrappedLineLayout, div, point, px,
+};
+use unicode_segmentation::UnicodeSegmentation as _;
+
+use super::{
+    TextViewDefaults, TextViewStyle,
+    document::{NodeRenderOptions, PrevBlock, flow_position},
+    editor_seam::{self, EditorSink},
+    format::markdown,
+    markdown_ext::MarkdownExtensions,
+    node::NodeContext,
+};
+
+pub use super::node::{
+    BlockNode, BlockState, CodeBlock, ColumnumnAlign as ColumnAlign, ImageNode, InlineNode,
+    LinkMark, Paragraph, Span, Table, TextMark,
+};
+
+/// Parses Markdown with the parser [`TextView`](super::TextView) uses,
+/// including the given extensions, into the block tree.
+pub fn parse_markdown(
+    source: &str,
+    extensions: Arc<MarkdownExtensions>,
+) -> Result<Vec<BlockNode>, SharedString> {
+    let mut cx = NodeContext {
+        markdown_extensions: extensions,
+        ..Default::default()
+    };
+    let parsed = markdown::parse(source, &mut cx)?;
+    Ok(Arc::unwrap_or_clone(parsed.blocks))
+}
+
+/// What [`BlockNode::render`] needs besides the node: the text style, code
+/// highlighter and Markdown extensions.
+#[derive(Clone)]
+pub struct RenderContext {
+    node: NodeContext,
+}
+
+impl RenderContext {
+    /// Creates a local rendering context without reading application globals.
+    /// The host supplies body typography through its container or
+    /// `with_body_font_size`.
+    pub fn new(style: TextViewStyle) -> Self {
+        Self {
+            node: NodeContext {
+                style: Arc::new(style),
+                link_source: super::link::LinkSource::ExternalEditor(None),
+                ..Default::default()
+            },
+        }
+    }
+
+    /// Replaces code highlighting for this context only.
+    pub fn with_code_block_highlighter<F>(mut self, highlighter: F) -> Self
+    where
+        F: Fn(&CodeBlock) -> Vec<(Range<usize>, HighlightStyle)> + Send + Sync + 'static,
+    {
+        self.node.code_block_highlighter = Some(Arc::new(highlighter));
+        self
+    }
+
+    /// Resolves document image URIs. The host owns resource access policy.
+    pub fn with_image_source<F>(mut self, resolver: F) -> Self
+    where
+        F: Fn(&SharedUri) -> ImageSource + Send + Sync + 'static,
+    {
+        self.node.image_source = Some(Arc::new(resolver));
+        self
+    }
+
+    /// Handles link activation. Without a handler, links use App::open_url.
+    pub fn on_link_click<F>(mut self, handler: F) -> Self
+    where
+        F: Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync + 'static,
+    {
+        self.node.link_source = super::link::LinkSource::ExternalEditor(Some(Arc::new(handler)));
+        self
+    }
+
+    /// Registers a reference-link definition from the host's document.
+    /// Supply definitions for the entire document, including virtualized blocks.
+    pub fn with_link_reference(
+        mut self,
+        identifier: impl Into<SharedString>,
+        link: LinkMark,
+    ) -> Self {
+        self.node.link_refs.insert(identifier.into(), link);
+        self
+    }
+
+    /// The context TextView would start from: the global [`TextViewDefaults`]
+    /// style and code-block highlighter when set, else the theme's style.
+    pub fn from_app(cx: &App) -> Self {
+        let theme = crate::Theme::global(cx);
+        let defaults = cx.try_global::<TextViewDefaults>();
+        let style = defaults
+            .and_then(|defaults| defaults.style().cloned())
+            .unwrap_or_else(|| TextViewStyle::from_theme(&theme));
+        Self {
+            node: NodeContext {
+                style: Arc::new(style),
+                link_source: super::link::LinkSource::ExternalEditor(None),
+                code_block_highlighter: defaults.and_then(|d| d.code_block_highlighter()),
+                ..Default::default()
+            },
+        }
+    }
+
+    /// Replaces the text style.
+    pub fn with_style(mut self, style: TextViewStyle) -> Self {
+        self.node.style = Arc::new(style);
+        self
+    }
+
+    /// Sets the Markdown extensions that render custom nodes.
+    pub fn with_markdown_extensions(mut self, extensions: Arc<MarkdownExtensions>) -> Self {
+        self.node.markdown_extensions = extensions;
+        self
+    }
+
+    /// Sets the font size body text inherits, which the block gaps and
+    /// heading sizes are relative to.
+    pub fn with_body_font_size(mut self, size: Pixels) -> Self {
+        self.node.body_font_size = size;
+        self
+    }
+
+    /// The text style blocks render with.
+    pub fn style(&self) -> &TextViewStyle {
+        &self.node.style
+    }
+}
+
+/// Where a block stands in the flow it is rendered in, which decides the gaps
+/// around it: the first block takes no gap above, the last none below, and a
+/// heading or rule counts the block before it.
+#[derive(Clone, Copy, Debug)]
+pub struct FlowPosition {
+    is_first: bool,
+    is_last: bool,
+    prev: PrevBlock,
+}
+
+impl FlowPosition {
+    /// The position of `blocks[ix]` among its siblings. Blocks that render
+    /// nothing (definitions, breaks) are skipped when looking for the block
+    /// before it.
+    pub fn at(blocks: &[&BlockNode], ix: usize, is_last: bool) -> Self {
+        let (is_first, prev) = flow_position(blocks, ix);
+        Self {
+            is_first,
+            is_last,
+            prev,
+        }
+    }
+
+    /// Overrides whether the block opens its flow, e.g. for a chunk of a
+    /// long list rendered as a list of its own.
+    pub fn first(mut self, is_first: bool) -> Self {
+        self.is_first = is_first;
+        self
+    }
+
+    /// Overrides whether the block closes its flow.
+    pub fn last(mut self, is_last: bool) -> Self {
+        self.is_last = is_last;
+        self
+    }
+}
+
+impl BlockNode {
+    /// Renders under a stable host-owned identity. Use a domain ID, not a list
+    /// position, when inserting, reordering or virtualizing blocks. Identity
+    /// scopes GPUI element state independently of the leaf keys used for geometry.
+    pub fn render_with_id(
+        &self,
+        id: impl Into<ElementId>,
+        flow: FlowPosition,
+        render_context: &RenderContext,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> AnyElement {
+        div()
+            .id(id)
+            .child(self.render(0, flow, render_context, window, cx))
+            .into_any_element()
+    }
+
+    /// Renders the block as TextView does. `ix` identifies the block among
+    /// its siblings (element ids derive from it).
+    pub fn render(
+        &self,
+        ix: usize,
+        flow: FlowPosition,
+        cx: &RenderContext,
+        window: &mut Window,
+        app: &mut App,
+    ) -> AnyElement {
+        self.render_block(
+            NodeRenderOptions {
+                ix,
+                is_first: flow.is_first,
+                is_last: flow.is_last,
+                prev: flow.prev,
+                ..Default::default()
+            },
+            &cx.node,
+            window,
+            app,
+        )
+    }
+}
+
+/// Runs `f`, which should prepaint rendered blocks, and returns its result
+/// with the geometry every keyed text leaf reported meanwhile.
+///
+/// `decorations` maps a leaf key to byte ranges of that leaf's text (atoms
+/// count as one `U+FFFC` each) to paint behind the text: the host's selection,
+/// find matches, anything. Leaves without a key, or without an entry, paint as
+/// usual. Atom decorations cover the whole atom and paint over its content
+/// so selections remain visible over opaque images. Supply translucent colors
+/// for overlays. Ranges are half-open; empty ranges paint nothing.
+/// Scopes nest, and the previous one is restored even if `f` panics.
+///
+/// Keys must be unique within one collection scope. Nested scopes are isolated:
+/// an outer scope does not collect the inner scope's leaves. Only prepainted
+/// leaves are returned; absence is not evidence that a document leaf was removed.
+/// The host owns document revisions, virtualization, focus, native input and
+/// UTF-16/UTF-8 conversion for platform input APIs.
+pub fn collect_leaf_geometry<R>(
+    decorations: HashMap<u64, Vec<(Range<usize>, Hsla)>>,
+    f: impl FnOnce() -> R,
+) -> (R, HashMap<u64, LeafGeometry>) {
+    let collector = Rc::new(Collector {
+        pieces: RefCell::default(),
+        decorations,
+    });
+    let result = editor_seam::with_sink(collector.clone(), f);
+    let mut pieces = collector.pieces.take();
+    pieces.retain(|_, pieces| !pieces.is_empty());
+    let leaves = pieces
+        .into_iter()
+        .map(|(key, pieces)| (key, LeafGeometry { pieces }))
+        .collect();
+    (result, leaves)
+}
+
+struct Collector {
+    pieces: RefCell<HashMap<u64, Vec<Piece>>>,
+    decorations: HashMap<u64, Vec<(Range<usize>, Hsla)>>,
+}
+
+impl EditorSink for Collector {
+    fn report(&self, key: u64, layout: &TextLayout) {
+        self.pieces
+            .borrow_mut()
+            .insert(key, vec![Piece::Whole(LayoutSnapshot::new(layout))]);
+    }
+
+    fn begin_pieces(&self, key: u64) {
+        self.pieces.borrow_mut().insert(key, Vec::new());
+    }
+
+    fn report_text_piece(&self, key: u64, start: usize, layout: &TextLayout) {
+        self.pieces
+            .borrow_mut()
+            .entry(key)
+            .or_default()
+            .push(Piece::Text {
+                start,
+                layout: LayoutSnapshot::new(layout),
+            });
+    }
+
+    fn report_box_piece(&self, key: u64, range: Range<usize>, bounds: Bounds<Pixels>) {
+        self.pieces
+            .borrow_mut()
+            .entry(key)
+            .or_default()
+            .push(Piece::Atom { range, bounds });
+    }
+
+    fn report_empty_piece(&self, key: u64, start: usize, bounds: Bounds<Pixels>) {
+        self.pieces
+            .borrow_mut()
+            .entry(key)
+            .or_default()
+            .push(Piece::Empty { start, bounds });
+    }
+
+    fn backgrounds(&self, key: u64) -> Vec<(Range<usize>, Hsla)> {
+        self.decorations.get(&key).cloned().unwrap_or_default()
+    }
+}
+
+/// Copy mutable frame state, sharing only immutable shaped lines.
+#[derive(Clone)]
+struct LayoutSnapshot {
+    bounds: Bounds<Pixels>,
+    len: usize,
+    line_height: Pixels,
+    text: SharedString,
+    lines: Vec<Arc<WrappedLineLayout>>,
+}
+
+impl LayoutSnapshot {
+    fn new(layout: &TextLayout) -> Self {
+        Self {
+            bounds: layout.bounds(),
+            len: layout.len(),
+            line_height: layout.line_height(),
+            text: layout.text().into(),
+            lines: layout.line_layouts().into_iter().collect(),
+        }
+    }
+
+    fn bounds(&self) -> Bounds<Pixels> {
+        self.bounds
+    }
+    fn len(&self) -> usize {
+        self.len
+    }
+    fn line_height(&self) -> Pixels {
+        self.line_height
+    }
+    fn text(&self) -> &str {
+        &self.text
+    }
+    fn line_layouts(&self) -> &[Arc<WrappedLineLayout>] {
+        &self.lines
+    }
+
+    fn position_for_index(&self, index: usize) -> Option<Point<Pixels>> {
+        let mut origin = self.bounds.origin;
+        let mut start = 0;
+        for line in &self.lines {
+            if index <= start + line.len() {
+                return line
+                    .position_for_index(index - start, self.line_height)
+                    .map(|p| origin + p);
+            }
+            start += line.len() + 1;
+            origin.y += line.size(self.line_height).height;
+        }
+        None
+    }
+}
+
+/// How a leaf was laid out: one layout for the whole leaf, or (for a mixed
+/// paragraph rendered as a flow) single-line text fragments and atom boxes in
+/// leaf byte order.
+#[derive(Clone)]
+enum Piece {
+    Whole(LayoutSnapshot),
+    Text {
+        start: usize,
+        layout: LayoutSnapshot,
+    },
+    Empty {
+        start: usize,
+        bounds: Bounds<Pixels>,
+    },
+    Atom {
+        range: Range<usize>,
+        bounds: Bounds<Pixels>,
+    },
+}
+
+impl Piece {
+    fn bounds(&self) -> Bounds<Pixels> {
+        match self {
+            Piece::Whole(layout) | Piece::Text { layout, .. } => layout.bounds(),
+            Piece::Atom { bounds, .. } | Piece::Empty { bounds, .. } => *bounds,
+        }
+    }
+
+    fn range(&self) -> Range<usize> {
+        match self {
+            Piece::Whole(layout) => 0..layout.len(),
+            Piece::Text { start, layout } => *start..start + layout.len(),
+            Piece::Atom { range, .. } => range.clone(),
+            Piece::Empty { start, .. } => *start..*start,
+        }
+    }
+}
+
+/// Where one keyed text leaf was laid out in the last frame.
+///
+/// Indices are byte offsets into the leaf's text, where each inline atom
+/// (image, custom node) is one `U+FFFC` (3 bytes). Coordinates are window
+/// pixels and are valid for the frame that produced them. It is a value, not a
+/// handle: it owns its bounds and shares immutable shaped lines, never the
+/// renderer's mutable layout state. Later frames do not mutate this snapshot.
+/// The host must replace it after content, width, typography or scrolling changes.
+#[derive(Clone)]
+pub struct LeafGeometry {
+    pieces: Vec<Piece>,
+}
+
+impl LeafGeometry {
+    fn whole(&self) -> Option<&LayoutSnapshot> {
+        match self.pieces.as_slice() {
+            [Piece::Whole(layout)] => Some(layout),
+            _ => None,
+        }
+    }
+
+    /// The bounds of the leaf: the union of its pieces.
+    pub fn bounds(&self) -> Bounds<Pixels> {
+        self.pieces
+            .iter()
+            .map(Piece::bounds)
+            .reduce(|a, b| a.union(&b))
+            .unwrap_or_default()
+    }
+
+    /// The length of the leaf's text in bytes, atoms included.
+    pub fn len(&self) -> usize {
+        self.pieces
+            .iter()
+            .map(|piece| piece.range().end)
+            .max()
+            .unwrap_or(0)
+    }
+
+    /// Whether the leaf's text is empty (it still has a line box).
+    pub fn is_empty(&self) -> bool {
+        self.len() == 0
+    }
+
+    /// The height of a line.
+    pub fn line_height(&self) -> Pixels {
+        self.pieces
+            .iter()
+            .find_map(|piece| match piece {
+                Piece::Whole(layout) | Piece::Text { layout, .. } => Some(layout.line_height()),
+                Piece::Empty { bounds, .. } => Some(bounds.size.height),
+                Piece::Atom { .. } => None,
+            })
+            .unwrap_or_else(|| self.bounds().size.height)
+    }
+
+    /// The height of a caret: the font's ascent plus descent, not the whole
+    /// line box, which includes leading.
+    pub fn caret_height(&self) -> Pixels {
+        let line_height = self.line_height();
+        self.pieces
+            .iter()
+            .find_map(|piece| match piece {
+                Piece::Whole(layout) | Piece::Text { layout, .. } => {
+                    let line = layout.lines.first()?;
+                    let h = line.unwrapped_layout.ascent + line.unwrapped_layout.descent;
+                    (h > Pixels::ZERO).then_some(h)
+                }
+                Piece::Atom { .. } | Piece::Empty { .. } => None,
+            })
+            .unwrap_or(line_height * 0.85)
+            .min(line_height)
+    }
+
+    /// The top of a caret placed at byte `index`, in window coordinates.
+    pub fn position_for_index(&self, index: usize) -> Point<Pixels> {
+        if let Some(layout) = self.whole() {
+            return layout
+                .position_for_index(index.min(layout.len()))
+                .unwrap_or_else(|| self.bounds().origin);
+        }
+        // The piece the index starts, else the one it ends (a caret at a
+        // fragment boundary sits at the start of the next fragment).
+        let piece = self
+            .pieces
+            .iter()
+            .find(|p| p.range().contains(&index))
+            .or_else(|| self.pieces.iter().rev().find(|p| p.range().end == index))
+            .or(self.pieces.last());
+        let Some(piece) = piece else {
+            return self.bounds().origin;
+        };
+        match piece {
+            Piece::Whole(layout) | Piece::Text { layout, .. } => {
+                let local = index.saturating_sub(piece.range().start).min(layout.len());
+                layout
+                    .position_for_index(local)
+                    .unwrap_or_else(|| layout.bounds().origin)
+            }
+            Piece::Empty { bounds, .. } => bounds.origin,
+            Piece::Atom { range, bounds } => {
+                let x = if index >= range.end {
+                    bounds.right()
+                } else {
+                    bounds.left()
+                };
+                // Align with the text line: an atom taller than the line
+                // keeps the caret at its bottom line.
+                let y = (bounds.bottom() - self.line_height()).max(bounds.top());
+                point(x, y)
+            }
+        }
+    }
+
+    /// The byte index of the caret position closest to `at`: glyph halves
+    /// round to the nearer side, unlike `TextLayout::index_for_position`.
+    pub fn index_for_position(&self, at: Point<Pixels>) -> usize {
+        if let Some(layout) = self.whole() {
+            return whole_index_at(layout, at);
+        }
+        // The visual line under the point (pieces whose vertical extent
+        // holds it, else the nearest), then the horizontally nearest piece.
+        let dy = |p: &Piece| {
+            let b = p.bounds();
+            if at.y < b.top() {
+                b.top() - at.y
+            } else if at.y > b.bottom() {
+                at.y - b.bottom()
+            } else {
+                px(0.)
+            }
+        };
+        let dx = |p: &Piece| {
+            let b = p.bounds();
+            if at.x < b.left() {
+                b.left() - at.x
+            } else if at.x > b.right() {
+                at.x - b.right()
+            } else {
+                px(0.)
+            }
+        };
+        let Some(piece) = self.pieces.iter().min_by(|a, b| {
+            (dy(a), dx(a))
+                .partial_cmp(&(dy(b), dx(b)))
+                .unwrap_or(std::cmp::Ordering::Equal)
+        }) else {
+            return 0;
+        };
+        let index = match piece {
+            Piece::Whole(layout) => whole_index_at(layout, at),
+            // Fragments are one line: take the caret position nearest to the
+            // point (`closest_index_for_position` rounds up at an exact
+            // boundary inside scaled code text).
+            // Only grapheme boundaries are candidates: an interior byte of a
+            // multi-byte character maps to the next glyph's x, so it could
+            // tie with (and beat) the real boundary and split the character.
+            Piece::Text { start, layout } => {
+                let text = layout.text();
+                let nearest = text
+                    .grapheme_indices(true)
+                    .map(|(i, _)| i)
+                    .chain(std::iter::once(text.len()))
+                    .filter_map(|i| {
+                        layout
+                            .position_for_index(i)
+                            .map(|p| (i, (p.x - at.x).abs()))
+                    })
+                    .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
+                    .map_or(0, |(i, _)| i);
+                start + nearest
+            }
+            Piece::Empty { start, .. } => *start,
+            Piece::Atom { range, bounds } => {
+                if at.x < bounds.center().x {
+                    range.start
+                } else {
+                    range.end
+                }
+            }
+        };
+        index.min(self.len())
+    }
+
+    /// The byte range of the inline atom drawn under `at`, if any.
+    pub fn atom_at(&self, at: Point<Pixels>) -> Option<Range<usize>> {
+        self.pieces.iter().find_map(|piece| match piece {
+            Piece::Atom { range, bounds } if bounds.contains(&at) => Some(range.clone()),
+            _ => None,
+        })
+    }
+}
+
+/// The closest index in a whole-leaf layout for a window point.
+fn whole_index_at(layout: &LayoutSnapshot, at: Point<Pixels>) -> usize {
+    let bounds = layout.bounds();
+    let line_height = layout.line_height();
+    let mut top = bounds.top();
+    let mut start = 0;
+    let lines = layout.line_layouts();
+    for (ix, line) in lines.iter().enumerate() {
+        let height = line.size(line_height).height.max(line_height);
+        let is_last = ix + 1 == lines.len();
+        if at.y < top + height || is_last {
+            let local = point(
+                (at.x - bounds.left()).max(px(0.)),
+                (at.y - top).clamp(px(0.), (height - px(1.)).max(px(0.))),
+            );
+            let index = match line.closest_index_for_position(local, line_height) {
+                Ok(ix) | Err(ix) => ix,
+            };
+            return start + index;
+        }
+        top += height;
+        start += line.len() + 1;
+    }
+    0
+}
+
+#[cfg(test)]
+mod tests {
+    use std::cell::Cell;
+
+    use gpui::{
+        Context, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
+        Render, Styled as _, TestAppContext, VisualTestContext, div, rgb, rgba,
+    };
+
+    use super::*;
+    use crate::text::{InlineElement, MarkdownNode, markdown_ext::TestInlinePlugin};
+
+    type Geometries = Rc<RefCell<HashMap<u64, LeafGeometry>>>;
+
+    /// Stands in for an editor's root element: prepaints its child inside a
+    /// `collect_leaf_geometry` scope and keeps what the leaves reported.
+    struct Probe {
+        child: Option<AnyElement>,
+        decorations: HashMap<u64, Vec<(Range<usize>, Hsla)>>,
+        out: Geometries,
+        /// Replaces `collect_leaf_geometry` with a custom sink.
+        sink: Option<Rc<dyn EditorSink>>,
+    }
+
+    impl IntoElement for Probe {
+        type Element = Self;
+        fn into_element(self) -> Self::Element {
+            self
+        }
+    }
+
+    impl Element for Probe {
+        type RequestLayoutState = AnyElement;
+        type PrepaintState = ();
+
+        fn id(&self) -> Option<ElementId> {
+            None
+        }
+
+        fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
+            None
+        }
+
+        fn request_layout(
+            &mut self,
+            _: Option<&GlobalElementId>,
+            _: Option<&InspectorElementId>,
+            window: &mut Window,
+            cx: &mut App,
+        ) -> (LayoutId, AnyElement) {
+            let mut child = self.child.take().unwrap();
+            (child.request_layout(window, cx), child)
+        }
+
+        fn prepaint(
+            &mut self,
+            _: Option<&GlobalElementId>,
+            _: Option<&InspectorElementId>,
+            _: Bounds<Pixels>,
+            child: &mut AnyElement,
+            window: &mut Window,
+            cx: &mut App,
+        ) {
+            if let Some(sink) = self.sink.take() {
+                editor_seam::with_sink(sink, || child.prepaint(window, cx));
+                return;
+            }
+            let decorations = std::mem::take(&mut self.decorations);
+            let (_, geometries) = collect_leaf_geometry(decorations, || child.prepaint(window, cx));
+            *self.out.borrow_mut() = geometries;
+        }
+
+        fn paint(
+            &mut self,
+            _: Option<&GlobalElementId>,
+            _: Option<&InspectorElementId>,
+            _: Bounds<Pixels>,
+            child: &mut AnyElement,
+            _: &mut (),
+            window: &mut Window,
+            cx: &mut App,
+        ) {
+            child.paint(window, cx);
+        }
+    }
+
+    struct Host {
+        blocks: Vec<BlockNode>,
+        extensions: Arc<MarkdownExtensions>,
+        decorations: HashMap<u64, Vec<(Range<usize>, Hsla)>>,
+        out: Geometries,
+        width: Pixels,
+        sink: Option<Rc<dyn EditorSink>>,
+    }
+
+    impl Render for Host {
+        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+            let rcx = RenderContext::from_app(cx)
+                .with_markdown_extensions(self.extensions.clone())
+                .with_body_font_size(px(16.));
+            let refs: Vec<&BlockNode> = self.blocks.iter().collect();
+            let children: Vec<AnyElement> = refs
+                .iter()
+                .enumerate()
+                .map(|(ix, block)| {
+                    let flow = FlowPosition::at(&refs, ix, ix + 1 == refs.len());
+                    block.render(ix, flow, &rcx, window, cx)
+                })
+                .collect();
+            Probe {
+                child: Some(div().w(self.width).children(children).into_any_element()),
+                decorations: self.decorations.clone(),
+                out: self.out.clone(),
+                sink: self.sink.clone(),
+            }
+        }
+    }
+
+    fn draw(
+        cx: &mut TestAppContext,
+        blocks: Vec<BlockNode>,
+        decorations: HashMap<u64, Vec<(Range<usize>, Hsla)>>,
+        extensions: Arc<MarkdownExtensions>,
+        width: Pixels,
+    ) -> HashMap<u64, LeafGeometry> {
+        draw_and_inspect(cx, blocks, decorations, extensions, width, |_| ()).0
+    }
+
+    fn draw_and_inspect<R>(
+        cx: &mut TestAppContext,
+        blocks: Vec<BlockNode>,
+        decorations: HashMap<u64, Vec<(Range<usize>, Hsla)>>,
+        extensions: Arc<MarkdownExtensions>,
+        width: Pixels,
+        inspect: impl FnOnce(&mut Window) -> R,
+    ) -> (HashMap<u64, LeafGeometry>, R) {
+        cx.update(crate::init);
+        let out = Geometries::default();
+        let (_, vcx) = cx.add_window_view({
+            let out = out.clone();
+            move |_, _| Host {
+                blocks,
+                extensions,
+                decorations,
+                out,
+                width,
+                sink: None,
+            }
+        });
+        settle(vcx);
+        let inspected = vcx.update(|window, _| inspect(window));
+        (out.take(), inspected)
+    }
+
+    fn settle(cx: &mut VisualTestContext) {
+        cx.run_until_parked();
+        cx.update(|window, cx| window.draw(cx).clear(cx));
+        cx.run_until_parked();
+    }
+
+    fn plain(text: &str) -> Paragraph {
+        let mut paragraph = Paragraph::default();
+        paragraph.push(InlineNode::new(text.to_string()));
+        paragraph
+    }
+
+    fn atom_plugin() -> Arc<MarkdownExtensions> {
+        Arc::new(
+            MarkdownExtensions::default().plugin(TestInlinePlugin::new("atom").render_with(
+                |_, _, _, _| {
+                    Some(InlineElement::new(div().w(px(24.)).h(px(16.))).with_baseline(px(12.)))
+                },
+            )),
+        )
+    }
+
+    fn atom() -> InlineNode {
+        InlineNode::custom(MarkdownNode::new("atom", ()).text("\u{FFFC}"))
+    }
+
+    #[gpui::test]
+    fn keyed_leaves_report_geometry_and_unkeyed_leaves_do_not(cx: &mut TestAppContext) {
+        let blocks = vec![
+            BlockNode::Heading {
+                level: 1,
+                children: plain("Title").with_leaf_key(1),
+                span: None,
+            },
+            BlockNode::Paragraph(plain("first").with_leaf_key(2)),
+            BlockNode::Paragraph(plain("not an editor leaf")),
+            BlockNode::CodeBlock(
+                CodeBlock::new("let x = 1;".into(), Some("rust".into()), None::<Span>)
+                    .with_leaf_key(3),
+            ),
+            BlockNode::Table(Table::new(
+                vec![
+                    vec![plain("a").with_leaf_key(4), plain("b").with_leaf_key(5)],
+                    vec![plain("c").with_leaf_key(6), plain("d").with_leaf_key(7)],
+                ],
+                vec![ColumnAlign::None, ColumnAlign::Right],
+            )),
+            BlockNode::List {
+                children: vec![BlockNode::ListItem {
+                    children: vec![BlockNode::Paragraph(plain("item").with_leaf_key(8))],
+                    spread: false,
+                    checked: None,
+                    span: None,
+                }],
+                ordered: false,
+                start: None,
+                span: None,
+            },
+        ];
+        let geometries = draw(cx, blocks, HashMap::new(), Default::default(), px(300.));
+        let mut keys: Vec<u64> = geometries.keys().copied().collect();
+        keys.sort_unstable();
+        assert_eq!(keys, (1..=8).collect::<Vec<_>>());
+        for key in [1, 2, 3, 8] {
+            assert!(geometries[&key].bounds().size.height > px(0.), "leaf {key}");
+        }
+        assert_eq!(geometries[&1].len(), "Title".len());
+        assert_eq!(geometries[&3].len(), "let x = 1;".len());
+        // Document order is top to bottom.
+        let top = |key: u64| geometries[&key].bounds().top();
+        assert!(top(1) < top(2) && top(2) < top(3) && top(3) < top(4) && top(7) < top(8));
+        // Cells of one row share a line; the second column sits to the right.
+        assert_eq!(top(4), top(5));
+        assert!(geometries[&5].bounds().left() > geometries[&4].bounds().right() - px(1.));
+    }
+
+    #[gpui::test]
+    fn wrapped_leaf_maps_indices_and_positions(cx: &mut TestAppContext) {
+        let text = "the quick brown fox jumps over the lazy dog and keeps running far away";
+        let blocks = vec![BlockNode::Paragraph(plain(text).with_leaf_key(1))];
+        let geometries = draw(cx, blocks, HashMap::new(), Default::default(), px(120.));
+        let leaf = &geometries[&1];
+        assert_eq!(leaf.len(), text.len());
+        assert!(!leaf.is_empty());
+        let line = leaf.line_height();
+        assert!(line > px(0.) && leaf.caret_height() <= line);
+        let mut last_y = px(-1.);
+        let mut lines = std::collections::BTreeSet::new();
+        for index in 0..=text.len() {
+            let caret = leaf.position_for_index(index);
+            assert!(caret.y >= last_y, "carets never move up");
+            last_y = caret.y;
+            lines.insert((caret.y.as_f32() * 100.) as i64);
+            // Hit-testing at a caret lands on that caret (at a soft wrap the
+            // end of a line and the start of the next share a caret index).
+            let hit = leaf.index_for_position(point(caret.x, caret.y + line / 2.));
+            assert_eq!(
+                leaf.position_for_index(hit),
+                caret,
+                "index {index} hit {hit}"
+            );
+        }
+        assert!(lines.len() >= 3, "the text wraps onto several lines");
+        // Far outside the leaf clamps to its ends.
+        assert_eq!(leaf.index_for_position(point(px(-500.), px(-500.))), 0);
+        assert_eq!(
+            leaf.index_for_position(point(px(5000.), px(5000.))),
+            text.len()
+        );
+    }
+
+    #[gpui::test]
+    fn mixed_text_and_atoms_report_boxes(cx: &mut TestAppContext) {
+        let mut paragraph = Paragraph::default();
+        paragraph.push(InlineNode::new("ab"));
+        paragraph.push(atom());
+        paragraph.push(InlineNode::new("cd"));
+        let atom_len = '\u{FFFC}'.len_utf8();
+        let blocks = vec![BlockNode::Paragraph(paragraph.with_leaf_key(1))];
+        let geometries = draw(cx, blocks, HashMap::new(), atom_plugin(), px(300.));
+        let leaf = &geometries[&1];
+        assert_eq!(leaf.len(), 2 + atom_len + 2);
+        // The box is hit-testable, and only the box.
+        let range = 2..2 + atom_len;
+        let before = leaf.position_for_index(2);
+        let after = leaf.position_for_index(2 + atom_len);
+        assert!(after.x > before.x, "the caret moves across the atom");
+        let inside = point(
+            (before.x + after.x) / 2.,
+            before.y + leaf.line_height() / 2.,
+        );
+        assert_eq!(leaf.atom_at(inside), Some(range.clone()));
+        assert_eq!(leaf.atom_at(point(before.x - px(8.), inside.y)), None);
+        // Clicking either half of the atom puts the caret on its near edge.
+        let quarter = |t: f32| point(before.x + (after.x - before.x) * t, inside.y);
+        assert_eq!(leaf.index_for_position(quarter(0.25)), range.start);
+        assert_eq!(leaf.index_for_position(quarter(0.75)), range.end);
+        // Text on both sides keeps its own indices.
+        let end = leaf.position_for_index(leaf.len());
+        assert_eq!(
+            leaf.index_for_position(point(end.x + px(40.), inside.y)),
+            leaf.len()
+        );
+        assert_eq!(leaf.index_for_position(point(px(-40.), inside.y)), 0);
+    }
+
+    #[gpui::test]
+    fn mixed_flow_hits_land_on_grapheme_boundaries(cx: &mut TestAppContext) {
+        // Accented, CJK and emoji text (a skin-tone sequence is one grapheme
+        // of two chars) on both sides of atoms, so the leaf is a mixed flow.
+        let parts = ["éa", "\u{FFFC}", "中文", "\u{FFFC}", "👍🏽x"];
+        let mut paragraph = Paragraph::default();
+        for part in parts {
+            if part == "\u{FFFC}" {
+                paragraph.push(atom());
+            } else {
+                paragraph.push(InlineNode::new(part));
+            }
+        }
+        let text = parts.concat();
+        let blocks = vec![BlockNode::Paragraph(paragraph.with_leaf_key(1))];
+        let geometries = draw(cx, blocks, HashMap::new(), atom_plugin(), px(400.));
+        let leaf = &geometries[&1];
+        assert_eq!(leaf.len(), text.len());
+        let boundaries: Vec<usize> = text
+            .grapheme_indices(true)
+            .map(|(i, _)| i)
+            .chain(std::iter::once(text.len()))
+            .collect();
+
+        // Sweep the whole line: every hit is a grapheme boundary that an
+        // edit can use.
+        let bounds = leaf.bounds();
+        let y = bounds.top() + leaf.line_height() / 2.;
+        let mut x = bounds.left() - px(4.);
+        while x <= bounds.right() + px(4.) {
+            let index = leaf.index_for_position(point(x, y));
+            assert!(
+                boundaries.contains(&index),
+                "hit at {x:?} returned {index}, inside a grapheme of {text:?}"
+            );
+            let mut edited = text.clone();
+            edited.insert(index, '|');
+            x += px(0.5);
+        }
+
+        // The caret x of each boundary hits that boundary, never a byte
+        // inside the character before it (`é`: offsets 1 and 2 share an x).
+        for &index in &boundaries {
+            let at = leaf.position_for_index(index);
+            assert_eq!(
+                leaf.index_for_position(point(at.x, y)),
+                index,
+                "caret position of {index} in {text:?}"
+            );
+        }
+    }
+
+    #[gpui::test]
+    fn mixed_flow_keeps_empty_lines_and_trailing_breaks(cx: &mut TestAppContext) {
+        let mut paragraph = Paragraph::default();
+        paragraph.push(atom());
+        paragraph.push(InlineNode::new("a\n\n"));
+        let mut breaks = Paragraph::default();
+        breaks.push(atom());
+        breaks.push(InlineNode::new("\n\n"));
+        let leaves = draw(
+            cx,
+            vec![
+                BlockNode::Paragraph(paragraph.with_leaf_key(1)),
+                BlockNode::Paragraph(breaks.with_leaf_key(2)),
+            ],
+            HashMap::new(),
+            atom_plugin(),
+            px(300.),
+        );
+        let leaf = &leaves[&1];
+        assert_eq!(leaf.len(), 6);
+        let a = leaf.position_for_index(3);
+        let empty = leaf.position_for_index(5);
+        let end = leaf.position_for_index(6);
+        assert!(a.y < empty.y && empty.y < end.y);
+        for index in [5, 6] {
+            let caret = leaf.position_for_index(index);
+            assert_eq!(
+                leaf.index_for_position(point(
+                    caret.x + px(20.),
+                    caret.y + leaf.line_height() / 2.
+                )),
+                index
+            );
+        }
+        assert!(leaf.bounds().bottom() >= end.y + leaf.line_height());
+        let breaks = &leaves[&2];
+        assert_eq!(breaks.len(), 5);
+        assert!(breaks.line_height() < breaks.bounds().size.height);
+        assert!(breaks.position_for_index(5).y > breaks.position_for_index(4).y);
+    }
+
+    #[gpui::test]
+    fn atom_decorations_are_painted_for_custom_nodes_and_images(cx: &mut TestAppContext) {
+        let color: Hsla = rgba(0x12345680).into();
+        let mut custom = Paragraph::default();
+        custom.push(atom());
+        let mut image = Paragraph::default();
+        image.push(InlineNode::image(ImageNode {
+            url: "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20'/>".into(),
+            width: Some(px(20.).into()), height: Some(px(20.).into()),
+            ..Default::default()
+        }));
+        let blocks = vec![
+            BlockNode::Paragraph(custom.with_leaf_key(1)),
+            BlockNode::Paragraph(image.with_leaf_key(2)),
+        ];
+        let decorations = HashMap::from([
+            (1, vec![(0..3, color), (1..1, color)]),
+            (2, vec![(0..3, color), (3..6, color)]),
+        ]);
+        let (_, painted) =
+            draw_and_inspect(cx, blocks, decorations, atom_plugin(), px(300.), |window| {
+                let background: gpui::Background = color.into();
+                window
+                    .painted_quads()
+                    .into_iter()
+                    .filter(|quad| quad.background == background)
+                    .count()
+            });
+        assert_eq!(
+            painted, 2,
+            "one overlay per atom; empty and adjacent ranges paint nothing"
+        );
+    }
+
+    #[gpui::test]
+    fn rekeying_cloned_leaves_preserves_each_identity(cx: &mut TestAppContext) {
+        let original = plain("shared source").with_leaf_key(1);
+        let copy = original.clone().with_leaf_key(2);
+        let code = CodeBlock::new("let x = 1;".into(), None, None::<Span>).with_leaf_key(3);
+        let copy_code = code.clone().with_leaf_key(4);
+        let leaves = draw(
+            cx,
+            vec![
+                BlockNode::Paragraph(original),
+                BlockNode::Paragraph(copy),
+                BlockNode::CodeBlock(code),
+                BlockNode::CodeBlock(copy_code),
+            ],
+            HashMap::new(),
+            Default::default(),
+            px(300.),
+        );
+        assert_eq!(leaves.len(), 4);
+        assert_eq!(leaves[&1].len(), "shared source".len());
+        assert_eq!(leaves[&3].len(), "let x = 1;".len());
+        assert!(leaves[&1].bounds().top() < leaves[&2].bounds().top());
+        assert!(leaves[&3].bounds().top() < leaves[&4].bounds().top());
+    }
+
+    #[gpui::test]
+    fn geometry_is_an_immutable_snapshot(cx: &mut TestAppContext) {
+        cx.update(crate::init);
+        let out = Geometries::default();
+        let (host, vcx) = cx.add_window_view({
+            let out = out.clone();
+            move |_, _| Host {
+                blocks: vec![BlockNode::Paragraph(
+                    plain("a long sentence that wraps when the viewport gets narrower")
+                        .with_leaf_key(1),
+                )],
+                extensions: Default::default(),
+                decorations: HashMap::new(),
+                out,
+                width: px(600.),
+                sink: None,
+            }
+        });
+        settle(vcx);
+        let old = out.borrow()[&1].clone();
+        let before = old.bounds();
+        vcx.update(|_, cx| {
+            host.update(cx, |host, cx| {
+                host.width = px(80.);
+                cx.notify();
+            })
+        });
+        settle(vcx);
+        assert!(out.borrow()[&1].bounds().size.height > before.size.height);
+        assert_eq!(old.bounds(), before);
+        assert_eq!(old.position_for_index(0), before.origin);
+    }
+
+    #[gpui::test]
+    fn empty_keyed_leaf_has_a_line_box_and_empty_unkeyed_leaf_stays_empty(cx: &mut TestAppContext) {
+        let blocks = vec![
+            BlockNode::Paragraph(Paragraph::default()),
+            BlockNode::Paragraph(Paragraph::default().with_leaf_key(9)),
+        ];
+        let geometries = draw(cx, blocks, HashMap::new(), Default::default(), px(300.));
+        assert_eq!(geometries.len(), 1);
+        let leaf = &geometries[&9];
+        assert!(leaf.is_empty());
+        assert!(leaf.bounds().size.height > px(0.));
+        assert_eq!(leaf.position_for_index(0).x, leaf.bounds().left());
+        assert_eq!(
+            leaf.index_for_position(point(px(50.), leaf.bounds().top())),
+            0
+        );
+    }
+
+    #[gpui::test]
+    fn image_only_leaf_reports_its_box(cx: &mut TestAppContext) {
+        let image = ImageNode {
+            url: "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20'/>".into(),
+            width: Some(px(20.).into()),
+            height: Some(px(20.).into()),
+            ..Default::default()
+        };
+        let mut paragraph = Paragraph::default();
+        paragraph.push(InlineNode::image(image));
+        let blocks = vec![BlockNode::Paragraph(paragraph.with_leaf_key(1))];
+        let geometries = draw(cx, blocks, HashMap::new(), Default::default(), px(300.));
+        let leaf = &geometries[&1];
+        let atom_len = '\u{FFFC}'.len_utf8();
+        assert_eq!(leaf.len(), atom_len);
+        let inside = leaf.bounds().center();
+        assert_eq!(leaf.atom_at(inside), Some(0..atom_len));
+    }
+
+    /// Records which leaves ask for backgrounds, to prove decorations are
+    /// requested for keyed leaves only.
+    #[derive(Default)]
+    struct Recorder {
+        asked: RefCell<Vec<u64>>,
+    }
+
+    impl EditorSink for Recorder {
+        fn report(&self, _: u64, _: &TextLayout) {}
+        fn begin_pieces(&self, _: u64) {}
+        fn report_text_piece(&self, _: u64, _: usize, _: &TextLayout) {}
+        fn report_box_piece(&self, _: u64, _: Range<usize>, _: Bounds<Pixels>) {}
+        fn report_empty_piece(&self, _: u64, _: usize, _: Bounds<Pixels>) {}
+        fn backgrounds(&self, key: u64) -> Vec<(Range<usize>, Hsla)> {
+            self.asked.borrow_mut().push(key);
+            vec![(0..1, rgb(0xff0000).into())]
+        }
+    }
+
+    #[gpui::test]
+    fn decorations_are_requested_per_keyed_leaf(cx: &mut TestAppContext) {
+        cx.update(crate::init);
+        let recorder = Rc::new(Recorder::default());
+        let mut mixed = Paragraph::default();
+        mixed.push(InlineNode::new("ab"));
+        mixed.push(atom());
+        let blocks = vec![
+            BlockNode::Paragraph(plain("one").with_leaf_key(1)),
+            BlockNode::Paragraph(plain("unkeyed")),
+            BlockNode::Paragraph(mixed.with_leaf_key(2)),
+        ];
+        let (_, vcx) = cx.add_window_view({
+            let sink: Rc<dyn EditorSink> = recorder.clone();
+            move |_, _| Host {
+                blocks,
+                extensions: atom_plugin(),
+                decorations: HashMap::new(),
+                out: Geometries::default(),
+                width: px(300.),
+                sink: Some(sink),
+            }
+        });
+        settle(vcx);
+        let asked = recorder.asked.borrow();
+        assert!(asked.contains(&1) && asked.contains(&2), "{asked:?}");
+        assert!(asked.iter().all(|key| *key == 1 || *key == 2), "{asked:?}");
+    }
+
+    #[gpui::test]
+    fn decorated_leaves_still_report_geometry(cx: &mut TestAppContext) {
+        let blocks = vec![BlockNode::Paragraph(plain("hello world").with_leaf_key(1))];
+        let decorations = HashMap::from([(
+            1,
+            vec![
+                (0..5, rgba(0x3366ff80).into()),
+                (6..11, rgb(0xffcc00).into()),
+            ],
+        )]);
+        let plain_geometry = draw(
+            cx,
+            blocks.clone(),
+            HashMap::new(),
+            Default::default(),
+            px(300.),
+        );
+        let decorated = draw(cx, blocks, decorations, Default::default(), px(300.));
+        assert_eq!(
+            decorated[&1].bounds().size,
+            plain_geometry[&1].bounds().size,
+            "decorations paint behind the text and never change layout"
+        );
+    }
+
+    #[test]
+    fn collect_leaf_geometry_nests_and_restores_on_panic() {
+        let seen = Cell::new(0);
+        let (inner_ran, geometries) = collect_leaf_geometry(HashMap::new(), || {
+            assert!(editor_seam::current().is_some());
+            let outer = editor_seam::current().unwrap();
+            collect_leaf_geometry(HashMap::new(), || seen.set(seen.get() + 1));
+            let after = editor_seam::current().unwrap();
+            assert!(Rc::ptr_eq(&outer, &after), "the outer scope is restored");
+            true
+        });
+        assert!(inner_ran && geometries.is_empty() && seen.get() == 1);
+        assert!(editor_seam::current().is_none());
+
+        let result = std::panic::catch_unwind(|| {
+            collect_leaf_geometry(HashMap::new(), || panic!("boom"));
+        });
+        assert!(result.is_err());
+        assert!(editor_seam::current().is_none(), "cleared after a panic");
+    }
+
+    #[test]
+    fn parse_markdown_reads_what_an_editor_round_trips() {
+        let source = "```rust title=\"x\"\nfn main() {}\n```\n\n\
+                      | a | b | c | d |\n|---|:--|:-:|--:|\n| 1 | 2 | 3 | 4 |\n\n\
+                      [ref]: https://example.com \"Title\"\n";
+        let blocks = parse_markdown(source, Default::default()).unwrap();
+        let code = blocks
+            .iter()
+            .find_map(|block| match block {
+                BlockNode::CodeBlock(code) => Some(code),
+                _ => None,
+            })
+            .expect("code block");
+        assert_eq!(code.lang().as_deref(), Some("rust"));
+        assert_eq!(code.meta().as_deref(), Some("title=\"x\""));
+        let table = blocks
+            .iter()
+            .find_map(|block| match block {
+                BlockNode::Table(table) => Some(table),
+                _ => None,
+            })
+            .expect("table");
+        assert_eq!(
+            table.column_aligns(),
+            [
+                ColumnAlign::None,
+                ColumnAlign::Left,
+                ColumnAlign::Center,
+                ColumnAlign::Right
+            ]
+        );
+        let cells: Vec<Vec<String>> = table
+            .rows()
+            .map(|row| {
+                row.map(|cell| cell.children().iter().map(|c| c.text()).collect())
+                    .collect()
+            })
+            .collect();
+        assert_eq!(cells, [["a", "b", "c", "d"], ["1", "2", "3", "4"]]);
+        assert!(blocks.iter().any(|block| matches!(
+            block,
+            BlockNode::Definition { identifier, .. } if identifier.as_ref() == "ref"
+        )));
+        assert!(
+            blocks
+                .iter()
+                .all(|block| block.span().is_some() || matches!(block, BlockNode::Unknown))
+        );
+    }
+
+    #[test]
+    fn parse_markdown_matches_text_view_parse() {
+        let source = "# Title\n\nSome **bold** and `code` and ==mark==.\n\n- one\n- two\n";
+        let mut cx = NodeContext::default();
+        let expected = markdown::parse(source, &mut cx).unwrap();
+        let blocks = parse_markdown(source, Default::default()).unwrap();
+        assert_eq!(blocks, *expected.blocks);
+    }
+
+    #[test]
+    fn table_and_paragraph_builders_read_back() {
+        let mut paragraph = Paragraph::default();
+        paragraph.push(InlineNode::new("hi").marks(vec![(0..2, TextMark::default().bold())]));
+        assert_eq!(paragraph.children()[0].text(), "hi");
+        assert!(paragraph.children()[0].mark_runs()[0].1.bold);
+        assert!(paragraph.children()[0].as_image().is_none());
+        assert!(paragraph.children()[0].as_custom().is_none());
+        let table = Table::new(vec![vec![paragraph]], vec![ColumnAlign::Center]);
+        assert_eq!(table.rows().count(), 1);
+        assert_eq!(table.column_aligns(), [ColumnAlign::Center]);
+    }
+}
diff --git a/third-party/gpui-kit/crates/base/src/text/editor_seam.rs b/third-party/gpui-kit/crates/base/src/text/editor_seam.rs
new file mode 100644
index 0000000000..869432f04b
--- /dev/null
+++ b/third-party/gpui-kit/crates/base/src/text/editor_seam.rs
@@ -0,0 +1,86 @@
+//! Crate-private plumbing behind [`editor_host`](super::editor_host).
+//!
+//! A text leaf whose [`InlineState`](super::inline::InlineState) carries an
+//! `editor_key` reports its laid-out [`TextLayout`] to the sink installed for
+//! the duration of `collect_leaf_geometry`, and takes extra range backgrounds
+//! (the host's selection and decorations) from it. `TextView` never sets a key
+//! or installs a sink, so read-only rendering is unchanged: outside a scope the
+//! only cost is one thread-local read per laid-out text element.
+//!
+//! The sink is frame-local: it is written during prepaint and read by the
+//! host afterwards; nothing here reaches back into an entity.
+
+use std::{cell::RefCell, ops::Range, rc::Rc};
+
+use gpui::{Bounds, Hsla, Pixels, TextLayout};
+
+pub(crate) trait EditorSink {
+    /// The leaf `key` was laid out this frame as one `TextLayout`.
+    fn report(&self, key: u64, layout: &TextLayout);
+    /// The leaf `key` is laid out in pieces this frame (a mixed text /
+    /// image / custom-inline paragraph rendered by `InlineFlow`); drops
+    /// pieces reported for it earlier in the frame.
+    fn begin_pieces(&self, key: u64);
+    /// One single-line text fragment of `key`, whose index 0 is the leaf's
+    /// byte `start`.
+    fn report_text_piece(&self, key: u64, start: usize, layout: &TextLayout);
+    /// An inline atom (image, custom node) of `key` covering the leaf bytes
+    /// `range`, drawn in `bounds`.
+    fn report_box_piece(&self, key: u64, range: Range<usize>, bounds: Bounds<Pixels>);
+    /// A line with no fragment (e.g. after a trailing hard break).
+    fn report_empty_piece(&self, key: u64, start: usize, bounds: Bounds<Pixels>);
+    /// Extra backgrounds to paint behind the leaf `key`, in its text's bytes.
+    fn backgrounds(&self, key: u64) -> Vec<(Range<usize>, Hsla)>;
+}
+
+thread_local! {
+    static SINK: RefCell<Option<Rc<dyn EditorSink>>> = const { RefCell::new(None) };
+}
+
+/// Run `f` with `sink` installed; restores the previous sink afterwards.
+pub(crate) fn with_sink<R>(sink: Rc<dyn EditorSink>, f: impl FnOnce() -> R) -> R {
+    /// Restores the previous sink on drop, so a panic inside `f` cannot leave
+    /// a stale sink installed for later frames.
+    struct Restore(Option<Rc<dyn EditorSink>>);
+    impl Drop for Restore {
+        fn drop(&mut self) {
+            let previous = self.0.take();
+            SINK.with(|s| *s.borrow_mut() = previous);
+        }
+    }
+    let _restore = Restore(SINK.with(|s| s.replace(Some(sink))));
+    f()
+}
+
+pub(crate) fn current() -> Option<Rc<dyn EditorSink>> {
+    SINK.with(|s| s.borrow().clone())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    struct Nop;
+    impl EditorSink for Nop {
+        fn report(&self, _: u64, _: &TextLayout) {}
+        fn begin_pieces(&self, _: u64) {}
+        fn report_text_piece(&self, _: u64, _: usize, _: &TextLayout) {}
+        fn report_box_piece(&self, _: u64, _: Range<usize>, _: Bounds<Pixels>) {}
+        fn report_empty_piece(&self, _: u64, _: usize, _: Bounds<Pixels>) {}
+        fn backgrounds(&self, _: u64) -> Vec<(Range<usize>, Hsla)> {
+            Vec::new()
+        }
+    }
+
+    #[test]
+    fn with_sink_restores_on_panic() {
+        let result = std::panic::catch_unwind(|| {
+            with_sink(Rc::new(Nop), || {
+                assert!(current().is_some());
+                panic!("boom");
+            })
+        });
+        assert!(result.is_err());
+        assert!(current().is_none(), "sink is cleared after a panic");
+    }
+}
diff --git a/third-party/gpui-kit/crates/base/src/text/format/html.rs b/third-party/gpui-kit/crates/base/src/text/format/html.rs
index f0c4066409..c564e79892 100644
--- a/third-party/gpui-kit/crates/base/src/text/format/html.rs
+++ b/third-party/gpui-kit/crates/base/src/text/format/html.rs
@@ -53,6 +53,45 @@ const BLOCK_ELEMENTS: [&str; 35] = [
     "script",
 ];
 
+/// A paragraph under construction. A literal line feed is content; only a
+/// trailing HTML break is removed when the complete text block is taken.
+#[derive(Default)]
+struct HtmlParagraph {
+    content: Paragraph,
+    ends_with_break: bool,
+}
+
+impl HtmlParagraph {
+    fn push(&mut self, node: InlineNode) {
+        self.content.push(node);
+        self.ends_with_break = false;
+    }
+
+    fn push_str(&mut self, text: &str) {
+        if !text.is_empty() {
+            self.content.push_str(text);
+            self.ends_with_break = false;
+        }
+    }
+
+    fn push_image(&mut self, image: ImageNode) {
+        self.push(InlineNode::image(image));
+    }
+
+    fn push_break(&mut self) {
+        self.content.push(InlineNode::new("\n"));
+        self.ends_with_break = true;
+    }
+
+    fn take(&mut self) -> Paragraph {
+        if self.ends_with_break {
+            trim_trailing_break(&mut self.content);
+        }
+        self.ends_with_break = false;
+        self.content.take()
+    }
+}
+
 /// Parse HTML into AST Node.
 pub(crate) fn parse(
     source: &str,
@@ -76,7 +115,7 @@ pub(crate) fn parse(
         fragment_start,
         next_anchor: 0,
     };
-    let mut paragraph = Paragraph::default();
+    let mut paragraph = HtmlParagraph::default();
     // NOTE: The outer paragraph is not used.
     let node: BlockNode =
         parse_node(&dom.document, &mut paragraph, cx, &mut links).unwrap_or(BlockNode::Unknown);
@@ -270,13 +309,13 @@ fn parse_table_cell(
     attrs: &RefCell<Vec<html5ever::Attribute>>,
     links: &mut HtmlLinks,
 ) {
-    let mut paragraph = Paragraph::default();
+    let mut paragraph = HtmlParagraph::default();
     for child in node.children.borrow().iter() {
         parse_paragraph(&mut paragraph, child, links);
     }
     let width = attr_width_height(attrs).0;
     let table_cell = node::TableCell {
-        children: paragraph,
+        children: paragraph.take(),
         width,
     };
     row.children.push(table_cell);
@@ -302,9 +341,9 @@ fn trim_text(text: &str) -> String {
     out
 }
 
-fn parse_paragraph(paragraph: &mut Paragraph, node: &Rc<Node>, links: &mut HtmlLinks) {
+fn parse_paragraph(paragraph: &mut HtmlParagraph, node: &Rc<Node>, links: &mut HtmlLinks) {
     fn push_merged(
-        paragraph: &mut Paragraph,
+        paragraph: &mut HtmlParagraph,
         text: String,
         marks: Vec<(Range<usize>, TextMark)>,
         new_mark: Option<TextMark>,
@@ -329,18 +368,22 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &Rc<Node>, links: &mut HtmlL
 
     fn merge_children_with_mark(
         node: &Node,
-        paragraph: &mut Paragraph,
+        paragraph: &mut HtmlParagraph,
         new_mark: Option<TextMark>,
         links: &mut HtmlLinks,
     ) {
         let mut merged_text = String::new();
         let mut merged_marks = Vec::new();
+        let mut ends_with_break = None;
 
         for child in node.children.borrow().iter() {
-            let mut child_paragraph = Paragraph::default();
+            let mut child_paragraph = HtmlParagraph::default();
             parse_paragraph(&mut child_paragraph, &child, links);
 
-            for node in child_paragraph.children {
+            if !child_paragraph.content.is_empty() {
+                ends_with_break = Some(child_paragraph.ends_with_break);
+            }
+            for node in child_paragraph.content.children {
                 let offset = merged_text.len();
                 merged_text.push_str(&node.text);
                 for (range, child_mark) in node.marks {
@@ -365,6 +408,9 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &Rc<Node>, links: &mut HtmlL
         }
 
         push_merged(paragraph, merged_text, merged_marks, new_mark.clone());
+        if let Some(ends_with_break) = ends_with_break {
+            paragraph.ends_with_break = ends_with_break;
+        }
     }
 
     match &node.data {
@@ -429,6 +475,9 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &Rc<Node>, links: &mut HtmlL
                     links,
                 );
             }
+            local_name!("br") => {
+                paragraph.push_break();
+            }
             local_name!("img") => {
                 let Some(src) = attr_value(attrs, local_name!("src")) else {
                     if cfg!(debug_assertions) {
@@ -462,7 +511,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &Rc<Node>, links: &mut HtmlL
 
 fn parse_node(
     node: &Rc<Node>,
-    paragraph: &mut Paragraph,
+    paragraph: &mut HtmlParagraph,
     cx: &mut NodeContext,
     links: &mut HtmlLinks,
 ) -> Option<BlockNode> {
@@ -480,10 +529,13 @@ fn parse_node(
             ref attrs,
             ..
         } => match name.local {
-            local_name!("br") => Some(BlockNode::Break {
-                html: true,
-                span: None,
-            }),
+            // A line break inside the paragraph being built, as a markdown
+            // hard break becomes. As a block of its own it landed apart from
+            // the text on both sides, which then stayed on one line.
+            local_name!("br") => {
+                paragraph.push_break();
+                None
+            }
             local_name!("h1")
             | local_name!("h2")
             | local_name!("h3")
@@ -501,14 +553,14 @@ fn parse_node(
                     .to_digit(10)
                     .unwrap_or(6) as u8;
 
-                let mut paragraph = Paragraph::default();
+                let mut paragraph = HtmlParagraph::default();
                 for child in node.children.borrow().iter() {
                     parse_paragraph(&mut paragraph, child, links);
                 }
 
                 let heading = BlockNode::Heading {
                     level,
-                    children: paragraph,
+                    children: paragraph.take(),
                     span: None,
                 };
                 if children.len() > 0 {
@@ -537,7 +589,7 @@ fn parse_node(
                 let title = attr_value(&attrs, local_name!("title"));
                 let (width, height) = attr_width_height(&attrs);
 
-                let mut paragraph = Paragraph::default();
+                let mut paragraph = HtmlParagraph::default();
                 paragraph.push_image(ImageNode {
                     url: src.into(),
                     title: title.map(Into::into),
@@ -548,13 +600,13 @@ fn parse_node(
                 });
 
                 if children.len() > 0 {
-                    children.push(BlockNode::Paragraph(paragraph));
+                    children.push(BlockNode::Paragraph(paragraph.take()));
                     Some(BlockNode::Root {
                         children,
                         span: None,
                     })
                 } else {
-                    Some(BlockNode::Paragraph(paragraph))
+                    Some(BlockNode::Paragraph(paragraph.take()))
                 }
             }
             local_name!("ul") | local_name!("ol") => {
@@ -571,24 +623,30 @@ fn parse_node(
                 let mut children = vec![];
                 consume_paragraph(&mut children, paragraph);
 
+                let mut ends_with_break = false;
                 for child in node.children.borrow().iter() {
-                    let mut child_paragraph = Paragraph::default();
+                    let mut child_paragraph = HtmlParagraph::default();
                     if let Some(child_node) = parse_node(child, &mut child_paragraph, cx, links) {
                         children.push(child_node);
+                        ends_with_break = false;
                     }
-                    if child_paragraph.text_len() > 0 {
+                    if child_paragraph.content.text_len() > 0 {
+                        ends_with_break = child_paragraph.ends_with_break;
                         // If last child is paragraph, merge child
                         if let Some(last_child) = children.last_mut() {
                             if let BlockNode::Paragraph(last_paragraph) = last_child {
-                                last_paragraph.merge(child_paragraph);
+                                last_paragraph.merge(child_paragraph.content);
                                 continue;
                             }
                         }
 
-                        children.push(BlockNode::Paragraph(child_paragraph));
+                        children.push(BlockNode::Paragraph(child_paragraph.content));
                     }
                 }
 
+                if ends_with_break && let Some(BlockNode::Paragraph(last)) = children.last_mut() {
+                    trim_trailing_break(last);
+                }
                 consume_paragraph(&mut children, paragraph);
 
                 Some(BlockNode::ListItem {
@@ -670,7 +728,7 @@ fn parse_node(
                     // Others to as Inline
                     parse_paragraph(paragraph, node, links);
 
-                    if paragraph.is_image() {
+                    if paragraph.content.is_image() {
                         Some(BlockNode::Paragraph(paragraph.take()))
                     } else {
                         None
@@ -693,7 +751,7 @@ fn parse_node(
 
 fn consume_children_nodes(
     node: &Node,
-    paragraph: &mut Paragraph,
+    paragraph: &mut HtmlParagraph,
     cx: &mut NodeContext,
     links: &mut HtmlLinks,
 ) -> Vec<BlockNode> {
@@ -709,14 +767,41 @@ fn consume_children_nodes(
     children
 }
 
-fn consume_paragraph(children: &mut Vec<BlockNode>, paragraph: &mut Paragraph) {
-    if paragraph.is_empty() {
+fn consume_paragraph(children: &mut Vec<BlockNode>, paragraph: &mut HtmlParagraph) {
+    if paragraph.content.is_empty() {
         return;
     }
 
     children.push(BlockNode::Paragraph(paragraph.take()));
 }
 
+/// Drops one trailing HTML break after the parser has established its
+/// provenance. The break may be a separate node or part of a marked run.
+/// A standalone break is retained for Markdown's inline-HTML parser.
+fn trim_trailing_break(paragraph: &mut Paragraph) {
+    if let [only] = paragraph.children.as_slice()
+        && only.text == "\n"
+    {
+        return;
+    }
+    let Some(last) = paragraph.children.last_mut() else {
+        return;
+    };
+    let Some(text) = last.text.strip_suffix('\n').map(str::to_string) else {
+        return;
+    };
+    if text.is_empty() {
+        paragraph.children.pop();
+        return;
+    }
+    let len = text.len();
+    last.text = text.into();
+    last.marks.retain_mut(|(range, _)| {
+        range.end = range.end.min(len);
+        range.start < range.end
+    });
+}
+
 #[cfg(test)]
 mod tests {
     use gpui::{px, relative};
@@ -779,6 +864,35 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_pre_keeps_whitespace_in_nested_elements() {
+        let mut cx = NodeContext::default();
+        for (html, text) in [
+            // Code in `<pre><code>` keeps its line feeds and indent.
+            (
+                "<pre><code>fn main() {\n    one();\n}</code></pre>",
+                "fn main() {\n    one();\n}",
+            ),
+            // So does text deeper down, and the space between elements.
+            (
+                "<pre><code><b>one</b>  <i>two\n\tthree</i></code></pre>",
+                "one  two\n\tthree",
+            ),
+            // The parser keeps a line feed after `<code>`, unlike `<pre>`.
+            ("<pre><code>\none</code></pre>", "\none"),
+            // Escaped markup is still text.
+            ("<pre><code>&lt;b&gt; &amp;amp;</code></pre>", "<b> &amp;"),
+            // Outside `<pre>`, whitespace is collapsed as before.
+            ("<p><code>one\n   two</code></p>", "one two"),
+        ] {
+            let document = super::parse(html, 0, &mut cx).unwrap();
+            let BlockNode::Paragraph(paragraph) = &document.blocks[0] else {
+                panic!("expected one paragraph, got {:?}", document.blocks);
+            };
+            assert_eq!(paragraph.text(), text, "{html}");
+        }
+    }
+
     #[test]
     fn test_trim_text() {
         assert_eq!(trim_text("  \n\tHello world \t\r "), " Hello world ",);
@@ -837,6 +951,137 @@ mod tests {
         );
     }
 
+    /// The text of the one paragraph `html` parses to.
+    fn paragraph_text(html: &str) -> String {
+        let mut cx = NodeContext::default();
+        let document = super::parse(html, 0, &mut cx).unwrap();
+        let BlockNode::Paragraph(paragraph) = &document.blocks[0] else {
+            panic!("expected one paragraph, got {:?}", document.blocks);
+        };
+        paragraph.text()
+    }
+
+    #[test]
+    fn test_br_breaks_the_line_where_it_stands() {
+        assert_eq!(paragraph_text("<p>one<br>two</p>"), "one\ntwo");
+        assert_eq!(
+            paragraph_text("one<br>two<br><br>three"),
+            "one\ntwo\n\nthree"
+        );
+        assert_eq!(paragraph_text("<b>one<br>two</b>"), "one\ntwo");
+        // The minifier trims the spaces around it.
+        assert_eq!(paragraph_text("<p>one <br> two</p>"), "one\ntwo");
+        // As in a browser, a break that ends a block adds no line.
+        assert_eq!(paragraph_text("<p>one<br></p>"), "one");
+        assert_eq!(paragraph_text("<p>one<br><br></p>"), "one\n");
+        // Markdown parses inline HTML one tag at a time and needs the break.
+        assert_eq!(paragraph_text("<br>"), "\n");
+    }
+
+    #[test]
+    fn test_br_that_ends_inline_markup() {
+        // Inside inline markup the break joins the text before it in one
+        // run; at the end of the block it still adds no line.
+        for tag in ["b", "span"] {
+            assert_eq!(
+                paragraph_text(&format!("<p><{tag}>one<br></{tag}></p>")),
+                "one",
+                "{tag}"
+            );
+            assert_eq!(
+                paragraph_text(&format!("<p><{tag}>one<br><br></{tag}></p>")),
+                "one\n",
+                "{tag}"
+            );
+            assert_eq!(
+                paragraph_text(&format!("<p><{tag}>one<br>two</{tag}></p>")),
+                "one\ntwo",
+                "{tag}"
+            );
+        }
+        assert_eq!(paragraph_text("<p>one<b><br></b></p>"), "one");
+        assert_eq!(paragraph_text("<p><b>one<br></b><br></p>"), "one\n");
+
+        // The marks end with the text they kept.
+        let mut cx = NodeContext::default();
+        let document = super::parse("<p><b>one<br></b></p>", 0, &mut cx).unwrap();
+        let BlockNode::Paragraph(paragraph) = &document.blocks[0] else {
+            panic!("expected one paragraph, got {:?}", document.blocks);
+        };
+        let [node] = paragraph.children.as_slice() else {
+            panic!("expected one node, got {:?}", paragraph.children);
+        };
+        assert!(
+            node.marks.iter().all(|(range, _)| *range == (0..3)),
+            "{:?}",
+            node.marks
+        );
+        assert!(node.marks.iter().any(|(_, mark)| mark.bold));
+    }
+
+    #[test]
+    fn test_literal_trailing_line_feeds_are_preserved() {
+        for (html, expected) in [
+            ("<pre>one\n</pre>", "one\n"),
+            ("<pre>one\n\n</pre>", "one\n\n"),
+            ("<pre>one\n<br></pre>", "one\n"),
+            ("<pre>one<br>\n</pre>", "one\n\n"),
+            ("<textarea>one\n</textarea>", "one\n"),
+        ] {
+            assert_eq!(paragraph_text(html), expected, "{html}");
+        }
+    }
+
+    #[test]
+    fn test_trailing_breaks_at_text_block_boundaries() {
+        for tag in ["p", "h1", "h6", "td", "th", "li"] {
+            for (content, expected) in [
+                ("one<br>", "one"),
+                ("one<br><br>", "one\n"),
+                ("<b>one<br></b>", "one"),
+                ("<span>one<br><br></span>", "one\n"),
+                ("one<br>two", "one\ntwo"),
+                ("one<br><span></span>", "one"),
+            ] {
+                let html = match tag {
+                    "td" | "th" => format!("<table><tr><{tag}>{content}</{tag}></tr></table>"),
+                    "li" => format!("<ul><li>{content}</li></ul>"),
+                    _ => format!("<{tag}>{content}</{tag}>"),
+                };
+                let mut cx = NodeContext::default();
+                let document = super::parse(&html, 0, &mut cx).unwrap();
+                let paragraph = match &document.blocks[0] {
+                    BlockNode::Paragraph(paragraph)
+                    | BlockNode::Heading {
+                        children: paragraph,
+                        ..
+                    } => paragraph,
+                    BlockNode::Table(table) => &table.children[0].children[0].children,
+                    BlockNode::List { children, .. } => {
+                        let BlockNode::ListItem { children, .. } = &children[0] else {
+                            panic!("expected list item: {html}");
+                        };
+                        let [BlockNode::Paragraph(paragraph)] = children.as_slice() else {
+                            panic!("expected one paragraph: {html}");
+                        };
+                        paragraph
+                    }
+                    _ => panic!("expected text block: {html}"),
+                };
+                assert_eq!(paragraph.text(), expected, "{html}");
+                for node in &paragraph.children {
+                    assert!(
+                        node.marks
+                            .iter()
+                            .all(|(range, _)| range.start < range.end
+                                && range.end <= node.text.len()),
+                        "mark outside text: {html}"
+                    );
+                }
+            }
+        }
+    }
+
     #[test]
     fn test_value_to_length() {
         assert_eq!(super::value_to_length("100px"), Some(px(100.).into()));
diff --git a/third-party/gpui-kit/crates/base/src/text/format/html5minify/mod.rs b/third-party/gpui-kit/crates/base/src/text/format/html5minify/mod.rs
index c3d02de3bc..28d10194c8 100644
--- a/third-party/gpui-kit/crates/base/src/text/format/html5minify/mod.rs
+++ b/third-party/gpui-kit/crates/base/src/text/format/html5minify/mod.rs
@@ -116,6 +116,15 @@ impl<'a> Context<'a> {
             None
         }
     }
+
+    /// Whether the parent keeps its whitespace, or an element above it does:
+    /// text in `<pre><code>` keeps its whitespace as well.
+    fn preserves_whitespace(&self) -> bool {
+        matches!(&self.parent.data, NodeData::Element { name, .. } if preserve_whitespace(name.local.as_ref()))
+            || self
+                .parent_context
+                .is_some_and(Context::preserves_whitespace)
+    }
 }
 
 impl<'a, W> Minifier<'a, W>
@@ -192,7 +201,7 @@ where
                 }
 
                 // Check if parent is whitespace preserving element or contains code (<script>, <style>)
-                let (skip_collapse_whitespace, contains_code) =
+                let (parent_preserves_whitespace, contains_code) =
                     ctx.as_ref().map_or((false, false), |ctx| {
                         if let NodeData::Element { name, .. } = &ctx.parent.data {
                             let name = name.local.as_ref();
@@ -202,20 +211,25 @@ where
                             (false, false)
                         }
                     });
+                let skip_collapse_whitespace =
+                    ctx.as_ref().is_some_and(Context::preserves_whitespace);
 
                 if skip_collapse_whitespace {
                     // The parser drops a line feed right after `<pre>` or
                     // `<textarea>`, so one that starts the text needs another
                     // in front of it to survive the next parse. Comments
-                    // before it are left out, so they do not count.
-                    let first = ctx.as_ref().is_some_and(|ctx| {
-                        ctx.left.is_none_or(|left| {
-                            !self.preserve_comments
-                                && left
-                                    .iter()
-                                    .all(|node| matches!(node.data, NodeData::Comment { .. }))
-                        })
-                    });
+                    // before it are left out, so they do not count. A line
+                    // feed after a nested start tag, as in `<pre><code>`, is
+                    // kept by the parser and needs nothing.
+                    let first = parent_preserves_whitespace
+                        && ctx.as_ref().is_some_and(|ctx| {
+                            ctx.left.is_none_or(|left| {
+                                !self.preserve_comments
+                                    && left
+                                        .iter()
+                                        .all(|node| matches!(node.data, NodeData::Comment { .. }))
+                            })
+                        });
                     if first && contents.starts_with('\n') {
                         self.w.write_all(b"\n")?;
                     }
diff --git a/third-party/gpui-kit/crates/base/src/text/format/markdown.rs b/third-party/gpui-kit/crates/base/src/text/format/markdown.rs
index 1c757a9858..f62940c452 100644
--- a/third-party/gpui-kit/crates/base/src/text/format/markdown.rs
+++ b/third-party/gpui-kit/crates/base/src/text/format/markdown.rs
@@ -1060,6 +1060,7 @@ fn ast_to_node(source: &str, value: mdast::Node, cx: &mut NodeContext) -> BlockN
             let segments = code_source_segments(source, &raw.value, span, cx.offset);
             BlockNode::CodeBlock(
                 CodeBlock::new(raw.value.into(), raw.lang.map(Into::into), span)
+                    .with_meta(raw.meta.map(Into::into))
                     .source_segments(segments),
             )
         }
diff --git a/third-party/gpui-kit/crates/base/src/text/inline.rs b/third-party/gpui-kit/crates/base/src/text/inline.rs
index d8367fd9d3..232584ef6a 100644
--- a/third-party/gpui-kit/crates/base/src/text/inline.rs
+++ b/third-party/gpui-kit/crates/base/src/text/inline.rs
@@ -215,7 +215,7 @@ pub(super) struct Inline {
     range_backgrounds: Vec<(Range<usize>, Hsla)>,
     /// The start of a pending reveal, when it is in this text.
     reveal: Option<RevealAt>,
-    text_view: Option<gpui::WeakEntity<super::TextViewState>>,
+    link_source: super::link::LinkSource,
     link_fragments: Option<super::link::Fragments>,
     element_id: Option<ElementId>,
     /// What this frame's layout was shaped with, to hand the shaped text to
@@ -224,6 +224,9 @@ pub(super) struct Inline {
     /// The shaped text is in the table, not in `styled_text`, until paint
     /// takes it back.
     handed_over: bool,
+    /// A fragment of an editor leaf laid out by `InlineFlow`: the leaf's key
+    /// and the leaf byte offset of this text's index 0.
+    editor_piece: Option<(u64, usize)>,
 
     state: Arc<Mutex<InlineState>>,
 }
@@ -234,6 +237,10 @@ pub(crate) struct InlineState {
     /// The text that actually rendering, matched with selection.
     pub(super) text: SharedString,
     pub(super) selection: Option<Selection>,
+    /// Set only through the editor host API (`Paragraph::with_leaf_key`):
+    /// the leaf reports its geometry to the collector installed by
+    /// `editor_host::collect_leaf_geometry`. `None` for every plain `TextView`.
+    pub(crate) editor_key: Option<u64>,
 }
 
 /// One frame's [`StyledText`], kept for the next frame's [`Inline`] of the
@@ -311,7 +318,124 @@ impl InlineState {
     }
 }
 
+/// The native lines and placement of a completed paint. `TextLayout::clone`
+/// shares mutable measurement state; these immutable shaped lines do not.
+struct PaintedSelectionLayout {
+    lines: smallvec::SmallVec<[Arc<gpui::WrappedLineLayout>; 1]>,
+    bounds: Bounds<Pixels>,
+    line_height: Pixels,
+    len: usize,
+}
+
+impl PaintedSelectionLayout {
+    fn new(layout: &TextLayout) -> Self {
+        Self {
+            lines: layout.line_layouts(),
+            bounds: layout.bounds(),
+            line_height: layout.line_height(),
+            len: layout.len(),
+        }
+    }
+
+    fn position_for_index(&self, index: usize) -> Option<Point<Pixels>> {
+        let mut origin = self.bounds.origin;
+        let mut start = 0;
+        for line in &self.lines {
+            let end = start + line.len();
+            if index < start {
+                break;
+            }
+            if index <= end {
+                return Some(origin + line.position_for_index(index - start, self.line_height)?);
+            }
+            origin.y += line.size(self.line_height).height;
+            start = end + 1;
+        }
+        None
+    }
+
+    fn rows_extent(&self) -> (Pixels, Pixels) {
+        let top = self.bounds.top();
+        let mut last_line_top = top;
+        for line in self.lines.iter().take(self.lines.len().saturating_sub(1)) {
+            last_line_top += line.size(self.line_height).height;
+        }
+        let last_row_top = self.lines.last().map_or(top, |line| {
+            last_line_top + line.wrap_boundaries.len() as f32 * self.line_height
+        });
+        (top, last_row_top + self.line_height)
+    }
+}
+
+/// Targets the document-owned source state, never an ephemeral flow fragment.
+pub(super) struct InlineSelectionProjection {
+    source: Weak<Mutex<InlineState>>,
+    range: Range<usize>,
+    text: SharedString,
+    layout: PaintedSelectionLayout,
+    selection_bounds: Option<Bounds<Pixels>>,
+}
+
+impl InlineSelectionProjection {
+    pub(super) fn reset(&self) {
+        if let Some(source) = self.source.upgrade()
+            && let Ok(mut source) = source.lock()
+        {
+            source.selection = None;
+        }
+    }
+
+    pub(super) fn project(&self, start: Point<Pixels>, end: Point<Pixels>) {
+        let Some(source) = self.source.upgrade() else {
+            return;
+        };
+        let selection =
+            project_selection(&self.text, &self.layout, self.selection_bounds, start, end);
+        if let Some(selection) = selection
+            && let Ok(mut source) = source.lock()
+        {
+            let start = self.range.start + selection.start;
+            let end = self.range.start + selection.end;
+            source.selection = Some(match source.selection {
+                Some(previous) => Selection::new(previous.start.min(start), previous.end.max(end)),
+                None => Selection::new(start, end),
+            });
+        }
+    }
+}
+
 impl Inline {
+    /// Editor seam (design D3): report this leaf's layout and take the
+    /// editor's backgrounds. Inert unless the state carries an editor key
+    /// and an editor sink is installed.
+    fn report_to_editor(&mut self) {
+        // The sink (a thread-local read) is checked first: outside an
+        // editor's prepaint no lock is taken.
+        let Some(sink) = super::editor_seam::current() else {
+            return;
+        };
+        if let Some((key, start)) = self.editor_piece {
+            let layout = self.styled_text.layout();
+            let end = start + layout.len();
+            sink.report_text_piece(key, start, layout);
+            self.range_backgrounds
+                .extend(
+                    sink.backgrounds(key)
+                        .into_iter()
+                        .filter_map(|(range, color)| {
+                            let (lo, hi) = (range.start.max(start), range.end.min(end));
+                            (lo < hi).then(|| (lo - start..hi - start, color))
+                        }),
+                );
+            return;
+        }
+        let Some(key) = self.state.lock().ok().and_then(|state| state.editor_key) else {
+            return;
+        };
+        sink.report(key, self.styled_text.layout());
+        self.range_backgrounds.extend(sink.backgrounds(key));
+    }
+
     /// Hands the shaped text to the next frame (see [`RetainedLayout`]).
     /// Called after prepaint, so an element that is laid out but never
     /// painted (scrolled out of view) keeps its layout too; paint takes it
@@ -377,11 +501,12 @@ impl Inline {
             selection_source: None,
             range_backgrounds: Vec::new(),
             reveal: None,
-            text_view: None,
+            link_source: super::link::LinkSource::None,
             link_fragments: None,
             element_id: None,
             retained_key: None,
             handed_over: false,
+            editor_piece: None,
             state,
         }
     }
@@ -392,6 +517,13 @@ impl Inline {
         self
     }
 
+    /// Report this text as a fragment of the editor leaf `key`, starting at
+    /// the leaf's byte `start` (see `report_to_editor`).
+    pub(super) fn editor_piece(mut self, piece: Option<(u64, usize)>) -> Self {
+        self.editor_piece = piece;
+        self
+    }
+
     /// Preserve the shared inline-flow baseline through GPUI's element-bound snapping.
     pub(super) fn paint_origin(mut self, origin: Point<Pixels>) -> Self {
         self.paint_origin = Some(origin);
@@ -474,11 +606,8 @@ impl Inline {
         );
     }
 
-    pub(super) fn text_view(
-        mut self,
-        view: Option<gpui::WeakEntity<super::TextViewState>>,
-    ) -> Self {
-        self.text_view = view;
+    pub(super) fn link_source(mut self, source: super::link::LinkSource) -> Self {
+        self.link_source = source;
         self
     }
     pub(super) fn link_fragments(mut self, fragments: super::link::Fragments) -> Self {
@@ -490,7 +619,7 @@ impl Inline {
         self
     }
 
-    fn link_regions(&self, window: &Window) -> Vec<(super::node::LinkOccurrence, Bounds<Pixels>)> {
+    fn link_regions(&self, window: &Window) -> Vec<super::link::Fragment> {
         let layout = self.styled_text.layout();
         let bounds = layout.bounds();
         let line_height = layout.line_height();
@@ -498,26 +627,31 @@ impl Inline {
         self.links
             .iter()
             .flat_map(|(range, mark)| {
-                let occurrence = mark.occurrence;
-                range_boxes(&glyphs, range.clone()).into_iter().filter_map(
-                    move |(row, left, right)| {
-                        occurrence.map(|id| {
-                            (
-                                id,
-                                Bounds::from_corners(
-                                    point(
-                                        bounds.left() + left,
-                                        bounds.top() + line_height * row as f32,
-                                    ),
-                                    point(
-                                        bounds.left() + right,
-                                        bounds.top() + line_height * (row + 1) as f32,
-                                    ),
+                range_boxes(&glyphs, range.clone())
+                    .into_iter()
+                    .enumerate()
+                    .map(move |(fragment_ix, (row, left, right))| {
+                        super::link::Fragment::new(
+                            mark.clone(),
+                            if fragment_ix == 0 {
+                                SharedString::from(
+                                    self.text.get(range.clone()).unwrap_or_default().to_string(),
+                                )
+                            } else {
+                                SharedString::default()
+                            },
+                            Bounds::from_corners(
+                                point(
+                                    bounds.left() + left,
+                                    bounds.top() + line_height * row as f32,
                                 ),
-                            )
-                        })
-                    },
-                )
+                                point(
+                                    bounds.left() + right,
+                                    bounds.top() + line_height * (row + 1) as f32,
+                                ),
+                            ),
+                        )
+                    })
             })
             .collect()
     }
@@ -539,10 +673,12 @@ impl Inline {
         &self,
         text_layout: &TextLayout,
         bounds: &Bounds<Pixels>,
-        window: &mut Window,
         cx: &mut App,
     ) -> (bool, bool, Option<Selection>) {
-        let Some(text_view_state) = self.text_view.as_ref().and_then(gpui::WeakEntity::upgrade)
+        let Some(text_view_state) = self
+            .link_source
+            .text_view()
+            .and_then(gpui::WeakEntity::upgrade)
         else {
             return (false, false, None);
         };
@@ -603,97 +739,17 @@ impl Inline {
         let Some((selection_start, selection_end)) = text_view_state.selection_points(cx) else {
             return (is_selectable, false, None);
         };
-        let line_height = window.line_height();
-
-        // Use for debug selection bounds
-        // self.paint_selected_bounds(Bounds::from_corners(selection_start, selection_end), window, cx);
-
-        // NOTE: the selection is computed purely from the geometric band
-        // (`selection_start`..`selection_end`), NOT from what is currently
-        // visible. Every glyph of a *painted* element is laid out (its
-        // `position_for_index` is valid) even when it is scrolled out of, or
-        // clipped by, an ancestor's viewport — the content mask only clips the
-        // painted pixels. Because the copied text is derived from
-        // `InlineState.selection`, gating the selection on `content_mask` here
-        // used to drop scrolled-out-but-selected glyphs, so a selection taller
-        // than the viewport (e.g. a long chat message, or a drag with
-        // auto-scroll) copied only the portion that happened to be on screen.
-        //
-        // This does not resurrect the #2156 clipped-hit-testing behavior: a
-        // selection can only START on visible text (window selection resolves
-        // endpoints with hitbox hover testing against visible Inline bounds),
-        // so the band's endpoints are always anchored to on-screen text.
-        // Content that is merely `overflow_hidden`
-        // (not scrolled) lies outside that band and is still excluded, while
-        // the highlight quads painted for off-screen glyphs are clipped away by
-        // GPUI's content mask as before.
-        //
-        // Each character is tested with its row's top and height, so an inline
-        // whose rows all miss the band, or all lie strictly inside it with no
-        // endpoint on any row, has the same answer for every character. Decide
-        // those without the walk below, which scans the layout twice per
-        // character: one long code block alone made every paint of a held
-        // selection cost tens of milliseconds.
-        if self.text.is_empty() {
-            return (true, true, None);
-        }
-        let (rows_top, rows_bottom) = match self.selection_bounds {
-            Some(bounds) => (bounds.top(), bounds.top() + bounds.size.height),
-            None => text_rows_extent(text_layout, line_height),
-        };
-        let band_top = selection_start.y.min(selection_end.y);
-        let band_bottom = selection_start.y.max(selection_end.y);
-        if rows_bottom <= band_top || rows_top > band_bottom {
-            return (true, true, None);
-        }
-        if band_top < rows_top && band_bottom >= rows_bottom && text_layout.len() >= self.text.len()
-        {
-            return (true, true, Some((0..self.text.len()).into()));
-        }
-
-        let mut selection: Option<Selection> = None;
-        let mut offset = 0;
-        let mut chars = self.text.chars().peekable();
-        while let Some(c) = chars.next() {
-            let Some(pos) = text_layout.position_for_index(offset) else {
-                offset += c.len_utf8();
-                continue;
-            };
-
-            let next_offset = offset + c.len_utf8();
-            let mut char_width = line_height.half();
-            if let Some(next_pos) = text_layout.position_for_index(next_offset) {
-                if next_pos.y == pos.y {
-                    char_width = next_pos.x - pos.x;
-                }
-            }
-
-            let selection_pos = self
-                .selection_bounds
-                .map_or(pos, |bounds| point(pos.x, bounds.top()));
-            let selection_height = self
-                .selection_bounds
-                .map_or(line_height, |bounds| bounds.size.height);
-            if point_in_text_selection(
-                selection_pos,
-                char_width,
+        (
+            true,
+            true,
+            project_selection(
+                &self.text,
+                text_layout,
+                self.selection_bounds,
                 selection_start,
                 selection_end,
-                selection_height,
-            ) {
-                if selection.is_none() {
-                    selection = Some((offset..offset).into());
-                }
-
-                if let Some(selection) = selection.as_mut() {
-                    selection.end = next_offset;
-                }
-            }
-
-            offset = next_offset;
-        }
-
-        (true, true, selection)
+            ),
+        )
     }
 
     /// One unclipped box per laid-out row, from its start to its last
@@ -709,9 +765,12 @@ impl Inline {
     /// a line height past its last glyph: the character walk gave that width
     /// to a character whose successor sits on the next row, and the selection
     /// geometry was tuned against it.
-    fn text_line_bounds(text_layout: &TextLayout, line_height: Pixels) -> Vec<Bounds<Pixels>> {
-        let origin = text_layout.bounds().origin;
-        let lines = text_layout.line_layouts();
+    fn text_line_bounds(
+        text_layout: &PaintedSelectionLayout,
+        line_height: Pixels,
+    ) -> Vec<Bounds<Pixels>> {
+        let origin = text_layout.bounds.origin;
+        let lines = &text_layout.lines;
         let row_count: usize = lines
             .iter()
             .map(|line| line.wrap_boundaries.len() + 1)
@@ -719,7 +778,7 @@ impl Inline {
         let mut line_bounds = Vec::with_capacity(row_count);
         let mut row_ix = 0;
         let mut y = origin.y;
-        for line in &lines {
+        for line in lines {
             let layout = &line.unwrapped_layout;
             let mut row_start = 0;
             let row_ends = line
@@ -932,6 +991,7 @@ impl Element for Inline {
         let bounds = Bounds::new(self.paint_origin.unwrap_or(bounds.origin), bounds.size);
         self.styled_text
             .prepaint(id, inspector_id, bounds, &mut (), window, cx);
+        self.report_to_editor();
 
         self.request_reveal(window);
 
@@ -941,7 +1001,7 @@ impl Element for Inline {
             sink.borrow_mut().extend(fragments);
             Vec::new()
         } else {
-            super::link::targets(fragments, &self.text_view, window, cx)
+            super::link::targets(fragments, &self.link_source, window, cx)
         };
         self.retain_styled_text();
         InlinePrepaintState { hitbox, links }
@@ -974,7 +1034,7 @@ impl Element for Inline {
 
         // layout selections
         let (is_selectable, is_selection, selection) =
-            self.layout_selections(&text_layout, &bounds, window, cx);
+            self.layout_selections(&text_layout, &bounds, cx);
 
         let Ok(mut state) = self.state.lock() else {
             _ = text_layout.paint_foreground(window, cx);
@@ -1000,8 +1060,8 @@ impl Element for Inline {
 
         if let Some(selection) = &state.selection {
             let color = self
-                .text_view
-                .as_ref()
+                .link_source
+                .text_view()
                 .and_then(gpui::WeakEntity::upgrade)
                 .map(|state| state.read(cx).text_view_style.selection())
                 .unwrap_or_else(|| crate::Theme::global(cx).tokens.colors.selection);
@@ -1011,14 +1071,22 @@ impl Element for Inline {
         _ = text_layout.paint_foreground(window, cx);
 
         if is_selectable {
-            if let Some(text_view_state) =
-                self.text_view.as_ref().and_then(gpui::WeakEntity::upgrade)
+            if let Some(text_view_state) = self
+                .link_source
+                .text_view()
+                .and_then(gpui::WeakEntity::upgrade)
             {
-                let text_bounds = Self::text_line_bounds(&text_layout, text_layout.line_height());
+                let copy_layout = PaintedSelectionLayout::new(&text_layout);
+                let text_bounds = Self::text_line_bounds(&copy_layout, text_layout.line_height());
                 let edges = state
                     .selection
                     .as_ref()
                     .and_then(|selection| Self::selection_edges(selection, &text_layout));
+                let (source, range) = self
+                    .selection_source
+                    .as_ref()
+                    .map(|(source, range)| (source, range.clone()))
+                    .unwrap_or((&self.state, 0..self.text.len()));
                 text_view_state.query(cx, |state, _| {
                     state.selection_adapter.report_inline(
                         hitbox,
@@ -1029,6 +1097,15 @@ impl Element for Inline {
                             hitbox.bounds,
                         )),
                         edges,
+                        Some(super::selection_adapter::CopyProjection::Text(
+                            InlineSelectionProjection {
+                                source: Arc::downgrade(source),
+                                range,
+                                text: self.text.clone(),
+                                layout: copy_layout,
+                                selection_bounds: self.selection_bounds,
+                            },
+                        )),
                     );
                 });
             }
@@ -1038,7 +1115,7 @@ impl Element for Inline {
                 let text_layout = text_layout.clone();
                 let inline_state = self.state.clone();
                 let text = self.text.clone();
-                let owner = self.text_view.clone();
+                let owner = self.link_source.text_view().cloned();
                 let line_bounds = self.selection_bounds;
                 let admission = owner
                     .as_ref()
@@ -1274,6 +1351,98 @@ fn selection_for_multi_click(
     }
 }
 
+trait SelectionGeometry {
+    fn len(&self) -> usize;
+    fn line_height(&self) -> Pixels;
+    fn rows_extent(&self) -> (Pixels, Pixels);
+    fn position_for_index(&self, index: usize) -> Option<Point<Pixels>>;
+}
+
+impl SelectionGeometry for TextLayout {
+    fn len(&self) -> usize {
+        TextLayout::len(self)
+    }
+    fn line_height(&self) -> Pixels {
+        TextLayout::line_height(self)
+    }
+    fn rows_extent(&self) -> (Pixels, Pixels) {
+        text_rows_extent(self, self.line_height())
+    }
+    fn position_for_index(&self, index: usize) -> Option<Point<Pixels>> {
+        TextLayout::position_for_index(self, index)
+    }
+}
+
+impl SelectionGeometry for PaintedSelectionLayout {
+    fn len(&self) -> usize {
+        self.len
+    }
+    fn line_height(&self) -> Pixels {
+        self.line_height
+    }
+    fn rows_extent(&self) -> (Pixels, Pixels) {
+        self.rows_extent()
+    }
+    fn position_for_index(&self, index: usize) -> Option<Point<Pixels>> {
+        self.position_for_index(index)
+    }
+}
+
+/// Shared by paint and Copy: project the current gesture onto the same shaped
+/// UTF-8 text, including rendered inline-code spacing.
+fn project_selection(
+    text: &str,
+    text_layout: &impl SelectionGeometry,
+    selection_bounds: Option<Bounds<Pixels>>,
+    selection_start: Point<Pixels>,
+    selection_end: Point<Pixels>,
+) -> Option<Selection> {
+    if text.is_empty() {
+        return None;
+    }
+    let line_height = text_layout.line_height();
+    let (rows_top, rows_bottom) = match selection_bounds {
+        Some(bounds) => (bounds.top(), bounds.bottom()),
+        None => text_layout.rows_extent(),
+    };
+    let band_top = selection_start.y.min(selection_end.y);
+    let band_bottom = selection_start.y.max(selection_end.y);
+    if rows_bottom <= band_top || rows_top > band_bottom {
+        return None;
+    }
+    if band_top < rows_top && band_bottom >= rows_bottom && text_layout.len() >= text.len() {
+        return Some((0..text.len()).into());
+    }
+
+    let mut selection: Option<Selection> = None;
+    for (offset, c) in text.char_indices() {
+        let Some(pos) = text_layout.position_for_index(offset) else {
+            continue;
+        };
+        let next_offset = offset + c.len_utf8();
+        let mut char_width = line_height.half();
+        if let Some(next_pos) = text_layout.position_for_index(next_offset)
+            && next_pos.y == pos.y
+        {
+            char_width = next_pos.x - pos.x;
+        }
+        let selection_pos = selection_bounds.map_or(pos, |bounds| point(pos.x, bounds.top()));
+        let selection_height = selection_bounds.map_or(line_height, |bounds| bounds.size.height);
+        if point_in_text_selection(
+            selection_pos,
+            char_width,
+            selection_start,
+            selection_end,
+            selection_height,
+        ) {
+            selection
+                .get_or_insert_with(|| Selection::new(offset, offset))
+                .end = next_offset;
+        }
+    }
+    selection
+}
+
 /// Check if a `pos` is within a `bounds`, considering multi-line selections.
 pub(super) fn point_in_text_selection(
     pos: Point<Pixels>,
@@ -1468,9 +1637,10 @@ mod line_bounds_tests {
                     );
                     element.prepaint_at(origin, window, cx);
                     let line_height = layout.line_height();
+                    let painted_layout = PaintedSelectionLayout::new(&layout);
                     for mask in [mask, clipped] {
                         let expected = by_character(text, &layout, line_height, mask);
-                        let actual: Vec<_> = Inline::text_line_bounds(&layout, line_height)
+                        let actual: Vec<_> = Inline::text_line_bounds(&painted_layout, line_height)
                             .into_iter()
                             .map(|bounds| bounds.intersect(&mask))
                             .filter(|bounds| {
diff --git a/third-party/gpui-kit/crates/base/src/text/inline_flow.rs b/third-party/gpui-kit/crates/base/src/text/inline_flow.rs
index 3947cbc4c1..0603eb5d14 100644
--- a/third-party/gpui-kit/crates/base/src/text/inline_flow.rs
+++ b/third-party/gpui-kit/crates/base/src/text/inline_flow.rs
@@ -27,9 +27,16 @@ pub(super) const INLINE_CODE_PADDING: f32 = 4.;
 pub(super) struct InlineFlow {
     id: ElementId,
     items: Vec<InlineFlowItem>,
-    text_view: Option<gpui::WeakEntity<super::TextViewState>>,
+    link_source: super::link::LinkSource,
+    /// The block editor leaf this flow renders (design D3): its fragments
+    /// and atoms are reported to the editor sink as pieces.
+    editor_key: Option<u64>,
 }
 
+/// Bytes an image or custom inline node occupies in an editor leaf (one
+/// U+FFFC).
+const EDITOR_ATOM_LEN: usize = '\u{FFFC}'.len_utf8();
+
 pub(super) type InlineRenderer = dyn Fn(&super::InlineRenderContext, &mut Window, &mut App) -> Option<super::InlineElement>
     + Send
     + Sync;
@@ -58,6 +65,7 @@ pub(super) enum InlineFlowItem {
     Image {
         source: ImageSource,
         link: Option<LinkMark>,
+        title: SharedString,
         width: Option<DefiniteLength>,
         height: Option<DefiniteLength>,
     },
@@ -167,6 +175,7 @@ struct InlineFlowTypography {
 #[derive(Default)]
 struct InlineFlowLayout {
     fragments: Vec<PositionedFragment>,
+    empty_lines: Vec<(usize, Bounds<Pixels>)>,
     size: Size<Pixels>,
     wrap_width: Option<Pixels>,
 }
@@ -238,20 +247,33 @@ enum LineFragmentKind {
     Image,
 }
 
+/// Atom decorations are overlays so they remain visible over opaque content.
+pub(super) struct FlowPaintItem {
+    element: AnyElement,
+    background: Option<(Bounds<Pixels>, Hsla)>,
+    overlays: Vec<(Bounds<Pixels>, Hsla)>,
+}
+
 impl InlineFlow {
     pub(super) fn new(id: impl Into<ElementId>, items: Vec<InlineFlowItem>) -> Self {
         Self {
             id: id.into(),
             items,
-            text_view: None,
+            link_source: super::link::LinkSource::None,
+            editor_key: None,
         }
     }
 
-    pub(super) fn text_view(
-        mut self,
-        view: Option<gpui::WeakEntity<super::TextViewState>>,
-    ) -> Self {
-        self.text_view = view;
+    pub(super) fn editor_key(mut self, key: Option<u64>) -> Self {
+        if let Some(key) = key {
+            self.id = ("editor-leaf", key).into();
+        }
+        self.editor_key = key;
+        self
+    }
+
+    pub(super) fn link_source(mut self, source: super::link::LinkSource) -> Self {
+        self.link_source = source;
         self
     }
 
@@ -276,7 +298,7 @@ impl IntoElement for InlineFlow {
 
 #[derive(Default)]
 pub(crate) struct InlineFlowPrepaintState {
-    elements: Vec<(AnyElement, Option<(Bounds<Pixels>, gpui::Hsla)>)>,
+    elements: Vec<FlowPaintItem>,
     links: Vec<AnyElement>,
 }
 
@@ -437,6 +459,72 @@ impl Element for InlineFlow {
             .reduce(|found, next| if found.1 { found } else { next })
             .map(|(ix, _)| ix);
 
+        // Editor leaf byte offset where each item starts: text items count
+        // their bytes, images and custom nodes one U+FFFC.
+        let editor = self
+            .editor_key
+            .and_then(|key| super::editor_seam::current().map(|sink| (key, sink)));
+        // Ordinary TextViews do not allocate editor bookkeeping.
+        let item_starts: Vec<usize> = if let Some((key, sink)) = &editor {
+            sink.begin_pieces(*key);
+            let mut at = 0;
+            self.items
+                .iter()
+                .map(|item| {
+                    let start = at;
+                    at += match item {
+                        InlineFlowItem::Text { text, .. } => text.len(),
+                        _ => EDITOR_ATOM_LEN,
+                    };
+                    start
+                })
+                .collect()
+        } else {
+            Vec::new()
+        };
+        if let Some((key, sink)) = &editor {
+            for (start, line_bounds) in &layout.empty_lines {
+                // Flow layout uses a one-byte object placeholder; the public
+                // leaf coordinate space uses the three-byte U+FFFC instead.
+                let mut flow_start = 0;
+                let mut leaf_start = 0;
+                for item in &self.items {
+                    let len = match item {
+                        InlineFlowItem::Text { text, .. } => text.len(),
+                        _ => IMAGE_LEN,
+                    };
+                    if flow_start + len > *start {
+                        break;
+                    }
+                    flow_start += len;
+                    leaf_start += match item {
+                        InlineFlowItem::Text { text, .. } => text.len(),
+                        _ => EDITOR_ATOM_LEN,
+                    };
+                }
+                sink.report_empty_piece(
+                    *key,
+                    leaf_start + start - flow_start,
+                    Bounds::new(bounds.origin + line_bounds.origin, line_bounds.size),
+                );
+            }
+        }
+        let decorations = editor.as_ref().map(|(key, sink)| sink.backgrounds(*key));
+        let report_box = |item_ix: usize, bounds: Bounds<Pixels>| {
+            let mut overlays = Vec::new();
+            if let Some((key, sink)) = &editor {
+                let start = item_starts[item_ix];
+                let end = start + EDITOR_ATOM_LEN;
+                sink.report_box_piece(*key, start..end, bounds);
+                for (range, color) in decorations.as_deref().unwrap_or_default() {
+                    if range.start < range.end && range.start < end && range.end > start {
+                        overlays.push((bounds, *color));
+                    }
+                }
+            }
+            overlays
+        };
+
         let mut text_fragment_count = 0;
         for (fragment_ix, fragment) in layout.fragments.iter().enumerate() {
             match fragment {
@@ -458,6 +546,8 @@ impl Element for InlineFlow {
                         continue;
                     };
                     let object_size = object.metrics.size;
+                    let overlays =
+                        report_box(*item_ix, Bounds::new(bounds.origin + *origin, object_size));
                     let mut element = InlineObject::new(
                         ("inline-object", *id),
                         text.clone(),
@@ -473,7 +563,7 @@ impl Element for InlineFlow {
                             size(bounds.size.width, selection_bounds.size.height),
                         ),
                     )
-                    .text_view(self.text_view.clone())
+                    .text_view(self.link_source.text_view().cloned())
                     .into_any_element();
                     element.prepaint_as_root(
                         bounds.origin + *origin,
@@ -484,12 +574,18 @@ impl Element for InlineFlow {
                         window,
                         cx,
                     );
-                    if let Some(id) = link.as_ref().and_then(|link| link.occurrence) {
-                        fragments
-                            .borrow_mut()
-                            .push((id, Bounds::new(bounds.origin + *origin, object_size)));
+                    if let Some(link) = link {
+                        fragments.borrow_mut().push(super::link::Fragment::new(
+                            link.clone(),
+                            accessibility_label.clone(),
+                            Bounds::new(bounds.origin + *origin, object_size),
+                        ));
                     }
-                    elements.push((element, None));
+                    elements.push(FlowPaintItem {
+                        element,
+                        background: None,
+                        overlays,
+                    });
                 }
                 PositionedFragment::Text {
                     item_ix,
@@ -539,7 +635,7 @@ impl Element for InlineFlow {
                     fragment_style.line_height = fragment_size.height.into();
                     fragment_style.white_space = WhiteSpace::Nowrap;
                     let mut element = Inline::new(state, links.clone(), highlights.clone())
-                        .text_view(self.text_view.clone())
+                        .link_source(self.link_source.clone())
                         .link_fragments(fragments.clone())
                         .selection_source(source_state.clone(), source_range.clone())
                         .range_backgrounds(slice_ranges(
@@ -555,6 +651,11 @@ impl Element for InlineFlow {
                                 .map(|reveal| reveal.clamp(source_range.start, source_range.end)),
                         )
                         .text_style(fragment_style.clone())
+                        .editor_piece(
+                            editor
+                                .as_ref()
+                                .map(|(key, _)| (*key, item_starts[*item_ix] + source_range.start)),
+                        )
                         .selection_bounds(Bounds::new(
                             point(bounds.left(), bounds.top() + selection_bounds.top()),
                             size(bounds.size.width, selection_bounds.size.height),
@@ -582,17 +683,29 @@ impl Element for InlineFlow {
                             },
                         );
                     });
-                    elements.push((element, background));
+                    elements.push(FlowPaintItem {
+                        element,
+                        background,
+                        overlays: Vec::new(),
+                    });
                 }
                 PositionedFragment::Image {
                     item_ix,
                     origin,
                     size: fragment_size,
                 } => {
-                    let InlineFlowItem::Image { source, link, .. } = &self.items[*item_ix] else {
+                    let InlineFlowItem::Image {
+                        source,
+                        link,
+                        title,
+                        ..
+                    } = &self.items[*item_ix]
+                    else {
                         continue;
                     };
                     let (origin, fragment_size) = (*origin, *fragment_size);
+                    let overlays =
+                        report_box(*item_ix, Bounds::new(bounds.origin + origin, fragment_size));
                     let mut element = Self::image_element(elements.len(), source, fragment_size);
                     element.prepaint_as_root(
                         bounds.origin + origin,
@@ -603,12 +716,18 @@ impl Element for InlineFlow {
                         window,
                         cx,
                     );
-                    if let Some(id) = link.as_ref().and_then(|link| link.occurrence) {
-                        fragments
-                            .borrow_mut()
-                            .push((id, Bounds::new(bounds.origin + origin, fragment_size)));
+                    if let Some(link) = link {
+                        fragments.borrow_mut().push(super::link::Fragment::new(
+                            link.clone(),
+                            title.clone(),
+                            Bounds::new(bounds.origin + origin, fragment_size),
+                        ));
                     }
-                    elements.push((element, None));
+                    elements.push(FlowPaintItem {
+                        element,
+                        background: None,
+                        overlays,
+                    });
                 }
             }
         }
@@ -619,7 +738,7 @@ impl Element for InlineFlow {
             .truncate(text_fragment_count);
 
         let fragments = std::mem::take(&mut *fragments.borrow_mut());
-        let links = super::link::targets(fragments, &self.text_view, window, cx);
+        let links = super::link::targets(fragments, &self.link_source, window, cx);
         InlineFlowPrepaintState { elements, links }
     }
 
@@ -634,8 +753,8 @@ impl Element for InlineFlow {
         cx: &mut App,
     ) {
         let preserve_selection = self
-            .text_view
-            .as_ref()
+            .link_source
+            .text_view()
             .and_then(gpui::WeakEntity::upgrade)
             .is_some_and(|view| view.read(cx).preserve_inline_selection);
         for item in &self.items {
@@ -647,11 +766,19 @@ impl Element for InlineFlow {
             }
         }
         let radius = crate::Theme::global(cx).tokens.radius.sm;
-        for (element, background) in &mut prepaint.elements {
+        for FlowPaintItem {
+            element,
+            background,
+            overlays,
+        } in &mut prepaint.elements
+        {
             if let Some((bounds, color)) = background {
                 window.paint_quad(gpui::fill(*bounds, *color).corner_radii(radius));
             }
             element.paint(window, cx);
+            for (bounds, color) in overlays {
+                window.paint_quad(gpui::fill(*bounds, *color));
+            }
         }
         for link in &mut prepaint.links {
             link.paint(window, cx);
@@ -861,6 +988,7 @@ fn layout_measured_flow(
     let line_ranges = line_ranges(items, image_sizes, &objects, text_style, wrap_width, window);
     let font_size = text_style.font_size.to_pixels(rem_size);
     let mut fragments = Vec::new();
+    let mut empty_lines = Vec::new();
     let mut max_width = Pixels::ZERO;
     let mut y = Pixels::ZERO;
 
@@ -1006,6 +1134,15 @@ fn layout_measured_flow(
             item_start = item_end;
         }
 
+        if line_range.is_empty() {
+            empty_lines.push((
+                line_range.start,
+                Bounds::new(
+                    point(Pixels::ZERO, y),
+                    size(Pixels::ZERO, line_ascent + line_descent),
+                ),
+            ));
+        }
         let mut x = Pixels::ZERO;
         for mut fragment in line_fragments {
             // Give a text run's glyph box equal leading on both sides, as
@@ -1075,6 +1212,7 @@ fn layout_measured_flow(
 
     InlineFlowLayout {
         fragments,
+        empty_lines,
         size: size(max_width, y),
         wrap_width,
     }
@@ -1155,6 +1293,10 @@ fn line_ranges(
     let mut ranges = Vec::new();
 
     for hard_line in hard_lines {
+        if hard_line.is_empty() {
+            ranges.push(hard_line);
+            continue;
+        }
         let mut item_start = 0;
         let mut wrap_fragments = Vec::new();
         for (ix, item) in items.iter().enumerate() {
diff --git a/third-party/gpui-kit/crates/base/src/text/inline_object.rs b/third-party/gpui-kit/crates/base/src/text/inline_object.rs
index f85fabe6af..28d9460449 100644
--- a/third-party/gpui-kit/crates/base/src/text/inline_object.rs
+++ b/third-party/gpui-kit/crates/base/src/text/inline_object.rs
@@ -146,6 +146,35 @@ impl MeasuredInlineObject {
     }
 }
 
+pub(super) struct InlineObjectSelectionProjection {
+    selected: std::sync::Weak<Mutex<bool>>,
+    bounds: Bounds<Pixels>,
+}
+
+impl InlineObjectSelectionProjection {
+    pub(super) fn project(&self, start: gpui::Point<Pixels>, end: gpui::Point<Pixels>) {
+        if let Some(selected) = self.selected.upgrade()
+            && let Ok(mut selected) = selected.lock()
+        {
+            *selected = object_in_selection(self.bounds, start, end);
+        }
+    }
+}
+
+fn object_in_selection(
+    bounds: Bounds<Pixels>,
+    start: gpui::Point<Pixels>,
+    end: gpui::Point<Pixels>,
+) -> bool {
+    point_in_text_selection(
+        bounds.origin,
+        bounds.size.width,
+        start,
+        end,
+        bounds.size.height,
+    )
+}
+
 /// Atomic selection wrapper that leaves child styling and interaction to GPUI.
 pub(super) struct InlineObject {
     text_view: Option<gpui::WeakEntity<super::TextViewState>>,
@@ -298,13 +327,7 @@ impl Element for InlineObject {
                         )
                     })
                     || state.selection_points(cx).is_some_and(|(start, end)| {
-                        point_in_text_selection(
-                            self.selection_bounds.origin,
-                            self.selection_bounds.size.width,
-                            start,
-                            end,
-                            self.selection_bounds.size.height,
-                        )
+                        object_in_selection(self.selection_bounds, start, end)
                     }))
         });
         if let Ok(mut value) = self.selected.lock() {
@@ -317,9 +340,18 @@ impl Element for InlineObject {
         if selectable {
             window.set_cursor_style(CursorStyle::IBeam, hitbox);
             view.as_ref().unwrap().query(cx, |state, _| {
-                state
-                    .selection_adapter
-                    .report_inline(hitbox, vec![bounds], None, None);
+                state.selection_adapter.report_inline(
+                    hitbox,
+                    vec![bounds],
+                    None,
+                    None,
+                    Some(super::selection_adapter::CopyProjection::InlineObject(
+                        InlineObjectSelectionProjection {
+                            selected: Arc::downgrade(&self.selected),
+                            bounds: self.selection_bounds,
+                        },
+                    )),
+                );
             });
             let hitbox = hitbox.clone();
             let selected_state = self.selected.clone();
diff --git a/third-party/gpui-kit/crates/base/src/text/link.rs b/third-party/gpui-kit/crates/base/src/text/link.rs
index 29566de1c4..2cde8dddc3 100644
--- a/third-party/gpui-kit/crates/base/src/text/link.rs
+++ b/third-party/gpui-kit/crates/base/src/text/link.rs
@@ -35,6 +35,44 @@ use super::{
     state::{ParsedContent, TextViewState},
 };
 
+pub(super) type EditorLinkHandler =
+    dyn Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync;
+
+/// Rendering has exactly one activation authority. A retired TextView never
+/// falls through to the external editor's default URL handling.
+#[derive(Clone, Default)]
+pub(crate) enum LinkSource {
+    #[default]
+    None,
+    TextView(WeakEntity<TextViewState>),
+    ExternalEditor(Option<Arc<EditorLinkHandler>>),
+}
+
+impl LinkSource {
+    pub(super) fn text_view(&self) -> Option<&WeakEntity<TextViewState>> {
+        match self {
+            Self::TextView(view) => Some(view),
+            Self::None | Self::ExternalEditor(_) => None,
+        }
+    }
+}
+
+pub(super) struct Fragment {
+    mark: LinkMark,
+    label: SharedString,
+    bounds: Bounds<Pixels>,
+}
+
+impl Fragment {
+    pub(super) fn new(mark: LinkMark, label: SharedString, bounds: Bounds<Pixels>) -> Self {
+        Self {
+            mark,
+            label,
+            bounds,
+        }
+    }
+}
+
 struct LinkOwner {
     mark: LinkMark,
     label: SharedString,
@@ -170,48 +208,132 @@ impl Links {
 }
 
 /// Per successful paragraph prepaint. It does not escape into document/window state.
-pub(super) type Fragments = Rc<std::cell::RefCell<Vec<(LinkOccurrence, Bounds<Pixels>)>>>;
+pub(super) type Fragments = Rc<std::cell::RefCell<Vec<Fragment>>>;
+
+#[derive(Clone)]
+enum Activation {
+    TextView {
+        view: WeakEntity<TextViewState>,
+        id: LinkOccurrence,
+        lease: Weak<()>,
+    },
+    ExternalEditor {
+        url: SharedString,
+        handler: Option<Arc<EditorLinkHandler>>,
+    },
+}
+
+impl Activation {
+    fn admitted(&self, cx: &App) -> bool {
+        match self {
+            Self::TextView { view, id, lease } => admitted(view, *id, lease, cx),
+            Self::ExternalEditor { .. } => true,
+        }
+    }
+
+    fn activate(&self, event: &ClickEvent, window: &mut Window, cx: &mut App) {
+        match self {
+            Self::TextView { view, id, lease } => activate(view, *id, lease, event, window, cx),
+            Self::ExternalEditor { url, handler } => {
+                if let Some(handler) = handler {
+                    handler(url, event, window, cx);
+                } else if activates(event) && !window.default_prevented() {
+                    cx.open_url(url);
+                }
+            }
+        }
+    }
+}
 
 pub(super) fn targets(
-    fragments: Vec<(LinkOccurrence, Bounds<Pixels>)>,
-    view: &Option<WeakEntity<TextViewState>>,
+    fragments: Vec<Fragment>,
+    source: &LinkSource,
     window: &mut Window,
     cx: &mut App,
 ) -> Vec<AnyElement> {
-    let Some(view) = view.as_ref().and_then(WeakEntity::upgrade) else {
+    if matches!(source, LinkSource::None) {
         return Vec::new();
-    };
-    let mut groups: Vec<(LinkOccurrence, Vec<Bounds<Pixels>>)> = Vec::new();
-    for (id, bounds) in fragments {
+    }
+    let mut groups: Vec<(LinkMark, String, Vec<Bounds<Pixels>>)> = Vec::new();
+    for Fragment {
+        mark,
+        label,
+        bounds,
+    } in fragments
+    {
         if bounds.size.width <= Pixels::ZERO || bounds.size.height <= Pixels::ZERO {
             continue;
         }
-        if let Some((_, regions)) = groups.iter_mut().find(|(key, _)| *key == id) {
+        let Some(id) = mark.occurrence else { continue };
+        if let Some((_, text, regions)) = groups
+            .iter_mut()
+            .find(|(mark, _, _)| mark.occurrence == Some(id))
+        {
+            text.push_str(&label);
             regions.push(bounds);
         } else {
-            groups.push((id, vec![bounds]));
+            groups.push((mark, label.to_string(), vec![bounds]));
         }
     }
     let mut result = Vec::new();
-    for (id, regions) in groups {
-        let prepared = view.update(cx, |state, _| {
-            if !state.interaction_enabled || state.parsed_error.is_some() {
-                return None;
+    for (mark, label, regions) in groups {
+        let id = mark.occurrence.unwrap();
+        let (target_id, focus, activation, label, url, color) = match source {
+            LinkSource::None => unreachable!(),
+            LinkSource::TextView(weak) => {
+                let Some(view) = weak.upgrade() else { continue };
+                let prepared = view.update(cx, |state, _| {
+                    if !state.interaction_enabled || state.parsed_error.is_some() {
+                        return None;
+                    }
+                    let color = state.text_view_style.link();
+                    let owner = state.links.0.get_mut(&id)?;
+                    let lease = Rc::downgrade(owner.lease.get_or_insert_with(|| Rc::new(())));
+                    owner.focus = owner.focus.clone().tab_stop(true);
+                    Some((
+                        owner.focus.clone(),
+                        lease,
+                        owner.label.clone(),
+                        owner.mark.url.clone(),
+                        color,
+                    ))
+                });
+                let Some((focus, lease, label, url, color)) = prepared else {
+                    continue;
+                };
+                (
+                    SharedString::from(format!("link/{id:?}")),
+                    focus,
+                    Activation::TextView {
+                        view: weak.clone(),
+                        id,
+                        lease,
+                    },
+                    label,
+                    url,
+                    color,
+                )
+            }
+            LinkSource::ExternalEditor(handler) => {
+                let target_id = SharedString::from(format!("editor-link/{id:?}/{}", mark.url));
+                let focus = window
+                    .use_keyed_state(target_id.clone(), cx, |_, cx| {
+                        cx.focus_handle().tab_stop(true)
+                    })
+                    .read(cx)
+                    .clone();
+                (
+                    target_id,
+                    focus,
+                    Activation::ExternalEditor {
+                        url: mark.url.clone(),
+                        handler: handler.clone(),
+                    },
+                    SharedString::from(label),
+                    mark.url,
+                    window.text_style().color,
+                )
             }
-            let color = state.text_view_style.link();
-            let owner = state.links.0.get_mut(&id)?;
-            let lease = Rc::downgrade(owner.lease.get_or_insert_with(|| Rc::new(())));
-            owner.focus = owner.focus.clone().tab_stop(true);
-            Some((
-                owner.focus.clone(),
-                lease,
-                owner.label.clone(),
-                owner.mark.url.clone(),
-                color,
-            ))
-        });
-        let Some((focus, lease, label, url, color)) = prepared else {
-            continue;
         };
         let bounds = regions.iter().copied().reduce(|a, b| a.union(&b)).unwrap();
         let regions: Arc<[Bounds<Pixels>]> = regions
@@ -219,20 +341,14 @@ pub(super) fn targets(
             .map(|r| Bounds::new(r.origin - bounds.origin, r.size))
             .collect::<Vec<_>>()
             .into();
-        let weak = view.downgrade();
-        let mouse_view = weak.clone();
-        let mouse_lease = lease.clone();
-        let key_view = weak.clone();
-        let key_lease = lease.clone();
-        let click_view = weak.clone();
-        let click_lease = lease.clone();
-        let aux_view = weak.clone();
-        let aux_lease = lease.clone();
-        let focus_view = weak.clone();
-        let focus_lease = lease.clone();
+        let mouse_activation = activation.clone();
+        let key_activation = activation.clone();
+        let click_activation = activation.clone();
+        let aux_activation = activation.clone();
+        let focus_activation = activation.clone();
         let ax_focus = focus.clone();
         let element = div()
-            .id(SharedString::from(format!("link/{id:?}")))
+            .id(target_id)
             .w(bounds.size.width)
             .h(bounds.size.height)
             .hitbox_regions(regions.clone())
@@ -242,33 +358,27 @@ pub(super) fn targets(
             .aria_label(if label.is_empty() { url } else { label })
             .cursor_pointer()
             .on_a11y_action(gpui::accesskit::Action::Focus, move |_, window, cx| {
-                if admitted(&focus_view, id, &focus_lease, cx) {
+                if focus_activation.admitted(cx) {
                     window.focus(&ax_focus, cx);
                 }
             })
             .capture_any_mouse_down(move |_, window, cx| {
-                if !admitted(&mouse_view, id, &mouse_lease, cx) {
+                if !mouse_activation.admitted(cx) {
                     window.prevent_default();
                     cx.stop_propagation();
                 }
             })
             .capture_key_down(move |_, window, cx| {
-                if !admitted(&key_view, id, &key_lease, cx) {
+                if !key_activation.admitted(cx) {
                     window.prevent_default();
                     cx.stop_propagation();
                 }
             })
-            .on_click(move |event, window, cx| {
-                activate(&click_view, id, &click_lease, event, window, cx)
-            })
-            .on_aux_click(move |event, window, cx| {
-                activate(&aux_view, id, &aux_lease, event, window, cx)
-            });
+            .on_click(move |event, window, cx| click_activation.activate(event, window, cx))
+            .on_aux_click(move |event, window, cx| aux_activation.activate(event, window, cx));
         let mut target = LinkTarget {
             element,
-            view: weak,
-            id,
-            lease,
+            activation,
             focus,
             regions,
             color,
@@ -334,26 +444,27 @@ fn activate(
     if decision.stop_propagation {
         cx.stop_propagation();
     }
-    let activates = match event {
+    if activates(event) && !window.default_prevented() && admitted(view, id, lease, cx) {
+        crate::TextSelection::end(window, cx);
+        cx.open_url(&url);
+    }
+}
+
+fn activates(event: &ClickEvent) -> bool {
+    match event {
         ClickEvent::Mouse(click) => matches!(
             click.up.button,
             gpui::MouseButton::Left | gpui::MouseButton::Middle
         ),
         ClickEvent::Touch(click) => !click.long_press,
         ClickEvent::Keyboard(_) | ClickEvent::Accessibility(_) => true,
-    };
-    if activates && !window.default_prevented() && admitted(view, id, lease, cx) {
-        crate::TextSelection::end(window, cx);
-        cx.open_url(&url);
     }
 }
 
 /// Delegates all native stages; only records the weak locator of its actual Div state.
 struct LinkTarget {
     element: Stateful<Div>,
-    view: WeakEntity<TextViewState>,
-    id: LinkOccurrence,
-    lease: Weak<()>,
+    activation: Activation,
     focus: FocusHandle,
     regions: Arc<[Bounds<Pixels>]>,
     color: gpui::Hsla,
@@ -395,10 +506,12 @@ impl Element for LinkTarget {
     ) -> (LayoutId, Self::RequestLayoutState) {
         let layout = self.element.request_layout(id, inspector, window, cx);
         let click = self.element.interactivity().click_handle(id, window);
-        if let Some(view) = self.view.upgrade() {
+        if let Activation::TextView { view, id, lease } = &self.activation
+            && let Some(view) = view.upgrade()
+        {
             view.update(cx, |state, _| {
-                if state.links.admits(self.id, &self.lease) {
-                    state.links.0.get_mut(&self.id).unwrap().click = click;
+                if state.links.admits(*id, lease) {
+                    state.links.0.get_mut(id).unwrap().click = click;
                 }
             });
         }
@@ -430,7 +543,7 @@ impl Element for LinkTarget {
             .paint(id, inspector, bounds, layout, prepaint, window, cx);
         if self.focus.is_focused(window)
             && window.last_input_was_keyboard()
-            && admitted(&self.view, self.id, &self.lease, cx)
+            && self.activation.admitted(cx)
         {
             for region in self.regions.iter() {
                 window.paint_quad(gpui::quad(
@@ -575,6 +688,174 @@ mod tests {
         cx.simulate_event(gpui::KeyUpEvent { keystroke });
     }
 
+    #[gpui::test]
+    fn external_editor_links_use_native_fragments_and_their_explicit_source(
+        cx: &mut TestAppContext,
+    ) {
+        use crate::text::editor_host::{FlowPosition, RenderContext};
+        use crate::text::node::{ImageNode, InlineNode, TextMark};
+
+        struct Host {
+            blocks: Vec<BlockNode>,
+            context: RenderContext,
+        }
+        impl Render for Host {
+            fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+                let blocks: Vec<_> = self.blocks.iter().collect();
+                div().w(px(180.)).children(
+                    blocks
+                        .iter()
+                        .enumerate()
+                        .map(|(ix, block)| {
+                            block.render_with_id(
+                                ix,
+                                FlowPosition::at(&blocks, ix, ix + 1 == blocks.len()),
+                                &self.context,
+                                window,
+                                cx,
+                            )
+                        })
+                        .collect::<Vec<_>>(),
... 16253 lines suppressed ...


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org