Commit ac007054e for llama.cpp

commit ac007054e7b6b39788ca52d1402de0e2e0053162
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date:   Tue Nov 10 09:51:01 2026 +0100

    ui: use a live-preview markdown editor for the chat form input (#31955)

    * ui: use a live-preview markdown editor for the chat form input

    Replace the contenteditable rich input with a CodeMirror editor that shows
    the markdown source of the block being edited and renders every other block
    through the MarkdownContent pipeline and stylesheet, so they match chat
    messages. Plain text types exactly as before, the value stays the exact
    markdown source, and mention links show as atomic badges.

    The editor lives in a shared MarkdownEditor component, and the highlight.js
    theme loader is shared between MarkdownContent and SyntaxHighlightedCode.
    The old rich input, its tokenizer, source history and their tests go away.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: give the production build more memory, note what mdsvex is for

    The single-bundle build now includes CodeMirror and runs past Node's default
    heap limit on CI runners. Raise it in the build script, so local and CI builds
    behave the same on every platform.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: keep the live preview's blocks while only the cursor moves

    Moving the cursor re-walked the syntax tree and rebuilt every block's
    decoration. Keep the blocks with the decorations and rebuild only when the
    text changes or the cursor enters another block.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: point the chat form story at the editor and drop the engraved placeholder until the design system lands

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: name the markdown editor for screen readers

    CodeMirror's content is a textbox with no name of its own, so the chat form
    input was announced as an unnamed text box, and could not be found by its name
    as the old input could. It takes an aria-label, its placeholder by default,
    which makes the chat form's "Type a message..." again.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: format the chat form input's classes

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: share the markdown styles and diagram drawing between messages and the editor

    The editor and MarkdownContent each @imported markdown-content.css into their own
    <style>, so the bundle carried two scoped copies of it. Both now render into a
    MarkdownSurface, the one element that carries the stylesheet; the few rules for
    MarkdownContent's own template elements are global under .markdown-content.

    Drawing mermaid and svg blocks and the actions of their header buttons move to
    markdown-diagrams.ts, so the editor's rendered blocks can draw diagrams the same
    way messages do.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: harden the live preview editor after review

    - onChange skips only the editor's own syncs from `value`, marked by an
      annotation, instead of reporting only edits that carry a user event
    - Enter and the mention badges ask the syntax tree whether they are in code, so
      ~~~ fences and indented code count, and a closing fence ends a block
    - a picked mention is one undoable edit, through replaceText
    - rendered blocks draw mermaid and svg diagrams with their header buttons, a $$
      math block with blank lines renders as one block, and a link reference
      definition stays raw instead of rendering to nothing
    - rendered blocks render again when the mention path settings change
    - a drag in a rendered block selects its text and copy takes it, across blocks;
      a click still opens the block
    - the block margins are the ones a chat message renders (each heading is a first
      child there), checked by a test; the blank lines after a raw block are its gap
    - the tree comes from syntaxTree, without forcing a full parse on every edit
    - placeholder, label, extensions, mention widgets and render key follow prop
      changes after mount, through compartments
    - the editor uses the markdown language alone, which drops the html, css and
      javascript parsers markdown() pulls in for html tag completion (-70 KB gzip)

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: pin the CodeMirror packages and set the build heap in .npmrc

    The new dependencies are pinned like the rest of package.json. The build heap
    moves from a node call on vite's bin path to npm's node-options, so the build
    script is plain `vite build` again and the setting reaches it on every platform.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: update the live preview block by block and estimate block heights

    An edit used to build every block's decorations and widgets again, which the
    editor then compared one by one. The previous decorations, and the mention badges
    of raw blocks, now move through the edit, and only the blocks whose range, text,
    raw or rendered state, or gap changed get new ones; the texts of the blocks an
    edit left alone are reused instead of sliced again. A test drives random edits,
    cursor moves and focus changes and checks each result against a full build.

    Rendered blocks report the height they were last measured at, or a guess from
    their text, so CodeMirror's height map for blocks off screen in a long document
    stays close and scrolling stays steady.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: skip the editor's redundant syncs, reconfigures and theme reloads

    - syncing `value` compares it with the text the editor last reported or synced, so
      a keystroke coming back through onChange no longer serializes the document again
    - the prop effects reconfigure the editor only when a prop changed, not once more
      right after it is created (which rebuilt the whole preview)
    - loadHighlightTheme leaves the theme's style element alone while the mode stays,
      so a message or an editor mounting no longer restyles the whole page

    Tests check that typing in one block leaves the other rendered blocks without a
    single DOM mutation, and that mounting reconfigures nothing.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: simplify the live preview to a plain rebuild in three small modules

    Rebuilding the decorations of every block on an edit measures the same as
    patching only the changed ones (0.09 ms against 0.11 ms per keystroke at
    200 blocks, 0.9 ms against 1.1 ms at 2000), and equal widgets keep their
    DOM either way, so the incremental update and its test go.

    - markdown-blocks.ts collects the document's blocks, markdown-block-widget.ts
      renders one, markdown-live-preview.ts decides which ones show raw text
    - one bounded cache holds a block's HTML and its measured height
    - the render key is a plain option: a new one reconfigures the preview,
      which keeps the editor's focus
    - MarkdownEditor follows its props with one helper instead of five effects

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: keep setext headings raw in the live preview editor

    `test` over `---` is a setext heading, so the preview rendered it as an h2
    and the underline vanished once the cursor left the block. The editor now
    shows a setext heading's text as typed at all times, like a link
    reference definition.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: add a setting to turn the live markdown preview off

    "Live markdown preview in the message input" (General) is on by default.
    Off, the input shows the plain markdown text, with file mention badges
    still in place; switching it takes effect without a reload.

    Assisted-by: Claude (Opus 5.5 Medium)

diff --git a/tools/ui/.npmrc b/tools/ui/.npmrc
index 0a690322a..fd58f2418 100644
--- a/tools/ui/.npmrc
+++ b/tools/ui/.npmrc
@@ -1,3 +1,6 @@
 engine-strict=true
 ignore-scripts=true
 min-release-age=7
+# The single-bundle production build needs more than the default heap on CI runners;
+# npm passes this to every script it runs, on every platform
+node-options=--max-old-space-size=6144
diff --git a/tools/ui/package-lock.json b/tools/ui/package-lock.json
index 4fc68726b..df25e0aa2 100644
--- a/tools/ui/package-lock.json
+++ b/tools/ui/package-lock.json
@@ -9,9 +9,15 @@
 			"version": "1.0.0",
 			"devDependencies": {
 				"@chromatic-com/storybook": "5.5.1",
+				"@codemirror/commands": "6.11.1",
+				"@codemirror/lang-markdown": "6.5.2",
+				"@codemirror/language": "6.13.1",
+				"@codemirror/state": "6.7.6",
+				"@codemirror/view": "6.43.15",
 				"@eslint/compat": "1.4.1",
 				"@eslint/js": "9.39.2",
 				"@internationalized/date": "3.12.2",
+				"@lezer/highlight": "1.2.5",
 				"@lucide/svelte": "1.46.0",
 				"@modelcontextprotocol/sdk": "1.32.1",
 				"@playwright/test": "1.56.1",
@@ -1799,6 +1805,154 @@
 				"storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0 || ^10.5.0-0 || ^10.6.0-0 || ^11.0.0-0"
 			}
 		},
+		"node_modules/@codemirror/autocomplete": {
+			"version": "6.20.4",
+			"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.4.tgz",
+			"integrity": "sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.17.0",
+				"@lezer/common": "^1.0.0"
+			}
+		},
+		"node_modules/@codemirror/commands": {
+			"version": "6.11.1",
+			"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
+			"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.7.0",
+				"@codemirror/view": "^6.27.0",
+				"@lezer/common": "^1.1.0"
+			}
+		},
+		"node_modules/@codemirror/lang-css": {
+			"version": "6.3.1",
+			"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
+			"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/autocomplete": "^6.0.0",
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@lezer/common": "^1.0.2",
+				"@lezer/css": "^1.1.7"
+			}
+		},
+		"node_modules/@codemirror/lang-html": {
+			"version": "6.4.12",
+			"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
+			"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/autocomplete": "^6.0.0",
+				"@codemirror/lang-css": "^6.0.0",
+				"@codemirror/lang-javascript": "^6.0.0",
+				"@codemirror/language": "^6.4.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.17.0",
+				"@lezer/common": "^1.0.0",
+				"@lezer/css": "^1.1.0",
+				"@lezer/html": "^1.3.12"
+			}
+		},
+		"node_modules/@codemirror/lang-javascript": {
+			"version": "6.2.5",
+			"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
+			"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/autocomplete": "^6.0.0",
+				"@codemirror/language": "^6.6.0",
+				"@codemirror/lint": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.17.0",
+				"@lezer/common": "^1.0.0",
+				"@lezer/javascript": "^1.0.0"
+			}
+		},
+		"node_modules/@codemirror/lang-markdown": {
+			"version": "6.5.2",
+			"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz",
+			"integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/autocomplete": "^6.7.1",
+				"@codemirror/lang-html": "^6.0.0",
+				"@codemirror/language": "^6.3.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0",
+				"@lezer/common": "^1.2.1",
+				"@lezer/markdown": "^1.0.0"
+			}
+		},
+		"node_modules/@codemirror/language": {
+			"version": "6.13.1",
+			"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
+			"integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/streamparser": "^6.0.0",
+				"@codemirror/view": "^6.23.0",
+				"@lezer/common": "^1.5.0",
+				"@lezer/highlight": "^1.0.0",
+				"@lezer/lr": "^1.0.0",
+				"style-mod": "^4.0.0"
+			}
+		},
+		"node_modules/@codemirror/lint": {
+			"version": "6.9.7",
+			"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
+			"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.42.0",
+				"crelt": "^1.0.5"
+			}
+		},
+		"node_modules/@codemirror/state": {
+			"version": "6.7.6",
+			"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
+			"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@marijn/find-cluster-break": "^1.0.0"
+			}
+		},
+		"node_modules/@codemirror/streamparser": {
+			"version": "6.0.0",
+			"resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
+			"integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@codemirror/view": {
+			"version": "6.43.15",
+			"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
+			"integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/state": "^6.7.0",
+				"crelt": "^1.0.6",
+				"style-mod": "^4.1.0",
+				"w3c-keyname": "^2.2.4"
+			}
+		},
 		"node_modules/@emnapi/core": {
 			"version": "1.9.2",
 			"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
@@ -3273,6 +3427,80 @@
 				"@jridgewell/sourcemap-codec": "^1.4.14"
 			}
 		},
+		"node_modules/@lezer/common": {
+			"version": "1.5.3",
+			"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
+			"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@lezer/css": {
+			"version": "1.3.9",
+			"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
+			"integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@lezer/common": "^1.2.0",
+				"@lezer/highlight": "^1.0.0",
+				"@lezer/lr": "^1.3.0"
+			}
+		},
+		"node_modules/@lezer/highlight": {
+			"version": "1.2.5",
+			"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
+			"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@lezer/common": "^1.3.0"
+			}
+		},
+		"node_modules/@lezer/html": {
+			"version": "1.3.13",
+			"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
+			"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@lezer/common": "^1.2.0",
+				"@lezer/highlight": "^1.0.0",
+				"@lezer/lr": "^1.0.0"
+			}
+		},
+		"node_modules/@lezer/javascript": {
+			"version": "1.5.6",
+			"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
+			"integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@lezer/common": "^1.2.0",
+				"@lezer/highlight": "^1.1.3",
+				"@lezer/lr": "^1.3.0"
+			}
+		},
+		"node_modules/@lezer/lr": {
+			"version": "1.4.11",
+			"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
+			"integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@lezer/common": "^1.0.0"
+			}
+		},
+		"node_modules/@lezer/markdown": {
+			"version": "1.8.1",
+			"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.8.1.tgz",
+			"integrity": "sha512-M5YmEcKWO4mJ45Y1hECwqyu8cLv25EiXOK554PVOvDOWOP0olwelHHQUWr9sjF+d1bENVhy0nfJ8E57XZUTiXg==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@lezer/common": "^1.5.0",
+				"@lezer/highlight": "^1.0.0"
+			}
+		},
 		"node_modules/@lucide/svelte": {
 			"version": "1.46.0",
 			"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.46.0.tgz",
@@ -3283,6 +3511,13 @@
 				"svelte": "^5"
 			}
 		},
+		"node_modules/@marijn/find-cluster-break": {
+			"version": "1.0.4",
+			"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
+			"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
+			"dev": true,
+			"license": "MIT"
+		},
 		"node_modules/@mdx-js/react": {
 			"version": "3.1.1",
 			"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz",
@@ -8029,6 +8264,13 @@
 				"layout-base": "^1.0.0"
 			}
 		},
+		"node_modules/crelt": {
+			"version": "1.0.7",
+			"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
+			"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
+			"dev": true,
+			"license": "MIT"
+		},
 		"node_modules/cross-spawn": {
 			"version": "7.0.6",
 			"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -15787,6 +16029,13 @@
 				"url": "https://github.com/sponsors/sindresorhus"
 			}
 		},
+		"node_modules/style-mod": {
+			"version": "4.1.4",
+			"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
+			"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
+			"dev": true,
+			"license": "MIT"
+		},
 		"node_modules/style-to-object": {
 			"version": "1.0.14",
 			"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
@@ -17511,6 +17760,13 @@
 				"node": ">=18"
 			}
 		},
+		"node_modules/w3c-keyname": {
+			"version": "2.2.8",
+			"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+			"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+			"dev": true,
+			"license": "MIT"
+		},
 		"node_modules/web-namespaces": {
 			"version": "2.0.1",
 			"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
diff --git a/tools/ui/package.json b/tools/ui/package.json
index c29469d2d..00049071e 100644
--- a/tools/ui/package.json
+++ b/tools/ui/package.json
@@ -34,9 +34,15 @@
 	},
 	"devDependencies": {
 		"@chromatic-com/storybook": "5.5.1",
+		"@codemirror/commands": "6.11.1",
+		"@codemirror/lang-markdown": "6.5.2",
+		"@codemirror/language": "6.13.1",
+		"@codemirror/state": "6.7.6",
+		"@codemirror/view": "6.43.15",
 		"@eslint/compat": "1.4.1",
 		"@eslint/js": "9.39.2",
 		"@internationalized/date": "3.12.2",
+		"@lezer/highlight": "1.2.5",
 		"@lucide/svelte": "1.46.0",
 		"@modelcontextprotocol/sdk": "1.32.1",
 		"@playwright/test": "1.56.1",
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
index 4ba70bc9a..dcdcd8f78 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
@@ -46,13 +46,10 @@
 	} from '$lib/types';
 	import {
 		buildMentionInsertion,
-		containsCodeSpan,
-		containsFileMentionLink,
 		findCommandToken,
 		findMentionToken,
 		getConversationModel,
 		isIMEComposing,
-		isOffsetInCodeBlock,
 		parseClipboardContent,
 		uuid
 	} from '$lib/utils';
@@ -113,27 +110,13 @@
 	}: Props = $props();

 	// Component References
-	// Shared handle of the two input renderers (plain textarea + rich chat form input).
-	type ChatInputHandle = {
-		focus(): void;
-		resetHeight(): void;
-		getElement(): HTMLElement | undefined;
-		getCaretOffset(): number;
-		setCaretOffset(offset: number): void;
-	};

 	let audioRecorder: AudioRecorder | undefined;
 	let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
 	let fileInputRef: ChatFormInputFileInputInvisible | undefined = $state(undefined);
 	let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
 		$state(undefined);
-	let inputRef: ChatInputHandle | undefined = $state(undefined);
-
-	// Render-mode gate: the plain textarea by default, the rich chat form input
-	// while the buffer carries a `file://` mention link or a complete code
-	// span (badges and code chips need a DOM the textarea cannot provide).
-	// Demotes back once neither remains.
-	let useRichInput = $state(false);
+	let inputRef: ChatFormInput | undefined = $state(undefined);

 	// Audio Recording State
 	let isRecording = $state(false);
@@ -235,34 +218,6 @@
 	);
 	let canSubmit = $derived(value.trim().length > 0 || hasAttachments);

-	// Caret offset restored after a renderer swap. Callers that mutate
-	// `value` themselves (e.g. the mention picker) pin the target offset
-	// BEFORE the assignment; otherwise the swap effect snapshots the
-	// current caret.
-	let pendingCaretOffset = 0;
-	let caretOffsetPinned = false;
-
-	function queueCaretRestore() {
-		queueMicrotask(() => {
-			inputRef?.focus();
-			inputRef?.setCaretOffset(pendingCaretOffset);
-			caretOffsetPinned = false;
-		});
-	}
-
-	$effect(() => {
-		const wantRichInput = containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
-
-		if (useRichInput === wantRichInput) return;
-
-		if (!caretOffsetPinned) {
-			pendingCaretOffset = inputRef?.getCaretOffset() ?? (value ?? '').length;
-		}
-
-		useRichInput = wantRichInput;
-		queueCaretRestore();
-	});
-
 	onMount(() => {
 		recordingSupported = isAudioRecordingSupported();
 		micUnavailableReason = microphoneUnavailableReason();
@@ -273,10 +228,6 @@
 		inputRef?.focus();
 	}

-	export function resetTextareaHeight() {
-		inputRef?.resetHeight();
-	}
-
 	export function openWorkingDirectoryPicker() {
 		pickers.handleWorkingDirectoryOpen();
 	}
@@ -317,8 +268,10 @@

 	function handleKeydown(event: KeyboardEvent) {
 		// Pickers consume navigation/escape keys first; when consumed, skip
-		// the enter-to-submit logic below.
+		// the enter-to-submit logic below and keep the key from the editor.
 		if (pickers.handleKeydown(event)) {
+			event.preventDefault();
+
 			return;
 		}

@@ -326,12 +279,11 @@
 			const isModifier = event.ctrlKey || event.metaKey;
 			const sendOnEnter = currentConfig.sendOnEnter !== false;

-			// Caret inside a fenced code block (closed, or still open
-			// while being typed): Enter adds a line, never submits. The
-			// rich chat form input consumes this case locally; this gate
-			// covers the plain textarea, where skipping submit lets the
-			// native newline through.
-			if (!isModifier && isOffsetInCodeBlock(value ?? '', inputRef?.getCaretOffset() ?? 0)) {
+			// Caret inside a code block (``` or ~~~ fenced, closed or
+			// still open while being typed, or indented): Enter adds a
+			// line, never submits - skipping submit lets the editor's own
+			// newline through.
+			if (!isModifier && inputRef?.isCaretInCodeBlock()) {
 				return;
 			}

@@ -522,20 +474,8 @@

 		if (!built) return;

-		// Pin the post-insertion caret BEFORE the swap effect runs;
-		// otherwise the effect clobbers it with the textarea's selection
-		// at promotion time (browser-dependent: usually reset to 0).
-		pendingCaretOffset = built.caretOffset;
-		caretOffsetPinned = true;
-
-		value = built.newValue;
-		onValueChange?.(built.newValue);
-
-		// Already in rich chat form input mode: no renderer flip, so the swap
-		// effect's caret restore never runs.
-		if (useRichInput) {
-			queueCaretRestore();
-		}
+		// one undoable edit; the input reports the new value like typing
+		inputRef?.replaceText(built.newValue, built.caretOffset);
 	}

 	async function handleMicClick() {
@@ -647,7 +587,6 @@
 				onKeydown={handleKeydown}
 				onPaste={handlePaste}
 				{placeholder}
-				{useRichInput}
 			/>

 			{#if mcpStore.resources.hasAttachments}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte
index 4a2cc386a..35d4bca07 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte
@@ -1,6 +1,12 @@
 <script lang="ts">
-	import ChatFormInputBasic from './ChatFormInputBasic.svelte';
-	import ChatFormInputRich from './ChatFormInputRich.svelte';
+	import { fileMentionWidgets } from './mention-badge-widget';
+	import { EditorView } from '@codemirror/view';
+	import { MarkdownEditor } from '$lib/components/app';
+	import { SETTINGS_KEYS } from '$lib/constants';
+	import { deviceStore } from '$lib/stores';
+	import { settingsStore } from '$lib/stores/settings/index.svelte';
+	import { toolsStore } from '$lib/stores/tools.svelte';
+	import { onMount } from 'svelte';

 	interface Props {
 		class?: string;
@@ -10,7 +16,6 @@
 		onPaste?: (event: ClipboardEvent) => void;
 		placeholder?: string;
 		value?: string;
-		useRichInput?: boolean;
 	}

 	let {
@@ -20,59 +25,107 @@
 		onKeydown,
 		onPaste,
 		placeholder = 'Ask anything...',
-		useRichInput = false,
 		value = $bindable('')
 	}: Props = $props();

-	let basicRef: ChatFormInputBasic | undefined = $state();
-	let richRef: ChatFormInputRich | undefined = $state();
+	let editor: MarkdownEditor | undefined = $state();

-	// The two renderers share one imperative handle (focus/caret/height), so
-	// the parent can drive whichever variant is mounted through this one.
-	export function getElement() {
-		return useRichInput ? richRef?.getElement() : basicRef?.getElement();
+	let livePreview = $derived(settingsStore.config[SETTINGS_KEYS.LIVE_MARKDOWN_PREVIEW] !== false);
+
+	// Rendered mention badges label paths by these, so the blocks render again on a change
+	let renderKey = $derived(
+		`${settingsStore.config[SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS]}|${toolsStore.serverHome ?? ''}`
+	);
+
+	// The form gets the keys and pastes first: whatever it handles (submit, pickers,
+	// pasted files) never reaches the editor
+	const extensions = [
+		EditorView.domEventHandlers({
+			keydown: (event) => {
+				onKeydown?.(event);
+
+				return event.defaultPrevented;
+			},
+			paste: (event) => {
+				onPaste?.(event);
+
+				return event.defaultPrevented;
+			}
+		})
+	];
+
+	function handleChange(next: string) {
+		value = next;
+		onInput?.();
 	}

+	onMount(() => {
+		if (!deviceStore.isMobile) editor?.focus();
+	});
+
 	export function focus() {
-		if (useRichInput) richRef?.focus();
-		else basicRef?.focus();
-	}
+		if (deviceStore.isMobile) return;

-	export function resetHeight() {
-		if (useRichInput) richRef?.resetHeight();
-		else basicRef?.resetHeight();
+		editor?.focus();
 	}

+	/** Cursor offset in the markdown source. */
 	export function getCaretOffset(): number {
-		return useRichInput ? (richRef?.getCaretOffset() ?? 0) : (basicRef?.getCaretOffset() ?? 0);
+		return editor?.getCaretOffset() ?? 0;
 	}

 	export function setCaretOffset(offset: number) {
-		if (useRichInput) richRef?.setCaretOffset(offset);
-		else basicRef?.setCaretOffset(offset);
+		editor?.focusAt(offset);
+	}
+
+	/** Is the cursor in a code block, where Enter adds a line? */
+	export function isCaretInCodeBlock(): boolean {
+		return editor?.isCaretInCodeBlock() ?? false;
+	}
+
+	/** Replace the text as one undoable edit with the cursor at `caret` (e.g. a picked mention). */
+	export function replaceText(next: string, caret: number) {
+		if (editor) {
+			editor.replaceText(next, caret);
+		} else {
+			handleChange(next);
+		}
 	}
 </script>

-{#if useRichInput}
-	<ChatFormInputRich
-		bind:this={richRef}
-		bind:value
-		class={className}
+<div
+	class={[
+		'chat-form-input mb-0.5 min-h-[2.375rem] flex-1',
+		disabled && 'cursor-not-allowed',
+		className
+	]}
+>
+	<MarkdownEditor
+		bind:this={editor}
 		{disabled}
-		{onInput}
-		{onKeydown}
-		{onPaste}
+		editingWidgets={fileMentionWidgets}
+		{extensions}
+		{livePreview}
+		onChange={handleChange}
 		{placeholder}
+		{renderKey}
+		value={value ?? ''}
 	/>
-{:else}
-	<ChatFormInputBasic
-		bind:this={basicRef}
-		bind:value
-		class={className}
-		{disabled}
-		{onInput}
-		{onKeydown}
-		{onPaste}
-		{placeholder}
-	/>
-{/if}
+</div>
+
+<style>
+	/* Plain text reads exactly like the former textarea */
+	.chat-form-input {
+		line-height: 1.5rem;
+	}
+
+	.chat-form-input :global(.cm-scroller) {
+		max-height: var(--max-message-height);
+		overflow-y: auto;
+	}
+
+	/* Rendered paragraphs and lists keep the line height of the text being typed */
+	.chat-form-input :global(.cm-md-block :is(p, li)) {
+		line-height: inherit;
+	}
+</style>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputBasic.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputBasic.svelte
deleted file mode 100644
index 0cd4516d8..000000000
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputBasic.svelte
+++ /dev/null
@@ -1,82 +0,0 @@
-<script lang="ts">
-	import { deviceStore } from '$lib/stores';
-	import { autoResizeTextarea } from '$lib/utils';
-	import { onMount } from 'svelte';
-
-	interface Props {
-		class?: string;
-		disabled?: boolean;
-		onInput?: () => void;
-		onKeydown?: (event: KeyboardEvent) => void;
-		onPaste?: (event: ClipboardEvent) => void;
-		placeholder?: string;
-		value?: string;
-	}
-
-	let {
-		class: className = '',
-		disabled = false,
-		onInput,
-		onKeydown,
-		onPaste,
-		placeholder = 'Ask anything...',
-		value = $bindable('')
-	}: Props = $props();
-
-	let textareaElement: HTMLTextAreaElement | undefined;
-
-	onMount(() => {
-		if (textareaElement) {
-			autoResizeTextarea(textareaElement);
-			textareaElement.focus({ preventScroll: true });
-		}
-	});
-
-	export function getElement() {
-		return textareaElement;
-	}
-
-	export function focus() {
-		if (deviceStore.isMobile) return;
-
-		textareaElement?.focus({ preventScroll: true });
-	}
-
-	export function resetHeight() {
-		if (textareaElement) {
-			textareaElement.style.height = '1rem';
-		}
-	}
-
-	// Plain-text caret offsets, shared with the rich chat form input variant so
-	// the picker/paste flows can address either renderer through one handle.
-	export function getCaretOffset(): number {
-		if (!textareaElement) return 0;
-
-		return textareaElement.selectionStart ?? textareaElement.value.length;
-	}
-
-	export function setCaretOffset(offset: number) {
-		textareaElement?.setSelectionRange(offset, offset);
-	}
-</script>
-
-<div class="flex-1 {className}">
-	<textarea
-		bind:this={textareaElement}
-		bind:value
-		class={[
-			'text-md min-h-12 w-full resize-none border-0 bg-transparent p-0 leading-6 outline-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0',
-			disabled && 'cursor-not-allowed'
-		]}
-		{disabled}
-		oninput={(event) => {
-			autoResizeTextarea(event.currentTarget);
-			onInput?.();
-		}}
-		onkeydown={onKeydown}
-		onpaste={onPaste}
-		{placeholder}
-		style="max-height: var(--max-message-height);"
-	></textarea>
-</div>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte
deleted file mode 100644
index 70251ea0c..000000000
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte
+++ /dev/null
@@ -1,867 +0,0 @@
-<script lang="ts">
-	import { CODE_BLOCK, CODE_TOKEN_ATTR, UI_DATA_ATTRS } from '$lib/constants';
-	import { BooleanString, ChatFormInputRichTokenKind, ColorMode } from '$lib/enums';
-	import { deviceStore } from '$lib/stores';
-	import type { ChatFormInputRichToken } from '$lib/types';
-	import type { SourceHistoryEntry } from '$lib/utils';
-	import {
-		badgeAwareWordJump,
-		buildFragment,
-		domMatchesTokens,
-		highlightCode,
-		isIMEComposing,
-		isOffsetInCodeBlock,
-		leadingBadgeEdgeOffset,
-		rangeToTextOffset,
-		serializeContent,
-		SourceHistory,
-		stripBlockBoundaryLineBreaks,
-		syncCodeBlockHatches,
-		textOffsetToRange,
-		tokenizeContent
-	} from '$lib/utils';
-	import githubLightCss from 'highlight.js/styles/github.css?inline';
-	import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
-	import { mode } from 'mode-watcher';
-	import { onDestroy, onMount, untrack } from 'svelte';
-
-	interface Props {
-		class?: string;
-		disabled?: boolean;
-		onInput?: () => void;
-		onKeydown?: (event: KeyboardEvent) => void;
-		onPaste?: (event: ClipboardEvent) => void;
-		placeholder?: string;
-		value?: string;
-	}
-
-	let {
-		class: className = '',
-		disabled = false,
-		onInput,
-		onKeydown,
-		onPaste,
-		placeholder = 'Ask anything...',
-		value = $bindable('')
-	}: Props = $props();
-
-	let rootElement: HTMLDivElement | undefined = $state();
-	let lastEmittedValue = '';
-	let isComposing = $state(false);
-
-	// Undo/redo in source space: the imperative token rebuilds destroy the
-	// browser's native undo stack.
-	const history = new SourceHistory();
-
-	// Browsers disagree on what an empty rich chat form input contains (`<br>`,
-	// `<div><br></div>`, or nothing), so emptiness is decided by the
-	// serialized source, not the DOM shape.
-	function syncEmptyState(serialized?: string) {
-		if (!rootElement) return;
-
-		const source = serialized ?? serializeContent(rootElement);
-
-		rootElement.dataset.empty = source.length === 0 ? BooleanString.TRUE : BooleanString.FALSE;
-	}
-
-	function renderTokens(tokens: ChatFormInputRichToken[]) {
-		if (!rootElement) return;
-
-		const caret = rangeToTextOffset(rootElement, safeRange());
-
-		// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
-		rootElement.replaceChildren(buildFragment(tokens));
-
-		syncCodeBlockHatches(rootElement);
-		highlightCodeBlocks(rootElement);
-
-		restoreCaret(caret);
-		resizeHeight();
-		syncEmptyState();
-	}
-
-	// Last highlighted source segment per block element - typing inside
-	// a block re-highlights only when the segment actually changed.
-	const highlightedSegments = new WeakMap<HTMLElement, string>();
-
-	const CODE_BLOCK_OPEN_RE = /^```([^\n`]*)\n/;
-
-	/**
-	 * Apply syntax highlighting to a code block element's CONTENT. The
-	 * fence lines stay plain text, and the blank padding that
-	 * `highlightCode` trims is re-added as plain text, so the element's
-	 * textContent stays byte-exact with the source segment. Replaces
-	 * the element's children - callers restore the caret afterwards.
-	 * Returns false when nothing changed.
-	 */
-	function highlightCodeBlockElement(el: HTMLElement): boolean {
-		const segment = el.textContent ?? '';
-
-		if (highlightedSegments.get(el) === segment) return false;
-
-		const open = CODE_BLOCK_OPEN_RE.exec(segment);
-
-		if (!open) return false;
-
-		const prefix = open[0];
-		const language = open[1].trim().split(/\s+/)[0] ?? '';
-		const content = segment.slice(prefix.length, -3);
-		const leading = content.match(CODE_BLOCK.TRIM_LEADING_PADDING_REGEX)?.[0] ?? '';
-		const trailing = content.match(CODE_BLOCK.TRIM_TRAILING_PADDING_REGEX)?.[0] ?? '';
-		const core = content.slice(leading.length, content.length - trailing.length);
-		// autoDetect off: re-guessing the language on every keystroke
-		// costs ~38ms a call and flickers while typing
-		const html = core ? highlightCode(core, language || 'text', false) : '';
-		const tpl = document.createElement('template');
-
-		tpl.innerHTML = html;
-
-		el.replaceChildren(
-			document.createTextNode(prefix + leading),
-			tpl.content.cloneNode(true),
-			document.createTextNode(trailing + '```')
-		);
-		highlightedSegments.set(el, segment);
-
-		return true;
-	}
-
-	function highlightCodeBlocks(root: HTMLElement) {
-		for (const el of root.querySelectorAll<HTMLElement>(
-			`code[${CODE_TOKEN_ATTR}="${ChatFormInputRichTokenKind.CODE_BLOCK}"]`
-		)) {
-			highlightCodeBlockElement(el);
-		}
-	}
-
-	/**
-	 * Re-highlight the code block the caret sits in after an edit.
-	 * Skipped when the block's segment is unchanged since its last
-	 * highlight, so edits outside blocks cost nothing.
-	 */
-	function rehighlightCaretCodeBlock() {
-		if (!rootElement) return;
-
-		const range = safeRange();
-
-		if (!range) return;
-
-		let node: Node | null = range.startContainer;
-
-		if (node === rootElement) {
-			node = rootElement.childNodes[range.startOffset - 1] ?? null;
-		}
-
-		while (node && node !== rootElement) {
-			if (
-				node instanceof HTMLElement &&
-				node.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
-			) {
-				const caret = rangeToTextOffset(rootElement, range);
-
-				if (highlightCodeBlockElement(node)) {
-					restoreCaret(caret);
-				}
-
-				return;
-			}
-
-			node = node.parentNode;
-		}
-	}
-
-	/**
-	 * Is the caret inside a fenced code block region? Source-level
-	 * (not DOM-level) so the still-OPEN fence counts too: while the
-	 * user is typing a block, no closing ``` exists yet and the
-	 * buffer is plain text with no block element to find. Root-level
-	 * caret positions right at a closed block's edge (escape
-	 * hatches, element boundaries restored by `textOffsetToRange`)
-	 * resolve past the closing fence, so they count as OUTSIDE.
-	 */
-	function caretInCodeBlock(): boolean {
-		if (!rootElement) return false;
-
-		return isOffsetInCodeBlock(
-			serializeContent(rootElement),
-			rangeToTextOffset(rootElement, safeRange())
-		);
-	}
-
-	/**
-	 * hljs theme for the highlighted code blocks. Mirrors
-	 * SyntaxHighlightedCode.svelte: one shared style element
-	 * (deduped via the data attribute) swapped on mode change.
-	 */
-	function loadHighlightTheme(isDark: boolean) {
-		document
-			.querySelectorAll(`style[${UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW}]`)
-			.forEach((s) => s.remove());
-
-		const style = document.createElement('style');
-
-		style.setAttribute(UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW, BooleanString.TRUE);
-		style.textContent = isDark ? githubDarkCss : githubLightCss;
-
-		document.head.appendChild(style);
-	}
-
-	$effect(() => {
-		loadHighlightTheme(mode.current === ColorMode.DARK);
-	});
-
-	function safeRange(): Range | null {
-		if (!rootElement) return null;
-
-		const selection = window.getSelection();
-
-		if (!selection || selection.rangeCount === 0) return null;
-
-		const range = selection.getRangeAt(0);
-
-		if (!rootElement.contains(range.startContainer) || !rootElement.contains(range.endContainer)) {
-			return null;
-		}
-
-		return range;
-	}
-
-	function restoreCaret(offset: number, extend = false) {
-		if (!rootElement) return;
-
-		const target = textOffsetToRange(rootElement, offset);
-		const selection = window.getSelection();
-
-		if (!selection) return;
-
-		if (extend && selection.anchorNode) {
-			selection.setBaseAndExtent(
-				selection.anchorNode,
-				selection.anchorOffset,
-				target.startContainer,
-				target.startOffset
-			);
-
-			return;
-		}
-
-		selection.removeAllRanges();
-		selection.addRange(target);
-	}
-
-	function resizeHeight() {
-		if (!rootElement) return;
-
-		rootElement.style.height = 'auto';
-		rootElement.style.height = `${rootElement.scrollHeight}px`;
-	}
-
-	function recordHistory(newGroup: boolean) {
-		if (!rootElement) return;
-
-		history.push(
-			{ caret: rangeToTextOffset(rootElement, safeRange()), value: lastEmittedValue },
-			Date.now(),
-			newGroup
-		);
-	}
-
-	/**
-	 * Re-emit the current markdown source value to the parent, then
-	 * reconcile the DOM against the token stream: when a code span
-	 * was just completed or broken, the token boundaries no longer
-	 * match the element structure and the DOM is rebuilt (caret
-	 * preserved through the source-offset mapping).
-	 */
-	function processInput(inputType?: string) {
-		if (isComposing || !rootElement) return;
-
-		syncEmptyState();
-		resizeHeight();
-
-		// Shift+Enter right after a code block leaves an all-newline
-		// text node (the fence's separator line plus Chromium's
-		// artificial end-of-buffer line break). Strip both so the caret
-		// lands on the line directly below the block.
-		if (inputType === 'insertLineBreak' || inputType === 'insertParagraph') {
-			const caret = rangeToTextOffset(rootElement, safeRange());
-
-			if (stripBlockBoundaryLineBreaks(rootElement)) {
-				restoreCaret(caret);
-			} else {
-				const source = serializeContent(rootElement);
-
-				let end = caret;
-
-				// the caret must end up after the inserted \n; some browsers
-				// leave it before (stuck at the end of the old line). A
-				// preceding \n means it already sits past the break
-				// (Chromium's artificial trailing newline) - leave it.
-				if (source[end] === '\n' && source[end - 1] !== '\n') {
-					end += 1;
-					restoreCaret(end);
-				}
-
-				// a line break at the buffer end renders only with a second,
-				// artificial trailing \n: a lone trailing \n is collapsed, so
-				// the new line is invisible and the next typed character
-				// consumes it. Append it when missing - unless the trailing
-				// \n doubles as a block's separator line (source ends with
-				// \n\n) or sits inside a block element.
-				let last = rootElement.lastChild;
-
-				while (last && last.nodeName === 'BR') last = last.previousSibling;
-
-				if (
-					end === source.length &&
-					source.endsWith('\n') &&
-					source[source.length - 2] !== '\n' &&
-					last?.nodeType === Node.TEXT_NODE
-				) {
-					// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
-					rootElement.appendChild(document.createTextNode('\n'));
-					restoreCaret(source.length);
-					resizeHeight();
-				}
-			}
-		}
-
-		syncCodeBlockHatches(rootElement);
-
-		const serialized = serializeContent(rootElement);
-
-		syncEmptyState(serialized);
-
-		if (serialized === lastEmittedValue) return;
-
-		// Plain typing/deletes coalesce per time window; structural edits
-		// (paste, newline, cut, autocorrect) start a new undo group.
-		recordHistory(inputType !== 'insertText' && !inputType?.startsWith('deleteContent'));
-
-		lastEmittedValue = serialized;
-		value = serialized;
-
-		// Rebuild when token boundaries shifted (a code span was just
-		// completed or broken) - the browser-owned text nodes cannot
-		// restyle themselves across element boundaries.
-		const tokens = tokenizeContent(serialized);
-
-		if (!domMatchesTokens(rootElement, tokens)) {
-			renderTokens(tokens);
-
-			// The rebuild can re-shape the DOM in a way that changes the
-			// serialization (e.g. Chromium merged trailing text into the
-			// block element and the rebuild splits it back out, which
-			// synthesizes the separator newline) - keep value in sync.
-			const reserialized = serializeContent(rootElement);
-
-			if (reserialized !== serialized) {
-				lastEmittedValue = reserialized;
-				value = reserialized;
-			}
-		} else {
-			rehighlightCaretCodeBlock();
-		}
-
-		onInput?.();
-	}
-
-	function handleInput(event: Event) {
-		processInput((event as InputEvent).inputType);
-	}
-
-	function handleCompositionStart() {
-		isComposing = true;
-	}
-
-	function handleCompositionEnd() {
-		isComposing = false;
-		processInput();
-	}
-
-	/**
-	 * Insert a line break at the caret MANUALLY. Native Shift+Enter at
-	 * the buffer end varies across browsers (a lone trailing \n that the
-	 * renderer collapses, or a <br> that the hatch sync strips), which
-	 * can leave the caret stuck on the old line; splitting the text node
-	 * ourselves keeps the DOM shape - and the caret - deterministic.
-	 * `processInput` then appends the artificial trailing \n when the
-	 * break lands at the buffer end.
-	 */
-	function insertLineBreak() {
-		if (!rootElement) return;
-
-		const range = safeRange();
-
-		if (!range) return;
-
-		if (!range.collapsed) {
-			range.deleteContents();
-		}
-
-		const container = range.startContainer;
-		const offset = range.startOffset;
-		const nl = document.createTextNode('\n');
-
-		// a break at the very end of a code block exits the block (the
-		// new line belongs below it, not inside)
-		let exitBlock: HTMLElement | null = null;
-
-		if (container.nodeType === Node.TEXT_NODE) {
-			let node: Node | null = container.parentNode;
-
-			while (node && node !== rootElement) {
-				if (
-					node instanceof HTMLElement &&
-					node.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
-				) {
-					const tail = document.createRange();
-
-					tail.setStart(container, offset);
-					tail.setEnd(node, node.childNodes.length);
-
-					if (tail.toString().length === 0) exitBlock = node;
-
-					break;
-				}
-
-				node = node.parentNode;
-			}
-		}
-
-		if (exitBlock) {
-			exitBlock.after(nl);
-		} else if (container.nodeType === Node.TEXT_NODE) {
-			const text = container as Text;
-
-			if (offset === 0) {
-				text.before(nl);
-			} else if (offset === text.length) {
-				text.after(nl);
-			} else {
-				text.splitText(offset).before(nl);
-			}
-		} else {
-			container.insertBefore(nl, container.childNodes[offset] ?? null);
-		}
-
-		const selection = window.getSelection();
-		const after = document.createRange();
-
-		after.setStartAfter(nl);
-		after.collapse(true);
-		selection?.removeAllRanges();
-		selection?.addRange(after);
-
-		processInput('insertLineBreak');
-	}
-
-	/**
-	 * Arrow escape to the line BEFORE a leading code block. Native
-	 * caret movement has no position above a buffer-starting block,
-	 * so a transient `<br>` hatch is created on demand: it gives the
-	 * caret a visible line, is consumed by the first character typed
-	 * on it, and is removed again when the caret leaves (see
-	 * handleSelectionChange). Returns true when the caret was moved.
-	 */
-	function moveCaretBeforeLeadingCodeBlock(key: string, extend: boolean): boolean {
-		if (!rootElement) return false;
-
-		// a hatch already exists - native movement handles it
-		if (rootElement.firstChild?.nodeName === 'BR') return false;
-
-		const first = rootElement.firstChild;
-
-		if (
-			!(first instanceof HTMLElement) ||
-			first.getAttribute(CODE_TOKEN_ATTR) !== ChatFormInputRichTokenKind.CODE_BLOCK
-		)
-			return false;
-
-		const range = safeRange();
-
-		if (!range || !range.collapsed) return false;
-
-		// the caret must sit inside the block: on its very first
-		// character for ArrowLeft, anywhere on its first line for
-		// ArrowUp
-		if (!first.contains(range.startContainer)) return false;
-
-		const caret = rangeToTextOffset(rootElement, range);
-
-		if (key === 'ArrowLeft') {
-			if (caret !== 0) return false;
-		} else {
-			const firstLineEnd = (first.textContent ?? '').indexOf('\n');
-
-			if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
-		}
-
-		// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
-		rootElement.prepend(document.createElement('br'));
-		restoreCaret(0, extend);
-
-		return true;
-	}
-
-	/**
-	 * Remove the transient leading hatch once the caret leaves it.
-	 * The hatch only exists to give the caret a line above a leading
-	 * code block; with the caret anywhere else the empty line would
-	 * just be visual noise. Typing on the hatch line consumes it via
-	 * the stale-hatch removal in `syncCodeBlockHatches` instead (the
-	 * new text node takes its place before the block).
-	 */
-	function handleSelectionChange() {
-		if (!rootElement) return;
-
-		const first = rootElement.firstChild;
-
-		if (first?.nodeName !== 'BR') return;
-
-		const second = first.nextSibling;
-
-		if (
-			!(second instanceof HTMLElement) ||
-			second.getAttribute(CODE_TOKEN_ATTR) !== ChatFormInputRichTokenKind.CODE_BLOCK
-		)
-			return;
-
-		const range = safeRange();
-		const onHatch =
-			range !== null && range.startContainer === rootElement && range.startOffset === 0;
-
-		if (!onHatch) {
-			first.remove();
-		}
-	}
-
-	/**
-	 * Undo/redo is replayed from source snapshots (the token rebuilds
-	 * destroy the native undo stack). Arrow keys around badges are
-	 * repaired locally: a badge is a non-editable island, so plain
-	 * ArrowLeft after a leading badge has no native previous position
-	 * and word jumps overshoot it by a word.
-	 *
-	 * Plain Enter inside a fenced code block (closed, or still open
-	 * while being typed) acts as Shift+Enter and adds a line instead of
-	 * submitting. ArrowLeft/ArrowUp at the edge of a leading code block
-	 * create the transient before-block hatch.
-	 */
-	function handleKeydown(event: KeyboardEvent) {
-		const mod = event.ctrlKey || event.metaKey;
-
-		if (mod && !event.altKey && !isComposing && rootElement) {
-			const key = event.key.toLowerCase();
-			const isUndo = key === 'z' && !event.shiftKey;
-			const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
-
-			if (isUndo || isRedo) {
-				event.preventDefault();
-				const current = {
-					caret: rangeToTextOffset(rootElement, safeRange()),
-					value: lastEmittedValue
-				};
-				const entry = isUndo ? history.undo(current) : history.redo(current);
-
-				if (entry) applyHistoryEntry(entry);
-
-				return;
-			}
-		}
-
-		if (
-			event.key === 'Enter' &&
-			event.shiftKey &&
-			!event.ctrlKey &&
-			!event.metaKey &&
-			!event.altKey &&
-			!isIMEComposing(event) &&
-			!disabled &&
-			!caretInCodeBlock() &&
-			safeRange()
-		) {
-			// Own the break outside code blocks: native end-of-buffer
-			// behavior varies across browsers and can leave the caret
-			// stuck on the old line (see insertLineBreak).
-			event.preventDefault();
-			insertLineBreak();
-
-			return;
-		}
-
-		if (
-			event.key === 'Enter' &&
-			!event.shiftKey &&
-			!event.ctrlKey &&
-			!event.metaKey &&
-			!event.altKey &&
-			!isIMEComposing(event) &&
-			caretInCodeBlock()
-		) {
-			// The native plain-Enter path must never run: it splits the
-			// buffer into `<div>` wrappers that `serializeContent` cannot
-			// see. `insertLineBreak` reproduces the Shift+Enter DOM (a `\n`
-			// text node) and fires `input` synchronously, so the usual
-			// re-tokenize/re-highlight follows.
-			event.preventDefault();
-			document.execCommand('insertLineBreak');
-
-			return;
-		}
-
-		if (
-			rootElement &&
-			(event.key === 'ArrowLeft' || event.key === 'ArrowUp') &&
-			!event.altKey &&
-			!event.ctrlKey &&
-			!event.metaKey
-		) {
-			if (moveCaretBeforeLeadingCodeBlock(event.key, event.shiftKey)) {
-				event.preventDefault();
-
-				return;
-			}
-		}
-
-		if (rootElement && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
-			const isWordJump = (event.altKey || event.ctrlKey) && !event.metaKey && !event.shiftKey;
-			const isPlainLeft =
-				event.key === 'ArrowLeft' && !event.altKey && !event.ctrlKey && !event.metaKey;
-
-			if (isWordJump || isPlainLeft) {
-				const source = serializeContent(rootElement);
-				const caret = rangeToTextOffset(rootElement, safeRange());
-				const target = isWordJump
-					? badgeAwareWordJump(source, caret, event.key === 'ArrowRight' ? 'forward' : 'backward')
-					: leadingBadgeEdgeOffset(source, caret);
-
-				if (target !== null) {
-					event.preventDefault();
-					restoreCaret(target, event.shiftKey);
-
-					return;
-				}
-			}
-		}
-
-		onKeydown?.(event);
-	}
-
-	// lastEmittedValue is set before `value` so the sync effect treats the
-	// change as our own and does not re-render.
-	function applyHistoryEntry(entry: SourceHistoryEntry) {
-		if (!rootElement) return;
-
-		renderTokens(tokenizeContent(entry.value));
-		lastEmittedValue = entry.value;
-		value = entry.value;
-		onInput?.();
-		restoreCaret(entry.caret);
-	}
-
-	/**
-	 * Plain-text paste. preventDefault + manual insertText keeps the
-	 * browser from producing stray `<div>` wrappers mid-paste; insertText
-	 * fires `input` synchronously, so `processInput` re-tokenizes the
-	 * buffer and rebuilds when the pasted text carries badge or code
-	 * tokens.
-	 */
-	function handlePasteEvent(event: ClipboardEvent) {
-		const pasted = event.clipboardData?.getData('text/plain');
-
-		if (pasted && pasted.length > 0) {
-			event.preventDefault();
-
-			// Snap a collapsed caret through the offset mapping first: at
-			// element-boundary carets (e.g. right before a badge) Chromium's
-			// insertText can drop the preceding text node's trailing whitespace.
-			const range = safeRange();
-
-			if (rootElement && range && range.collapsed) {
-				restoreCaret(rangeToTextOffset(rootElement, range));
-			}
-
-			document.execCommand('insertText', false, pasted);
-		}
-	}
-
-	// The parent's paste handler runs first and preventDefaults when it
-	// consumes the event (files, quoted prompts, long text).
-	function handlePaste(event: ClipboardEvent) {
-		onPaste?.(event);
-
-		if (!event.defaultPrevented) {
-			handlePasteEvent(event);
-		}
-	}
-
-	// The selection as markdown SOURCE (each badge contributes its full
-	// `[name](file://...)` link), so copy/cut carry raw markdown and
-	// pasting back re-renders the badges. Null for collapsed/outside
-	// selections - native clipboard behavior is fine there.
-	function selectionSourceSlice(): { text: string; range: Range } | null {
-		if (!rootElement) return null;
-
-		const range = safeRange();
-
-		if (!range || range.collapsed) return null;
-
-		const startRange = range.cloneRange();
-
-		startRange.collapse(true);
-
-		const source = serializeContent(rootElement);
-		const start = rangeToTextOffset(rootElement, startRange);
-		const end = rangeToTextOffset(rootElement, range);
-
-		return { range, text: source.slice(start, end) };
-	}
-
-	function handleCopy(event: ClipboardEvent) {
-		const slice = selectionSourceSlice();
-
-		if (!slice) return;
-
-		event.clipboardData?.setData('text/plain', slice.text);
-		event.preventDefault();
-	}
-
-	function handleCut(event: ClipboardEvent) {
-		const slice = selectionSourceSlice();
-
-		if (!slice) return;
-
-		event.clipboardData?.setData('text/plain', slice.text);
-		event.preventDefault();
-
-		// preventDefault suppresses the native deletion, so remove the
-		// selection manually and re-emit.
-		slice.range.deleteContents();
-		processInput('deleteByCut');
-	}
-
-	onMount(() => {
-		// untrack: the DOM is managed manually from input events, so the
-		// initial render must not subscribe to the value.
-		renderTokens(tokenizeContent(untrack(() => value)));
-		lastEmittedValue = untrack(() => value ?? '');
-		resizeHeight();
-		syncEmptyState();
-		document.addEventListener('selectionchange', handleSelectionChange);
-
-		if (!deviceStore.isMobile) {
-			rootElement?.focus({ preventScroll: true });
-		}
-	});
-
-	onDestroy(() => {
-		document.removeEventListener('selectionchange', handleSelectionChange);
-	});
-
-	// External `value` updates. When incoming === lastEmittedValue the
-	// change came from our own input, so leave the DOM alone - the
-	// browser already owns the right shape.
-	$effect(() => {
-		const incoming = value ?? '';
-
-		if (incoming === lastEmittedValue) return;
-
-		recordHistory(true); // external edit (mention insert, clear, ...): own undo step
-		renderTokens(tokenizeContent(incoming));
-		lastEmittedValue = incoming;
-	});
-
-	export function getElement() {
-		return rootElement;
-	}
-
-	export function getCaretOffset(): number {
-		if (!rootElement) return 0;
-
-		return rangeToTextOffset(rootElement, safeRange());
-	}
-
-	// Focus first: `selection.addRange` requires it on some browsers.
-	export function setCaretOffset(offset: number) {
-		if (rootElement && rootElement !== document.activeElement) {
-			rootElement.focus({ preventScroll: true });
-		}
-
-		restoreCaret(offset);
-	}
-
-	export function focus() {
-		if (deviceStore.isMobile) return;
-
-		rootElement?.focus({ preventScroll: true });
-	}
-
-	export function resetHeight() {
-		if (rootElement) {
-			rootElement.style.height = '';
-			resizeHeight();
-		}
-	}
-</script>
-
-<div class="flex-1 {className} mb-0.5">
-	<div
-		bind:this={rootElement}
-		aria-disabled={disabled}
-		aria-multiline="true"
-		aria-placeholder={placeholder}
-		class={[
-			'chat-form-input-rich text-md min-h-12 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 leading-6 outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
-			disabled && 'cursor-not-allowed'
-		]}
-		contenteditable={!disabled}
-		data-placeholder={placeholder}
-		oncompositionend={handleCompositionEnd}
-		oncompositionstart={handleCompositionStart}
-		oncopy={handleCopy}
-		oncut={handleCut}
-		oninput={handleInput}
-		onkeydown={handleKeydown}
-		onpaste={handlePaste}
-		role="textbox"
-		style="max-height: var(--max-message-height);"
-		tabindex={disabled ? -1 : 0}
-	></div>
-</div>
-
-<style>
-	/* pre-wrap is load-bearing: without it Chromium collapses \n in
-	   text nodes and converts them to spaces while typing */
-	.chat-form-input-rich {
-		white-space: pre-wrap;
-	}
-
-	.chat-form-input-rich:global([data-empty='true'])::before {
-		content: attr(data-placeholder);
-		color: var(--muted-foreground);
-		pointer-events: none;
-	}
-
-	/* Inline code - mirrors markdown-content.css */
-	.chat-form-input-rich :global(code[data-code-token='code_inline']) {
-		background: var(--muted);
-		color: var(--muted-foreground);
-		padding: 0.125rem 0.375rem;
-		border-radius: 0.375rem;
-		font-size: 0.875rem;
-	}
-
-	/* Fenced code block - mirrors .code-block-wrapper in markdown-content.css */
-	.chat-form-input-rich :global(code[data-code-token='code_block']) {
-		display: block;
-		margin: 0.25rem 0;
-		padding: 0.75rem 1rem;
-		border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
-		border-radius: 0.75rem;
-		background: var(--code-background);
-		color: var(--code-foreground);
-		font-size: 0.875rem;
-		line-height: 1.3;
-	}
-</style>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/mention-badge-widget.ts b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/mention-badge-widget.ts
new file mode 100644
index 000000000..5f32fa8d2
--- /dev/null
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/mention-badge-widget.ts
@@ -0,0 +1,104 @@
+// File mention badges for the chat form input: in the block being edited, each
+// `[name](file://path)` link shows as the same chip the rendered markdown draws.
+// The link stays in the source untouched; the badge is an atomic stand-in for it.
+
+import type { EditorState, Range } from '@codemirror/state';
+import { Decoration, WidgetType } from '@codemirror/view';
+import { codeRanges } from '$lib/components/app/content/MarkdownEditor/markdown-syntax';
+import { SETTINGS_KEYS } from '$lib/constants';
+import { settingsStore } from '$lib/stores/settings/index.svelte';
+import { toolsStore } from '$lib/stores/tools.svelte';
+import {
+	decodeFileLinkPath,
+	fileMentionLinkRe,
+	getMentionBadgeIconPaths,
+	getMentionBadgeLabel,
+	MENTION_BADGE_CLASSNAME,
+	MENTION_BADGE_ICON_CLASSNAME,
+	MENTION_BADGE_SVG_ATTRIBUTES
+} from '$lib/utils';
+
+const MENTION_RE = new RegExp(fileMentionLinkRe().source, 'g');
+
+class MentionBadgeWidget extends WidgetType {
+	readonly decodedPath: string;
+	readonly label: string;
+
+	constructor(
+		readonly name: string,
+		readonly path: string
+	) {
+		super();
+		this.decodedPath = decodeFileLinkPath(path);
+		this.label = getMentionBadgeLabel(
+			name,
+			this.decodedPath,
+			settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
+			toolsStore.serverHome
+		);
+	}
+
+	// the label follows the mention settings, so a changed setting redraws the badge
+	eq(other: MentionBadgeWidget) {
+		return other.path === this.path && other.label === this.label;
+	}
+
+	toDOM() {
+		const badge = document.createElement('span');
+
+		badge.title = this.decodedPath;
+		badge.className = `${MENTION_BADGE_CLASSNAME} align-middle`;
+
+		const svg = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'svg');
+
+		for (const [attr, value] of Object.entries(MENTION_BADGE_SVG_ATTRIBUTES)) {
+			svg.setAttribute(attr, value);
+		}
+
+		for (const cls of MENTION_BADGE_ICON_CLASSNAME.split(/\s+/).filter(Boolean)) {
+			svg.classList.add(cls);
+		}
+
+		for (const d of getMentionBadgeIconPaths(this.path)) {
+			const path = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'path');
+
+			path.setAttribute('d', d);
+			svg.appendChild(path);
+		}
+
+		const label = document.createElement('span');
+
+		label.classList.add('shrink-0', 'truncate');
+		label.textContent = this.label;
+
+		badge.append(svg, label);
+
+		return badge;
+	}
+}
+
+/**
+ * Badge widgets for the mention links between `from` and `to`. Code is literal: a
+ * mention link in a code block or in inline code stays text.
+ */
+export function fileMentionWidgets(
+	state: EditorState,
+	from: number,
+	to: number
+): Range<Decoration>[] {
+	const code = codeRanges(state, from, to);
+	const widgets: Range<Decoration>[] = [];
+
+	for (const match of state.doc.sliceString(from, to).matchAll(MENTION_RE)) {
+		const start = from + match.index;
+		const end = start + match[0].length;
+
+		if (code.some(([codeFrom, codeTo]) => start < codeTo && end > codeFrom)) continue;
+
+		const widget = new MentionBadgeWidget(match[1], match[2]);
+
+		widgets.push(Decoration.replace({ widget }).range(start, end));
+	}
+
+	return widgets;
+}
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
index e922dc482..6f3a13d45 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
@@ -93,8 +93,6 @@
 		uploadedFiles = [];
 		clearDraft();

-		chatFormRef?.resetTextareaHeight();
-
 		const success = await onSend?.(messageToSend, filesToSend);

 		if (!success) {
diff --git a/tools/ui/src/lib/components/app/chat/index.ts b/tools/ui/src/lib/components/app/chat/index.ts
index 6f0af1a33..0ec32a615 100644
--- a/tools/ui/src/lib/components/app/chat/index.ts
+++ b/tools/ui/src/lib/components/app/chat/index.ts
@@ -120,8 +120,8 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/
  * Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
  *
  * **Architecture:**
- * - Composes ChatFormInput (a plain textarea, or a ChatFormInputRich for
- *   messages with file mention links), ChatFormActions, and ChatFormPickerMcpPrompts
+ * - Composes ChatFormInput (a live-preview markdown editor), ChatFormActions,
+ *   and ChatFormPickerMcpPrompts
  * - Manages file upload state via `uploadedFiles` bindable prop
  * - Integrates with ModelsSelectorDropdown for model selection in router mode
  * - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.)
@@ -133,7 +133,7 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/
  * - Keyboard shortcut `/` triggers MCP prompt picker
  *
  * **Features:**
- * - Auto-resizing textarea with placeholder
+ * - Auto-growing live-preview markdown editor with placeholder
  * - File upload via button dropdown (images/text/PDF), drag-drop, or paste
  * - Audio recording with WAV conversion (when model supports audio)
  * - MCP prompt picker with search and argument forms
@@ -142,8 +142,7 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/
  * - Loading state with stop button, disabled state for errors
  *
  * **Exported API:**
- * - `focus()` - Focus the textarea programmatically
- * - `resetTextareaHeight()` - Reset textarea to default height after submit
+ * - `focus()` - Focus the message editor programmatically
  * - `openModelSelector()` - Open model selection dropdown
  * - `checkModelSelected(): boolean` - Validate model selection, show error if none
  *
@@ -237,11 +236,10 @@ export { default as ChatFormInputFileInputInvisible } from './ChatForm/ChatFormI
 export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte';

 /**
- * The message editor. Renders a plain auto-resizing textarea by default,
- * or a ChatFormInputRich that renders `[name](file://...)` mention links as
- * inline chips (keeping the value as the markdown source string) once a
- * mention link lands in the buffer. The variant is selected via the
- * `useRichInput` prop; both share one imperative handle.
+ * The message editor: a live-preview MarkdownEditor. Plain text reads like a
+ * textarea; markdown blocks render as in chat messages until the cursor enters
+ * them, and `[name](file://...)` mention links show as badges while editing.
+ * The value stays the exact markdown source string.
  */
 export { default as ChatFormInput } from './ChatForm/ChatFormInput/ChatFormInput.svelte';

diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte
index 4d5d7c2b9..52861144b 100644
--- a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte
@@ -1,5 +1,11 @@
 <script lang="ts">
 	import '$lib/styles/katex-custom.scss';
+	import {
+		getDiagramButtonAction,
+		renderMermaidDiagrams,
+		renderSvgDiagrams,
+		toggleDiagramSource
+	} from './markdown-diagrams';
 	import { getMarkdownProcessor, type MarkdownProcessor } from './markdown-processor';
 	import {
 		getCodeInfoFromTarget,
@@ -7,6 +13,7 @@
 		getMdastNodeHash,
 		isAppendMode
 	} from './markdown-utils';
+	import MarkdownSurface from './MarkdownSurface.svelte';
 	import { SAFE_HTML_CONFIG } from './safe-html-config';
 	import {
 		ActionIconCopyToClipboard,
@@ -16,20 +23,12 @@
 	} from '$lib/components/app';
 	import {
 		CODE_BLOCK_CLASS,
-		DIAGRAM_VIEW_MODE_ATTR,
-		DIAGRAM_VIEW_RENDERED,
-		DIAGRAM_VIEW_SOURCE,
 		IMAGE_NOT_ERROR_BOUND_SELECTOR,
 		MARKDOWN_DATA_ATTRS,
 		MERMAID_BLOCK_CLASS,
 		MERMAID_LANGUAGE,
-		MERMAID_RENDERED_ATTR,
-		MERMAID_SYNTAX_ATTR,
-		MERMAID_WRAPPER_CLASS,
 		SETTINGS_KEYS,
-		SVG,
-		TOGGLE_SOURCE_BTN_CLASS,
-		UI_DATA_ATTRS
+		SVG
 	} from '$lib/constants';
 	import { BooleanString, ColorMode, UrlProtocol } from '$lib/enums';
 	import { createStickToBottom } from '$lib/hooks/use-stick-to-bottom.svelte';
@@ -39,6 +38,7 @@
 		copyCodeToClipboard,
 		copyToClipboard,
 		getImageErrorFallbackHtml,
+		loadHighlightTheme,
 		preprocessLaTeX,
 		splitGluedClosingCodeFences
 	} from '$lib/utils';
@@ -47,8 +47,6 @@
 	import { mountSvgShadow } from '$lib/utils/svg-shadow';
 	import DOMPurify from 'dompurify';
 	import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
-	import githubLightCss from 'highlight.js/styles/github.css?inline';
-	import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
 	import type { Root as MdastRoot } from 'mdast';
 	import { mode } from 'mode-watcher';
 	import { onDestroy, tick } from 'svelte';
@@ -155,27 +153,6 @@
 		}
 	}

-	/**
-	 * Loads the appropriate highlight.js theme based on dark/light mode.
-	 * One shared style element for every markdown block, mirroring
-	 * SyntaxHighlightedCode.svelte. The old per-instance copies duplicated the
-	 * full theme CSS once per rendered message, which grows without bound in
-	 * long conversations.
-	 * @param isDark - Whether to load the dark theme (true) or light theme (false)
-	 */
-	function loadHighlightTheme(isDark: boolean) {
-		document
-			.querySelectorAll(`style[${UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW}]`)
-			.forEach((style) => style.remove());
-
-		const style = document.createElement('style');
-
-		style.setAttribute(UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW, BooleanString.TRUE);
-		style.textContent = isDark ? githubDarkCss : githubLightCss;
-
-		document.head.appendChild(style);
-	}
-
 	/**
 	 * Transforms a single MDAST node to HTML string with caching.

@@ -477,107 +454,35 @@
 	 */
 	async function handleMermaidClick(event: MouseEvent) {
 		const target = event.target as HTMLElement;
-		// Toggle a diagram block between its rendered view and its source view.
-		// Shared by mermaid and svg, css drives the visibility from the wrapper mode.
-		const toggleBtn = target.closest(`.${TOGGLE_SOURCE_BTN_CLASS}`);

-		if (toggleBtn) {
+		if (toggleDiagramSource(target)) {
 			event.preventDefault();
 			event.stopPropagation();

-			const wrapper = toggleBtn.closest(`.${MERMAID_WRAPPER_CLASS}, .${SVG.WRAPPER_CLASS}`);
-
-			if (!wrapper) return;
-
-			const isSource = wrapper.getAttribute(DIAGRAM_VIEW_MODE_ATTR) === DIAGRAM_VIEW_SOURCE;
-			const next = isSource ? DIAGRAM_VIEW_RENDERED : DIAGRAM_VIEW_SOURCE;
-
-			wrapper.setAttribute(DIAGRAM_VIEW_MODE_ATTR, next);
-			toggleBtn.setAttribute('aria-pressed', String(!isSource));
-
 			return;
 		}

-		// Check if clicking on copy or preview button in mermaid block
-		const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .copy-code-btn`);
-		const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .preview-code-btn`);
-
-		if (copyBtn || previewBtn) {
-			const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
-
-			if (!wrapper) return;
-
-			const preElement = wrapper.querySelector<HTMLElement>(
-				`pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]`
-			);
-
-			if (!preElement) return;
-
-			const mermaidSyntax = preElement.getAttribute(MERMAID_SYNTAX_ATTR) ?? '';
-
-			if (copyBtn) {
-				event.preventDefault();
-				event.stopPropagation();
-				try {
-					await copyToClipboard(mermaidSyntax);
-				} catch (error) {
-					console.error('Failed to copy mermaid syntax:', error);
-				}
-
-				return;
-			}
-
-			if (previewBtn) {
-				event.preventDefault();
-				event.stopPropagation();
-				const svg = preElement.querySelector('svg');
-
-				if (!svg) return;
-
-				mermaidPreviewSvgHtml = svg.outerHTML;
-				svgPreviewLive = false;
-				mermaidPreviewOpen = true;
-
-				return;
-			}
-		}
-
-		// Check if clicking on copy or preview button in svg block
-		const svgCopyBtn = target.closest(`.${SVG.WRAPPER_CLASS} .copy-code-btn`);
-		const svgPreviewBtn = target.closest(`.${SVG.WRAPPER_CLASS} .preview-code-btn`);
-
-		if (svgCopyBtn || svgPreviewBtn) {
-			const wrapper = target.closest(`.${SVG.WRAPPER_CLASS}`);
+		const action = getDiagramButtonAction(target);

-			if (!wrapper) return;
-
-			const preElement = wrapper.querySelector<HTMLElement>(
-				`pre.${SVG.BLOCK_CLASS}[${SVG.SOURCE_ATTR}]`
-			);
-
-			if (!preElement) return;
+		if (action) {
+			event.preventDefault();
+			event.stopPropagation();

-			if (svgCopyBtn) {
-				event.preventDefault();
-				event.stopPropagation();
+			if ('copy' in action) {
 				try {
-					await copyToClipboard(preElement.getAttribute(SVG.SOURCE_ATTR) ?? '');
+					await copyToClipboard(action.copy);
 				} catch (error) {
-					console.error('Failed to copy svg source:', error);
+					console.error('Failed to copy diagram source:', error);
 				}

 				return;
 			}

-			if (svgPreviewBtn) {
-				event.preventDefault();
-				event.stopPropagation();
-				mermaidPreviewSvgHtml = sanitizeSvg(preElement.getAttribute(SVG.SOURCE_ATTR) ?? '');
-				svgPreviewLive = false;
-				mermaidPreviewOpen = true;
+			mermaidPreviewSvgHtml = action.previewSvg;
+			svgPreviewLive = false;
+			mermaidPreviewOpen = true;

-				return;
-			}
+			return;
 		}

 		// A click on the header chrome targets the action buttons, never the
@@ -631,85 +536,6 @@
 		}
 	}

-	/**
-	 * Renders mermaid diagrams that haven't been rendered yet.
-	 * Called after each markdown content update.
-	 * Marks nodes immediately to prevent duplicate renders during streaming.
-	 * Reads mode.current before await to ensure reactive tracking.
-	 */
-	async function renderMermaidDiagrams() {
-		if (!containerRef) return;
-
-		const nodes = containerRef.querySelectorAll(
-			`pre.${MERMAID_BLOCK_CLASS}:not([${MERMAID_RENDERED_ATTR}])`
-		);
-
-		if (nodes.length === 0) return;
-
-		// Mark nodes immediately to prevent duplicate renders if called again during streaming.
-		// This avoids needing a guard that would block node discovery.
-		nodes.forEach((node) => node.setAttribute(MERMAID_RENDERED_ATTR, BooleanString.TRUE));
-
-		// Read mode before await so Svelte tracks it reactively.
-		const isDark = mode.current === ColorMode.DARK;
-		// lazy load the mermaid dependecy only when needed to reduce bundle size.
-		const { default: mermaid } = await import('mermaid');
-
-		mermaid.initialize({
-			flowchart: {
-				htmlLabels: true,
-				useMaxWidth: false
-			},
-			gantt: {
-				useMaxWidth: false
-			},
-			securityLevel: 'strict',
-			sequence: {
-				useMaxWidth: false
-			},
-			startOnLoad: false,
-			theme: isDark ? 'dark' : 'default'
-		});
-
-		try {
-			await mermaid.run({
-				nodes: Array.from(nodes) as unknown as NodeListOf<HTMLElement>
-			});
-		} catch (error) {
-			console.error('Failed to render mermaid diagram:', error);
-		}
-	}
-
-	/**
-	 * Renders svg diagrams that haven't been rendered yet.
-	 * Sanitizes the source before injecting and marks each node so it renders once.
-	 * An empty sanitize result keeps the raw source as escaped text.
-	 */
-	function renderSvgDiagrams() {
-		if (!containerRef) return;
-
-		const nodes = containerRef.querySelectorAll<HTMLElement>(
-			`pre.${SVG.BLOCK_CLASS}:not([${SVG.RENDERED_ATTR}])`
-		);
-
-		if (nodes.length === 0) return;
-
-		nodes.forEach((node) => {
-			node.setAttribute(SVG.RENDERED_ATTR, BooleanString.TRUE);
-
-			const source = node.getAttribute(SVG.SOURCE_ATTR) ?? node.textContent ?? '';
-			const clean = sanitizeSvg(source);
-
-			if (clean) {
-				node.textContent = '';
-				const host = document.createElement('div');
-
-				node.appendChild(host);
-				mountSvgShadow(host, clean, SVG.INLINE_SHADOW_STYLE);
-			}
-		});
-	}
-
 	/**
 	 * Handles image load errors by replacing the image with a fallback UI.
 	 * Shows a placeholder with a link to open the image in a new tab.
@@ -795,8 +621,8 @@
 		if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) {
 			setupCodeBlockActions();
 			setupImageErrorHandlers();
-			renderMermaidDiagrams();
-			renderSvgDiagrams();
+			renderMermaidDiagrams(containerRef, mode.current === ColorMode.DARK);
+			renderSvgDiagrams(containerRef);
 		}
 	});

@@ -813,11 +639,9 @@
 	});
 </script>

-<!-- svelte-ignore a11y_click_events_have_key_events -->
-<!-- svelte-ignore a11y_no_static_element_interactions -->
-<div
-	bind:this={containerRef}
-	class="markdown-content {className}{settingsStore.config[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS]
+<MarkdownSurface
+	bind:ref={containerRef}
+	class="{className}{settingsStore.config[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS]
 		? ' full-height-code-blocks'
 		: ''}"
 	onclick={handleMermaidClick}
@@ -913,7 +737,7 @@
 			</div>
 		{/if}
 	{/if}
-</div>
+</MarkdownSurface>

 <DialogCodePreview
 	code={previewCode}
@@ -927,7 +751,3 @@
 	open={mermaidPreviewOpen}
 	svgHtml={mermaidPreviewSvgHtml}
 />
-
-<style>
-	@import './markdown-content.css';
-</style>
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownSurface.svelte b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownSurface.svelte
new file mode 100644
index 000000000..e1c294dfc
--- /dev/null
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownSurface.svelte
@@ -0,0 +1,22 @@
+<script lang="ts">
+	import type { Snippet } from 'svelte';
+	import type { HTMLAttributes } from 'svelte/elements';
+
+	interface Props extends HTMLAttributes<HTMLDivElement> {
+		children?: Snippet;
+		class?: string;
+		ref?: HTMLDivElement;
+	}
+
+	let { children, class: className = '', ref = $bindable(), ...rest }: Props = $props();
+</script>
+
+<!-- The one element that carries the markdown styles, shared by MarkdownContent and
+     MarkdownEditor, so the bundle holds a single copy of markdown-content.css -->
+<div bind:this={ref} class="markdown-content {className}" {...rest}>
+	{@render children?.()}
+</div>
+
+<style>
+	@import './markdown-content.css';
+</style>
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css
index d9d18b44c..3a4fff8b0 100644
--- a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css
@@ -5,12 +5,12 @@
 	overflow-wrap: break-word;
 }

-.markdown-block--unstable {
+.markdown-content :global(.markdown-block--unstable) {
 	display: contents;
 }

 /* Streaming code block uses .code-block-wrapper styles */
-.streaming-code-block .streaming-code-pre {
+.markdown-content :global(.streaming-code-block .streaming-code-pre) {
 	background: transparent;
 	padding: 0.5rem;
 	margin: 0;
@@ -261,11 +261,11 @@ div.markdown-user-content :global(.table-wrapper) {
 	max-height: var(--max-message-height);
 }

-.markdown-content .markdown-block:not(:first-child) :global(.code-block-wrapper) {
+.markdown-content :global(.markdown-block:not(:first-child) .code-block-wrapper) {
 	margin-top: 1rem;
 }

-.markdown-content .markdown-block:not(:last-child) :global(.code-block-wrapper) {
+.markdown-content :global(.markdown-block:not(:last-child) .code-block-wrapper) {
 	margin-bottom: 1rem;
 }

@@ -275,7 +275,7 @@ div.markdown-user-content :global(.table-wrapper) {

 /* Scroll container for code blocks (both streaming and completed) */
 .markdown-content :global(.code-block-scroll-container),
-.streaming-code-scroll-container {
+.markdown-content :global(.streaming-code-scroll-container) {
 	min-height: var(--min-message-height);
 	max-height: var(--max-message-height);
 	overflow-y: auto;
@@ -289,7 +289,7 @@ div.markdown-user-content :global(.table-wrapper) {
 }

 .full-height-code-blocks :global(.code-block-scroll-container),
-.full-height-code-blocks .streaming-code-scroll-container {
+.full-height-code-blocks :global(.streaming-code-scroll-container) {
 	max-height: none;
 	overflow-y: visible;
 }
@@ -762,14 +762,14 @@ div.markdown-user-content :global(.table-wrapper) {
 }

 /* Streaming mermaid block - empty preview box */
-.mermaid-streaming-block {
+.markdown-content :global(.mermaid-streaming-block) {
 	min-height: 300px;
 	display: flex;
 	align-items: center;
 	justify-content: center;
 }

-.mermaid-loading-placeholder {
+.markdown-content :global(.mermaid-loading-placeholder) {
 	display: flex;
 	align-items: center;
 	justify-content: center;
@@ -777,7 +777,7 @@ div.markdown-user-content :global(.table-wrapper) {
 	color: var(--muted-foreground);
 }

-.mermaid-loading-text {
+.markdown-content :global(.mermaid-loading-text) {
 	font-size: 0.875rem;
 	font-style: italic;
 }
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-diagrams.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-diagrams.ts
new file mode 100644
index 000000000..e7da4c481
--- /dev/null
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-diagrams.ts
@@ -0,0 +1,144 @@
+// Diagram blocks (mermaid, svg) in rendered markdown: drawing them after the HTML is in
+// the page, and the actions of their header buttons. Shared by MarkdownContent and the
+// live preview of MarkdownEditor, so a diagram looks and behaves the same in both.
+
+import {
+	DIAGRAM_VIEW_MODE_ATTR,
+	DIAGRAM_VIEW_RENDERED,
+	DIAGRAM_VIEW_SOURCE,
+	MERMAID_BLOCK_CLASS,
+	MERMAID_RENDERED_ATTR,
+	MERMAID_SYNTAX_ATTR,
+	MERMAID_WRAPPER_CLASS,
+	SVG,
+	TOGGLE_SOURCE_BTN_CLASS
+} from '$lib/constants';
+import { BooleanString } from '$lib/enums';
+import { sanitizeSvg } from '$lib/utils/sanitize-svg';
+import { mountSvgShadow } from '$lib/utils/svg-shadow';
+
+export type DiagramAction = { copy: string } | { previewSvg: string };
+
+/**
+ * Renders the mermaid diagrams in `container` that haven't been rendered yet.
+ * Marks nodes immediately to prevent duplicate renders during streaming.
+ */
+export async function renderMermaidDiagrams(container: HTMLElement, isDark: boolean) {
+	const nodes = container.querySelectorAll<HTMLElement>(
+		`pre.${MERMAID_BLOCK_CLASS}:not([${MERMAID_RENDERED_ATTR}])`
+	);
+
+	if (nodes.length === 0) return;
+
+	// Mark nodes immediately to prevent duplicate renders if called again during streaming.
+	// This avoids needing a guard that would block node discovery.
+	nodes.forEach((node) => node.setAttribute(MERMAID_RENDERED_ATTR, BooleanString.TRUE));
+
+	// lazy load the mermaid dependecy only when needed to reduce bundle size.
+	const { default: mermaid } = await import('mermaid');
+
+	mermaid.initialize({
+		flowchart: {
+			htmlLabels: true,
+			useMaxWidth: false
+		},
+		gantt: {
+			useMaxWidth: false
+		},
+		securityLevel: 'strict',
+		sequence: {
+			useMaxWidth: false
+		},
+		startOnLoad: false,
+		theme: isDark ? 'dark' : 'default'
+	});
+
+	try {
+		await mermaid.run({ nodes: Array.from(nodes) as unknown as NodeListOf<HTMLElement> });
+	} catch (error) {
+		console.error('Failed to render mermaid diagram:', error);
+	}
+}
+
+/**
+ * Renders the svg diagrams in `container` that haven't been rendered yet.
+ * Sanitizes the source before injecting and marks each node so it renders once.
+ * An empty sanitize result keeps the raw source as escaped text.
+ */
+export function renderSvgDiagrams(container: HTMLElement) {
+	const nodes = container.querySelectorAll<HTMLElement>(
+		`pre.${SVG.BLOCK_CLASS}:not([${SVG.RENDERED_ATTR}])`
+	);
+
+	nodes.forEach((node) => {
+		node.setAttribute(SVG.RENDERED_ATTR, BooleanString.TRUE);
+
+		const source = node.getAttribute(SVG.SOURCE_ATTR) ?? node.textContent ?? '';
+		const clean = sanitizeSvg(source);
+
+		if (clean) {
+			node.textContent = '';
+			const host = document.createElement('div');
+
+			node.appendChild(host);
+			mountSvgShadow(host, clean, SVG.INLINE_SHADOW_STYLE);
+		}
+	});
+}
+
+/**
+ * Toggles a diagram block between its rendered view and its source view when `target`
+ * is its toggle button; css drives the visibility from the wrapper mode.
+ * Returns whether the target was a toggle button.
+ */
+export function toggleDiagramSource(target: HTMLElement): boolean {
+	const toggleBtn = target.closest(`.${TOGGLE_SOURCE_BTN_CLASS}`);
+
+	if (!toggleBtn) return false;
+
+	const wrapper = toggleBtn.closest(`.${MERMAID_WRAPPER_CLASS}, .${SVG.WRAPPER_CLASS}`);
+
+	if (!wrapper) return true;
+
+	const isSource = wrapper.getAttribute(DIAGRAM_VIEW_MODE_ATTR) === DIAGRAM_VIEW_SOURCE;
+
+	wrapper.setAttribute(
+		DIAGRAM_VIEW_MODE_ATTR,
+		isSource ? DIAGRAM_VIEW_RENDERED : DIAGRAM_VIEW_SOURCE
+	);
+	toggleBtn.setAttribute('aria-pressed', String(!isSource));
+
+	return true;
+}
+
+/**
+ * What a click on a diagram block's copy or preview button asks for: the text to copy
+ * (mermaid syntax, svg source) or the svg to preview. Null when `target` is neither.
+ */
+export function getDiagramButtonAction(target: HTMLElement): DiagramAction | null {
+	const isCopy = Boolean(target.closest('.copy-code-btn'));
+
+	if (!isCopy && !target.closest('.preview-code-btn')) return null;
+
+	const mermaidPre = target
+		.closest(`.${MERMAID_WRAPPER_CLASS}`)
+		?.querySelector<HTMLElement>(`pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]`);
+
+	if (mermaidPre) {
+		if (isCopy) return { copy: mermaidPre.getAttribute(MERMAID_SYNTAX_ATTR) ?? '' };
+
+		const svg = mermaidPre.querySelector('svg');
+
+		return svg ? { previewSvg: svg.outerHTML } : null;
+	}
+
+	const svgPre = target
+		.closest(`.${SVG.WRAPPER_CLASS}`)
+		?.querySelector<HTMLElement>(`pre.${SVG.BLOCK_CLASS}[${SVG.SOURCE_ATTR}]`);
+
+	if (!svgPre) return null;
+
+	const source = svgPre.getAttribute(SVG.SOURCE_ATTR) ?? '';
+
+	return isCopy ? { copy: source } : { previewSvg: sanitizeSvg(source) };
+}
diff --git a/tools/ui/src/lib/components/app/content/MarkdownEditor/MarkdownEditor.svelte b/tools/ui/src/lib/components/app/content/MarkdownEditor/MarkdownEditor.svelte
new file mode 100644
index 000000000..8571cded1
--- /dev/null
+++ b/tools/ui/src/lib/components/app/content/MarkdownEditor/MarkdownEditor.svelte
@@ -0,0 +1,386 @@
+<script lang="ts">
+	import '$lib/styles/katex-custom.scss';
+	import MarkdownSurface from '../MarkdownContent/MarkdownSurface.svelte';
+	import {
+		markdownLivePreview,
+		type MarkdownLivePreviewOptions,
+		markdownPlainText
+	} from './markdown-live-preview';
+	import { isInCodeBlock } from './markdown-syntax';
+	import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
+	import {
+		insertNewlineContinueMarkup,
+		markdownKeymap,
+		markdownLanguage
+	} from '@codemirror/lang-markdown';
+	import { HighlightStyle, LanguageSupport, syntaxHighlighting } from '@codemirror/language';
+	import {
+		Annotation,
+		Compartment,
+		EditorState,
+		type Extension,
+		Transaction
+	} from '@codemirror/state';
+	import { EditorView, keymap, placeholder as placeholderExt } from '@codemirror/view';
+	import { tags } from '@lezer/highlight';
+	import { DialogCodePreview, DialogMermaidPreview } from '$lib/components/app';
+	import { ColorMode } from '$lib/enums';
+	import { loadHighlightTheme } from '$lib/utils';
+	import { mode } from 'mode-watcher';
+	import { onDestroy, onMount, untrack } from 'svelte';
+
+	interface Props {
+		/** The editor's accessible name; the placeholder when not given. */
+		ariaLabel?: string;
+		class?: string;
+		disabled?: boolean;
+		/** Widgets for the raw text of the block being edited, see MarkdownLivePreviewOptions. */
+		editingWidgets?: MarkdownLivePreviewOptions['editingWidgets'];
+		/** Extra extensions, ahead of the default keymaps (e.g. their own key handling). */
+		extensions?: Extension[];
+		/** Render the markdown as it is written; off, the editor shows the plain text. */
+		livePreview?: boolean;
+		onChange?: (value: string) => void;
+		placeholder?: string;
+		/** What the rendered blocks depend on besides their text, see MarkdownLivePreviewOptions. */
+		renderKey?: string;
+		value: string;
+	}
+
+	let {
+		ariaLabel,
+		class: className = '',
+		disabled = false,
+		editingWidgets,
+		extensions = [],
+		livePreview = true,
+		onChange,
+		placeholder = '',
+		renderKey = '',
+		value
+	}: Props = $props();
+
+	// The raw block being edited keeps the typography of its rendered version
+	// (sizes and weights mirror markdown-content.css)
+	const rawMarkdownStyle = HighlightStyle.define([
+		{ fontSize: '1.875rem', fontWeight: '700', lineHeight: '1.2', tag: tags.heading1 },
+		{ fontSize: '1.5rem', fontWeight: '600', lineHeight: '1.3', tag: tags.heading2 },
+		{ fontSize: '1.25rem', fontWeight: '600', lineHeight: '1.4', tag: tags.heading3 },
+		{ fontSize: '1.125rem', fontWeight: '600', tag: tags.heading4 },
+		{ fontSize: '1rem', fontWeight: '600', tag: tags.heading5 },
+		{ fontSize: '0.875rem', fontWeight: '600', tag: tags.heading6 },
+		{ fontWeight: '600', tag: tags.strong },
+		{ fontStyle: 'italic', tag: tags.emphasis },
+		{ color: 'var(--muted-foreground)', fontStyle: 'italic', tag: tags.quote },
+		{ opacity: '0.7', tag: tags.strikethrough, textDecoration: 'line-through' },
+		{ fontFamily: 'var(--font-mono)', fontSize: '0.875rem', tag: tags.monospace }
+	]);
+
+	// The markdown language alone: markdown() would also bring the html, css and
+	// javascript parsers (for html tag completion), which the editor doesn't use
+	const markdownSupport = new LanguageSupport(markdownLanguage);
+
+	// Props can change after the editor is created: each lives in a compartment
+	const customCompartment = new Compartment();
+	const editableCompartment = new Compartment();
+	const labelsCompartment = new Compartment();
+	const previewCompartment = new Compartment();
+	// Marks the edits that bring the editor in line with `value`, which onChange skips
+	const valueSync = Annotation.define<boolean>();
+
+	let host: HTMLDivElement | undefined = $state();
+	let view: EditorView | undefined;
+	let previewCode = $state('');
+	let previewLanguage = $state('text');
+	let previewOpen = $state(false);
+	let diagramSvg = $state('');
+	let diagramOpen = $state(false);
+	// The document's text as of the last edit or sync, so syncing an unchanged `value`
+	// (each keystroke comes back through onChange) costs no serialization
+	let docText = '';
+
+	function labels(): Extension {
+		return [
+			placeholderExt(placeholder),
+			// the content is a textbox with no name of its own otherwise
+			EditorView.contentAttributes.of({ 'aria-label': ariaLabel || placeholder })
+		];
+	}
+
+	function previewExtension(): Extension {
+		if (!livePreview) return markdownPlainText(editingWidgets);
+
+		return [
+			syntaxHighlighting(rawMarkdownStyle),
+			markdownLivePreview({
+				editingWidgets,
+				onPreviewCode: (code, language) => {
+					previewCode = code;
+					previewLanguage = language;
+					previewOpen = true;
+				},
+				onPreviewDiagram: (svgHtml) => {
+					diagramSvg = svgHtml;
+					diagramOpen = true;
+				},
+				renderKey
+			})
+		];
+	}
+
+	onMount(() => {
+		if (!host) return;
+
+		docText = value;
+		view = new EditorView({
+			parent: host,
+			state: EditorState.create({
+				doc: value,
+				extensions: [
+					customCompartment.of(extensions),
+					history(),
+					// Shift+Enter is a plain line break that still continues lists and quotes
+					keymap.of([{ key: 'Shift-Enter', run: insertNewlineContinueMarkup }]),
+					keymap.of([...markdownKeymap, ...defaultKeymap, ...historyKeymap]),
+					markdownSupport,
+					previewCompartment.of(previewExtension()),
+					editableCompartment.of(EditorView.editable.of(!disabled)),
+					EditorView.lineWrapping,
+					labelsCompartment.of(labels()),
+					// Report every edit but the ones syncing from `value`
+					EditorView.updateListener.of((update) => {
+						const edited = update.transactions.some(
+							(tr) => tr.docChanged && !tr.annotation(valueSync)
+						);
+
+						if (!edited) return;
+
+						docText = update.state.doc.toString();
+						onChange?.(docText);
+					})
+				]
+			})
+		});
+	});
+
+	onDestroy(() => view?.destroy());
+
+	function reconfigure(compartment: Compartment, extension: Extension) {
+		view?.dispatch({ effects: compartment.reconfigure(extension) });
+	}
+
+	/** Runs `apply` when a value `read` gives changes: not on mount, the editor starts with them. */
+	function whenChanged(read: () => unknown[], apply: () => void) {
+		let last = read();
+
+		$effect(() => {
+			const next = read();
+
+			if (next.every((value, index) => value === last[index])) return;
+
+			last = next;
+			untrack(apply);
+		});
+	}
+
+	/** The single change that turns `current` into `next`: their differing middle. */
+	function changeBetween(current: string, next: string) {
+		let from = 0;
+
+		while (from < current.length && from < next.length && current[from] === next[from]) from++;
+
+		let currentTo = current.length;
+		let nextTo = next.length;
+
+		while (currentTo > from && nextTo > from && current[currentTo - 1] === next[nextTo - 1]) {
+			currentTo--;
+			nextTo--;
+		}
+
+		return { from, insert: next.slice(from, nextTo), to: currentTo };
+	}
+
+	/**
+	 * Bring the editor in line with `value` (streamed tokens, a cleared form, ...). Only
+	 * the changed middle is replaced, so the viewport and the selection around it
+	 * survive; these edits stay out of the undo history and out of onChange.
+	 */
+	function syncValue(next: string) {
+		if (!view || next === docText) return;
+
+		const current = docText;
+
+		docText = next;
+		view.dispatch({
+			annotations: [Transaction.addToHistory.of(false), valueSync.of(true)],
+			changes: changeBetween(current, next)
+		});
+	}
+
+	$effect(() => {
+		syncValue(value);
+	});
+
+	whenChanged(
+		() => [extensions],
+		() => reconfigure(customCompartment, extensions)
+	);
+	whenChanged(
+		() => [disabled],
+		() => reconfigure(editableCompartment, EditorView.editable.of(!disabled))
+	);
+	whenChanged(
+		() => [ariaLabel, placeholder],
+		() => reconfigure(labelsCompartment, labels())
+	);
+	// a new preview renders the blocks again when the render key is another one
+	whenChanged(
+		() => [editingWidgets, livePreview, renderKey],
+		() => reconfigure(previewCompartment, previewExtension())
+	);
+
+	$effect(() => {
+		loadHighlightTheme(mode.current === ColorMode.DARK);
+	});
+
+	/** Cursor offset in the markdown source. */
+	export function getCaretOffset(): number {
+		return view?.state.selection.main.head ?? 0;
+	}
+
+	/** Is the cursor in a code block (fenced, closed or still open, or indented)? */
+	export function isCaretInCodeBlock(): boolean {
+		return view ? isInCodeBlock(view.state, view.state.selection.main.head) : false;
+	}
+
+	/** Focus the editor with the cursor at `offset` (default: the end of the text). */
+	export function focusAt(offset?: number) {
+		if (!view) return;
+
+		// the caller may have just changed `value`: place the cursor in the new text
+		syncValue(value);
+
+		const length = view.state.doc.length;
+
+		view.dispatch({ selection: { anchor: Math.min(offset ?? length, length) } });
+		view.focus();
+	}
+
+	/**
+	 * Replace the text with `next` as one edit the user can undo (e.g. a picked mention),
+	 * focused with the cursor at `caret`; onChange reports it like typing.
+	 */
+	export function replaceText(next: string, caret = next.length) {
+		if (!view) return;
+
+		view.dispatch({
+			changes: next === docText ? undefined : changeBetween(docText, next),
+			selection: { anchor: Math.min(caret, next.length) },
+			userEvent: 'input.complete'
+		});
+		view.focus();
+	}
+
+	export function focus() {
+		view?.focus();
+	}
+</script>
+
+<!-- markdown-content: rendered blocks get exactly the chat message styles -->
+<div class={className}>
+	<MarkdownSurface bind:ref={host} />
+</div>
+
+<DialogCodePreview
+	code={previewCode}
+	language={previewLanguage}
+	onOpenChange={(open) => (previewOpen = open)}
+	open={previewOpen}
+/>
+
+<DialogMermaidPreview
+	onOpenChange={(open) => (diagramOpen = open)}
+	open={diagramOpen}
+	svgHtml={diagramSvg}
+/>
+
+<style>
+	div :global(.cm-editor) {
+		background: transparent;
+		color: inherit;
+	}
+
+	div :global(.cm-editor.cm-focused) {
+		outline: none;
+	}
+
+	div :global(.cm-scroller) {
+		font-family: inherit;
+		line-height: inherit;
+	}
+
+	div :global(.cm-content) {
+		padding: 0;
+		caret-color: var(--foreground);
+	}
+
+	div :global(.cm-line) {
+		padding: 0;
+	}
+
+	div :global(.cm-placeholder) {
+		color: var(--muted-foreground);
+		text-shadow: none;
+	}
+
+	/* The markdown img styles must not reach the editor's invisible caret helpers */
+	div :global(img.cm-widgetBuffer) {
+		margin: 0 !important;
+		box-shadow: none;
+		height: 1em;
+		width: 0;
+	}
+
+	/* Rendered block: margins come from the computed gap, so drop the outer ones */
+	div :global(.cm-md-block) {
+		display: flow-root;
+		/* the editor's pre-wrap would turn the newlines between tags into blank lines */
+		white-space: normal;
+		cursor: text;
+	}
+
+	div :global(.cm-md-block > :first-child) {
+		margin-top: 0 !important;
+	}
+
+	div :global(.cm-md-block > :last-child) {
+		margin-bottom: 0 !important;
+	}
+
+	/* Raw code block being edited: the same box as a rendered code block */
+	div :global(.cm-md-raw-code) {
+		padding-inline: 1rem;
+		font-family: var(--font-mono);
+		font-size: 0.875rem;
+		background: var(--code-background);
+		border-inline: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
+	}
+
+	div :global(.cm-md-raw-code-first) {
+		padding-top: 0.75rem;
+		border-top: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
+		border-radius: 0.75rem 0.75rem 0 0;
+	}
+
+	div :global(.cm-md-raw-code-last) {
+		padding-bottom: 0.75rem;
+		border-bottom: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
+		border-radius: 0 0 0.75rem 0.75rem;
+	}
+
+	/* A link reference definition renders to nothing, so it stays raw and muted */
+	div :global(.cm-md-raw-definition) {
+		color: var(--muted-foreground);
+		font-size: 0.875rem;
+	}
+</style>
diff --git a/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-block-widget.ts b/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-block-widget.ts
new file mode 100644
index 000000000..03eac836b
--- /dev/null
+++ b/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-block-widget.ts
@@ -0,0 +1,348 @@
+// A rendered block of the live preview: the HTML that MarkdownContent renders for its
+// source (same pipeline, same code blocks and diagrams), its clicks, and selecting and
+// copying its text.
+
+import { EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
+import {
+	getDiagramButtonAction,
+	renderMermaidDiagrams,
+	renderSvgDiagrams,
+	toggleDiagramSource
+} from '$lib/components/app/content/MarkdownContent/markdown-diagrams';
+import { getMarkdownProcessor } from '$lib/components/app/content/MarkdownContent/markdown-processor';
+import { getCodeInfoFromTarget } from '$lib/components/app/content/MarkdownContent/markdown-utils';
+import { MERMAID_BLOCK_CLASS } from '$lib/constants';
+import { ColorMode } from '$lib/enums';
+import {
+	copyCodeToClipboard,
+	copyToClipboard,
+	preprocessLaTeX,
+	splitGluedClosingCodeFences
+} from '$lib/utils';
+import { mode } from 'mode-watcher';
+
+export interface MarkdownBlockWidgetOptions {
+	onPreviewCode?: (code: string, language: string) => void;
+	onPreviewDiagram?: (svgHtml: string) => void;
+	/**
+	 * Anything outside the source that the rendered HTML depends on (e.g. how mention
+	 * badges label paths): blocks with a different key render again.
+	 */
+	renderKey?: string;
+}
+
+interface Rendered {
+	html: string;
+	/** The block's height when it was last on screen. */
+	height?: number;
+}
+
+const BLOCK_CLASS = 'cm-md-block';
+const DRAG_THRESHOLD_PX = 4;
+const RENDER_CACHE_LIMIT = 500;
+// Height guesses for rendered blocks CodeMirror hasn't measured yet (off screen in a
+// long document), which keep its scrollbar and scroll position steady
+const ESTIMATED_ROW_PX = 24;
+const ESTIMATED_ROW_CHARS = 80;
+/** What each source rendered to, by render key and source; shared by every editor. */
+const rendered = new Map<string, Rendered>();
+/** The key of the render a block's DOM shows. */
+const shownKey = new WeakMap<HTMLElement, string>();
+/** The widget a block's DOM belongs to now; the DOM outlives the widget it was made for. */
+const widgetOfDom = new WeakMap<HTMLElement, MarkdownBlockWidget>();
+
+async function renderMarkdownBlock(source: string, key: string): Promise<string> {
+	const processor = getMarkdownProcessor({});
+	const markdown = preprocessLaTeX(splitGluedClosingCodeFences(source));
+	const html = processor.stringify(await processor.run(processor.parse(markdown)));
+
+	// bounded: the oldest render goes when the cache is full
+	if (rendered.size >= RENDER_CACHE_LIMIT) rendered.delete(rendered.keys().next().value as string);
+
+	rendered.set(key, { html });
+
+	return html;
+}
+
+/** Rows `source` wraps to at a typical width: a cheap stand-in for its height. */
+function estimatedRows(source: string): number {
+	let rows = 0;
+
+	for (const line of source.split('\n'))
+		rows += Math.max(1, Math.ceil(line.length / ESTIMATED_ROW_CHARS));
+
+	return rows;
+}
+
+function caretAt(x: number, y: number): { node: Node; offset: number } | null {
+	if (typeof document.caretPositionFromPoint === 'function') {
+		const position = document.caretPositionFromPoint(x, y);
+
+		return position && { node: position.offsetNode, offset: position.offset };
+	}
+
+	const range = document.caretRangeFromPoint?.(x, y);
+
+	return range ? { node: range.startContainer, offset: range.startOffset } : null;
+}
+
+/** The page's selection when it is a non-empty one that starts in a rendered block of `view`. */
+function renderedSelection(view: EditorView): Range | null {
+	const selection = document.getSelection();
+	const anchor = selection?.anchorNode;
+
+	if (!selection || selection.isCollapsed || !anchor || selection.rangeCount === 0) return null;
+
+	const element = anchor instanceof Element ? anchor : anchor.parentElement;
+	const block = element?.closest(`.${BLOCK_CLASS}`);
+
+	return block && view.dom.contains(block) ? selection.getRangeAt(0) : null;
+}
+
+export class MarkdownBlockWidget extends WidgetType {
+	// For blocks off screen: the height this block had when last on screen, or a guess
+	// from its text before it ever was
+	get estimatedHeight(): number {
+		return rendered.get(this.key)?.height ?? estimatedRows(this.source) * ESTIMATED_ROW_PX;
+	}
+
+	private get key(): string {
+		return `${this.options.renderKey ?? ''}\u0000${this.source}`;
+	}
+
+	constructor(
+		readonly source: string,
+		readonly gapRem: number,
+		readonly options: MarkdownBlockWidgetOptions
+	) {
+		super();
+	}
+
+	// An equal widget keeps its DOM as it is, so only the blocks an edit changes redraw
+	eq(other: MarkdownBlockWidget) {
+		return (
+			other.source === this.source &&
+			other.gapRem === this.gapRem &&
+			other.options.renderKey === this.options.renderKey
+		);
+	}
+
+	// The editor leaves the rendered block alone (its own clicks, the reader's text
+	// selection in it), see onMouseDown and copyRenderedSelection
+	ignoreEvent() {
+		return true;
+	}
+
+	toDOM(view: EditorView) {
+		const dom = document.createElement('div');
+
+		dom.className = `markdown-block ${BLOCK_CLASS}`;
+		dom.addEventListener('mousedown', (event) =>
+			widgetOfDom.get(dom)?.onMouseDown(event, view, dom)
+		);
+		dom.addEventListener('click', (event) => widgetOfDom.get(dom)?.onClick(event));
+		this.fill(dom, view);
+
+		return dom;
+	}
+
+	// Reuse the DOM when only the source changed (e.g. streamed tokens), keeping the
+	// previous render on screen until the new one is ready
+	updateDOM(dom: HTMLElement, view: EditorView) {
+		this.fill(dom, view);
+
+		return true;
+	}
+
+	private fill(dom: HTMLElement, view: EditorView) {
+		const { key } = this;
+
+		widgetOfDom.set(dom, this);
+		dom.style.paddingTop = `${this.gapRem}rem`;
+
+		if (shownKey.get(dom) === key) return;
+
+		const cached = rendered.get(key);
+
+		if (cached) {
+			this.show(dom, view, cached.html, key);
+
+			return;
+		}
+
+		if (!dom.firstChild) {
+			const fallback = document.createElement('p');
+
+			fallback.textContent = this.source;
+			dom.replaceChildren(fallback);
+		}
+
+		void renderMarkdownBlock(this.source, key).then((html) => {
+			// a newer source may have reached this DOM while rendering
+			if (widgetOfDom.get(dom)?.key === key) this.show(dom, view, html, key);
+		});
+	}
+
+	// Measures the block in the editor's measure cycle (no forced layout) for
+	// estimatedHeight; the measure also makes the editor take in the new height
+	private measure(dom: HTMLElement, view: EditorView, key: string) {
+		view.requestMeasure({
+			key: dom,
+			read: () => dom.offsetHeight,
+			write: (height) => {
+				const entry = rendered.get(key);
+
+				if (entry && height > 0) entry.height = height;
+			}
+		});
+	}
+
+	private onClick(event: MouseEvent) {
+		const target = event.target as HTMLElement;
+
+		if (toggleDiagramSource(target)) {
+			event.preventDefault();
+			event.stopPropagation();
+
+			return;
+		}
+
+		const diagramAction = getDiagramButtonAction(target);
+
+		if (diagramAction) {
+			event.preventDefault();
+			event.stopPropagation();
+
+			if ('copy' in diagramAction) {
+				void copyToClipboard(diagramAction.copy);
+			} else {
+				this.options.onPreviewDiagram?.(diagramAction.previewSvg);
+			}
+
+			return;
+		}
+
+		const copyButton = target.closest<HTMLElement>('.copy-code-btn');
+		const previewButton = target.closest<HTMLElement>('.preview-code-btn');
+		const button = copyButton ?? previewButton;
+
+		if (!button) return;
+
+		event.preventDefault();
+		event.stopPropagation();
+
+		const info = getCodeInfoFromTarget(button);
+
+		if (!info) return;
+
+		if (copyButton) {
+			void copyCodeToClipboard(info.rawCode);
+		} else {
+			this.options.onPreviewCode?.(info.rawCode, info.language);
+		}
+	}
+
+	// A click on a rendered block opens it for editing; a drag selects its text, which
+	// the editor (ignoreEvent) and its copy keys (copyRenderedSelection) leave alone
+	private onMouseDown(event: MouseEvent, view: EditorView, dom: HTMLElement) {
+		if (event.button !== 0) return;
+
+		if ((event.target as HTMLElement).closest('a, button, input, summary')) return;
+
+		// no native handling: it would focus the editor and turn a block raw mid-drag
+		event.preventDefault();
+
+		const anchor = caretAt(event.clientX, event.clientY);
+
+		let selecting = false;
+
+		const onMove = (move: MouseEvent) => {
+			const distance = Math.hypot(move.clientX - event.clientX, move.clientY - event.clientY);
+
+			if (!selecting && distance < DRAG_THRESHOLD_PX) return;
+
+			selecting = true;
+
+			const head = caretAt(move.clientX, move.clientY);
+
+			if (anchor && head) {
+				document
+					.getSelection()
+					?.setBaseAndExtent(anchor.node, anchor.offset, head.node, head.offset);
+			}
+		};
+		const onUp = () => {
+			window.removeEventListener('mousemove', onMove);
+			window.removeEventListener('mouseup', onUp);
+
+			if (!selecting) this.openForEditing(event, view, dom);
+		};
+
+		window.addEventListener('mousemove', onMove);
+		window.addEventListener('mouseup', onUp);
+	}
+
+	// Shows the block's raw text with the cursor near the click
+	private openForEditing(event: MouseEvent, view: EditorView, dom: HTMLElement) {
+		const from = view.posAtDOM(dom);
+		const to = from + this.source.length;
+
+		view.dispatch({ selection: { anchor: from } });
+		view.focus();
+
+		// The raw block has a different layout: map the click once the editor has
+		// re-measured it (its height map settles over the next two frames)
+		requestAnimationFrame(() =>
+			requestAnimationFrame(() => {
+				const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
+
+				if (pos !== null && pos >= from && pos <= to) {
+					view.dispatch({ selection: { anchor: pos } });
+				}
+			})
+		);
+	}
+
+	private show(dom: HTMLElement, view: EditorView, html: string, key: string) {
+		dom.innerHTML = html;
+		shownKey.set(dom, key);
+		renderSvgDiagrams(dom);
+		this.measure(dom, view, key);
+
+		// mermaid draws asynchronously and changes the block's height when done
+		if (dom.querySelector(`pre.${MERMAID_BLOCK_CLASS}`)) {
+			void renderMermaidDiagrams(dom, mode.current === ColorMode.DARK).then(() =>
+				this.measure(dom, view, key)
+			);
+		}
+	}
+}
+
+// Copies the text the reader selected in rendered blocks, as text and as HTML. The
+// editor's own copy would act on its selection instead (the cursor's whole line when
+// it is empty), and the browser's stops at the first block, so this runs before both.
+export const copyRenderedSelection = ViewPlugin.define((view) => {
+	const onCopy = (event: ClipboardEvent) => {
+		const range = renderedSelection(view);
+
+		if (!range || !event.clipboardData) return;
+
+		const holder = document.createElement('div');
+
+		holder.append(range.cloneContents());
+		event.clipboardData.setData('text/html', holder.innerHTML);
+		event.clipboardData.setData('text/plain', (holder.textContent ?? '').trim());
+		event.preventDefault();
+		event.stopPropagation();
+	};
+
+	view.dom.addEventListener('copy', onCopy, true);
+	view.dom.addEventListener('cut', onCopy, true);
+
+	return {
+		destroy() {
+			view.dom.removeEventListener('copy', onCopy, true);
+			view.dom.removeEventListener('cut', onCopy, true);
+		}
+	};
+});
diff --git a/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-blocks.ts b/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-blocks.ts
new file mode 100644
index 000000000..8c803abe9
--- /dev/null
+++ b/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-blocks.ts
@@ -0,0 +1,145 @@
+// The top-level markdown blocks of the editor's document, read from its syntax tree:
+// the units the live preview either renders or shows as raw text.
+
+import { syntaxTree, syntaxTreeAvailable } from '@codemirror/language';
+import type { EditorState } from '@codemirror/state';
+
+export interface MarkdownBlock {
+	from: number;
+	/** End of the block's own text. */
+	sourceTo: number;
+	/** End of the block's range, including the blank lines that follow it. */
+	to: number;
+	name: string;
+	/** The collapsed margin between this block and the one before it, in rem. */
+	gapRem: number;
+	/** The block's own text, from..sourceTo. */
+	source: string;
+}
+
+// [top, bottom] margins in rem of each block kind as a chat message renders it (each
+// block in a .markdown-block of its own, so a heading is a first child). CodeMirror
+// can not measure margins on block widgets, so the collapsed gap between two blocks is
+// computed here and applied as padding. markdown-editor-margins.svelte.test.ts checks
+// them against the stylesheet.
+export const BLOCK_MARGINS_REM: Record<string, [number, number]> = {
+	ATXHeading1: [0.5, 0.75],
+	ATXHeading2: [0.5, 0.5],
+	ATXHeading3: [0.5, 0.5],
+	ATXHeading4: [0.5, 0.25],
+	ATXHeading5: [0.5, 0.25],
+	ATXHeading6: [0.5, 0.25],
+	Blockquote: [1.5, 1.5],
+	BulletList: [1, 1],
+	CodeBlock: [1, 1],
+	FencedCode: [1, 1],
+	HorizontalRule: [2, 2],
+	OrderedList: [1, 1],
+	SetextHeading1: [0.5, 0.75],
+	SetextHeading2: [0.5, 0.5],
+	Table: [1, 1]
+};
+export const PARAGRAPH_MARGINS_REM: [number, number] = [1, 1];
+export const CODE_BLOCK_NAMES = new Set(['CodeBlock', 'FencedCode']);
+const MATH_FENCE = '$$';
+
+function blockMargins(name: string): [number, number] {
+	return BLOCK_MARGINS_REM[name] ?? PARAGRAPH_MARGINS_REM;
+}
+
+/** How many lines of `text` open or close a `$$` math block (`$$x$$` on one line does neither). */
+function mathFenceCount(text: string): number {
+	if (!text.includes(MATH_FENCE)) return 0;
+
+	let count = 0;
+
+	for (const line of text.split('\n')) {
+		const trimmed = line.trim();
+
+		if (!trimmed.startsWith(MATH_FENCE)) continue;
+
+		if (trimmed.length > 2 * MATH_FENCE.length && trimmed.endsWith(MATH_FENCE)) continue;
+
+		count++;
+	}
+
+	return count;
+}
+
+/**
+ * A `$$` math block may hold blank lines, which split it into several top-level nodes
+ * for the markdown parser; join them back into one block, as the renderer sees it.
+ */
+function joinMathBlocks(
+	blocks: MarkdownBlock[],
+	join: (first: MarkdownBlock, last: MarkdownBlock) => MarkdownBlock
+): MarkdownBlock[] {
+	const opens = (block: MarkdownBlock) =>
+		!CODE_BLOCK_NAMES.has(block.name) && mathFenceCount(block.source) % 2 === 1;
+	const joined: MarkdownBlock[] = [];
+
+	for (let index = 0; index < blocks.length; index++) {
+		const block = blocks[index];
+
+		if (!opens(block)) {
+			joined.push(block);
+
+			continue;
+		}
+
+		let close = index + 1;
+
+		while (close < blocks.length && !opens(blocks[close])) close++;
+
+		// never closed: the block stays as the parser cut it
+		if (close === blocks.length) {
+			joined.push(block);
+
+			continue;
+		}
+
+		joined.push(join(block, blocks[close]));
+		index = close;
+	}
+
+	return joined;
+}
+
+export function collectBlocks(state: EditorState): MarkdownBlock[] {
+	const { doc } = state;
+	// A long document may not be parsed to its end yet: its last parsed node may be cut
+	// short, so it and the rest stay raw until the parser catches up (the tree change
+	// collects the blocks again)
+	const complete = syntaxTreeAvailable(state, doc.length);
+	// `to` and `gapRem` depend on the neighbours, so they are set once the blocks are known
+	const blockOf = (name: string, from: number, sourceTo: number): MarkdownBlock => ({
+		from,
+		gapRem: 0,
+		name,
+		source: doc.sliceString(from, sourceTo),
+		sourceTo,
+		to: sourceTo
+	});
+
+	let blocks: MarkdownBlock[] = [];
+
+	for (let node = syntaxTree(state).topNode.firstChild; node; node = node.nextSibling) {
+		blocks.push(blockOf(node.name, doc.lineAt(node.from).from, doc.lineAt(node.to).to));
+	}
+
+	if (!complete) blocks.pop();
+
+	blocks = joinMathBlocks(blocks, (first, last) => blockOf(first.name, first.from, last.sourceTo));
+
+	blocks.forEach((block, index) => {
+		const next = blocks[index + 1];
+		const prev = blocks[index - 1];
+
+		if (next) block.to = next.from - 1;
+		else if (complete) block.to = doc.length;
+
+		if (prev) block.gapRem = Math.max(blockMargins(prev.name)[1], blockMargins(block.name)[0]);
+	});
+
+	return blocks;
+}
diff --git a/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-live-preview.ts b/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-live-preview.ts
new file mode 100644
index 000000000..3fc646e22
--- /dev/null
+++ b/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-live-preview.ts
@@ -0,0 +1,209 @@
+// Live preview for MarkdownEditor: every top-level block the cursor is not in is
+// replaced by the HTML that MarkdownContent renders for it (same pipeline, same
+// stylesheet, same code blocks and diagrams), and the block being edited shows its raw
+// text. The document text itself is never touched, so the prompt stays byte-exact.
+
+import {
+	copyRenderedSelection,
+	MarkdownBlockWidget,
+	type MarkdownBlockWidgetOptions
+} from './markdown-block-widget';
+import { CODE_BLOCK_NAMES, collectBlocks, type MarkdownBlock } from './markdown-blocks';
+import { syntaxTree } from '@codemirror/language';
+import {
+	type EditorState,
+	type Extension,
+	type Range,
+	StateEffect,
+	StateField
+} from '@codemirror/state';
+import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
+
+export interface MarkdownLivePreviewOptions extends MarkdownBlockWidgetOptions {
+	/**
+	 * Inline widgets for the raw text of the block being edited (e.g. file mention
+	 * badges), given the state and the block's [from, to) range. They are atomic: the
+	 * cursor steps over them and a delete removes them whole.
+	 */
+	editingWidgets?: (state: EditorState, from: number, to: number) => Range<Decoration>[];
+}
+
+interface LivePreview {
+	atomic: DecorationSet;
+	/** The top-level blocks of the document, collected again only when its text changes. */
+	blocks: MarkdownBlock[];
+	decorations: DecorationSet;
+	/** Which blocks show their raw text; a cursor move within them changes nothing. */
+	raw: boolean[];
+}
+
+// Blocks that stay raw: a link reference definition renders to nothing, and a setext
+// heading would hide its underline (`---` under a line of text is easy to type by accident)
+const ALWAYS_RAW_NAMES = new Set(['LinkReference', 'SetextHeading1', 'SetextHeading2']);
+const setFocused = StateEffect.define<boolean>();
+// Whether the editor has focus. One field for every preview, so a preview that replaces
+// another (new options) starts from the focus the editor has.
+const focusField = StateField.define<boolean>({
+	create: () => false,
+	update(focused, tr) {
+		for (const effect of tr.effects) {
+			if (effect.is(setFocused)) focused = effect.value;
+		}
+
+		return focused;
+	}
+});
+const focusTracking: Extension = [
+	focusField,
+	EditorView.focusChangeEffect.of((_state, focusing) => setFocused.of(focusing))
+];
+
+/** The gap above a raw block, which must stay outside the block's own background. */
+class SpacerWidget extends WidgetType {
+	constructor(readonly gapRem: number) {
+		super();
+	}
+
+	eq(other: SpacerWidget) {
+		return other.gapRem === this.gapRem;
+	}
+
+	toDOM() {
+		const dom = document.createElement('div');
+
+		dom.className = 'cm-md-spacer';
+		dom.style.height = `${this.gapRem}rem`;
+
+		return dom;
+	}
+}
+
+/** The blocks that show their raw text: the ones the selection of a focused editor touches. */
+function rawBlocks(state: EditorState, blocks: MarkdownBlock[]): boolean[] {
+	const focused = state.field(focusField);
+	const { ranges } = state.selection;
+
+	return blocks.map(
+		(block) =>
+			ALWAYS_RAW_NAMES.has(block.name) ||
+			(focused && ranges.some((range) => range.from <= block.to && range.to >= block.from))
+	);
+}
+
+// Every block gets new decorations on every build. A widget equal to the one it
+// replaces keeps its DOM (see MarkdownBlockWidget.eq), so the page only changes where
+// the text did.
+function buildPreview(
+	state: EditorState,
+	blocks: MarkdownBlock[],
+	raw: boolean[],
+	options: MarkdownLivePreviewOptions
+): LivePreview {
+	const decorations: Range<Decoration>[] = [];
+	const atomic: Range<Decoration>[] = [];
+	const { doc } = state;
+
+	for (const [index, block] of blocks.entries()) {
+		const prev = blocks[index - 1];
+		// the blank lines after a raw block show as text already, so they are the gap
+		const gapRem = prev && raw[index - 1] && prev.to > prev.sourceTo ? 0 : block.gapRem;
+
+		if (!raw[index]) {
+			const widget = new MarkdownBlockWidget(block.source, gapRem, options);
+
+			decorations.push(Decoration.replace({ block: true, widget }).range(block.from, block.to));
+
+			continue;
+		}
+
+		if (options.editingWidgets) {
+			atomic.push(...options.editingWidgets(state, block.from, block.sourceTo));
+		}
+
+		if (gapRem > 0) {
+			decorations.push(
+				Decoration.widget({ block: true, side: -1, widget: new SpacerWidget(gapRem) }).range(
+					block.from
+				)
+			);
+		}
+
+		const lineClass = CODE_BLOCK_NAMES.has(block.name)
+			? 'cm-md-raw-code'
+			: block.name === 'LinkReference'
+				? 'cm-md-raw-definition'
+				: null;
+
+		if (!lineClass) continue;
+
+		const first = doc.lineAt(block.from).number;
+		const last = doc.lineAt(block.sourceTo).number;
+
+		for (let number = first; number <= last; number++) {
+			const classes = [lineClass];
+
+			if (number === first) classes.push(`${lineClass}-first`);
+
+			if (number === last) classes.push(`${lineClass}-last`);
+
+			decorations.push(Decoration.line({ class: classes.join(' ') }).range(doc.line(number).from));
+		}
+	}
+
+	return {
+		atomic: Decoration.set(atomic, true),
+		blocks,
+		decorations: Decoration.set(decorations, true),
+		raw
+	};
+}
+
+export function markdownLivePreview(options: MarkdownLivePreviewOptions = {}): Extension {
+	const field = StateField.define<LivePreview>({
+		create(state) {
+			const blocks = collectBlocks(state);
+
+			return buildPreview(state, blocks, rawBlocks(state, blocks), options);
+		},
+		provide: (f) => [
+			EditorView.decorations.from(f, (value) => value.decorations),
+			EditorView.decorations.from(f, (value) => value.atomic),
+			EditorView.atomicRanges.from(f, (value) => () => value.atomic)
+		],
+		update(value, tr) {
+			const reparsed = tr.docChanged || syntaxTree(tr.state) !== syntaxTree(tr.startState);
+			const blocks = reparsed ? collectBlocks(tr.state) : value.blocks;
+			const raw = rawBlocks(tr.state, blocks);
+
+			// a cursor move or a focus change that keeps the same blocks raw changes nothing
+			if (!reparsed && raw.every((isRaw, index) => isRaw === value.raw[index])) return value;
+
+			return buildPreview(tr.state, blocks, raw, options);
+		}
+	});
+
+	return [focusTracking, copyRenderedSelection, field];
+}
+
+/**
+ * The editor without the preview: every block shows its raw text, with the same inline
+ * widgets (see MarkdownLivePreviewOptions.editingWidgets) over the whole document.
+ */
+export function markdownPlainText(
+	editingWidgets?: MarkdownLivePreviewOptions['editingWidgets']
+): Extension {
+	if (!editingWidgets) return [];
+
+	const widgetsOf = (state: EditorState) =>
+		Decoration.set(editingWidgets(state, 0, state.doc.length), true);
+	const field = StateField.define<DecorationSet>({
+		create: widgetsOf,
+		provide: (f) => [
+			EditorView.decorations.from(f),
+			EditorView.atomicRanges.from(f, (value) => () => value)
+		],
+		update: (value, tr) => (tr.docChanged ? widgetsOf(tr.state) : value)
+	});
+
+	return field;
+}
diff --git a/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-syntax.ts b/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-syntax.ts
new file mode 100644
index 000000000..4f24518bf
--- /dev/null
+++ b/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-syntax.ts
@@ -0,0 +1,50 @@
+// Questions about the markdown under a position, answered from the editor's syntax
+// tree, so they agree with what the live preview renders (``` and ~~~ fences,
+// indented code, inline code).
+
+import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
+import type { EditorState } from '@codemirror/state';
+import type { SyntaxNode } from '@lezer/common';
+
+const CODE_NODE_NAMES = new Set(['CodeBlock', 'FencedCode', 'InlineCode']);
+const PARSE_TIMEOUT_MS = 50;
+
+/**
+ * Is `pos` inside a code block, fenced (closed, or still open while it is being
+ * typed) or indented? Right after a closing fence counts as outside.
+ */
+export function isInCodeBlock(state: EditorState, pos: number): boolean {
+	const tree = ensureSyntaxTree(state, pos, PARSE_TIMEOUT_MS) ?? syntaxTree(state);
+
+	for (let node: SyntaxNode | null = tree.resolveInner(pos, -1); node; node = node.parent) {
+		if (node.name === 'CodeBlock') return true;
+
+		if (node.name === 'FencedCode') {
+			const closing = node.lastChild;
+			const closed = closing?.name === 'CodeMark' && closing.from > node.from;
+
+			return !(closed && pos >= node.to);
+		}
+	}
+
+	return false;
+}
+
+/** The [from, to) ranges of code (blocks and inline) between `from` and `to`. */
+export function codeRanges(state: EditorState, from: number, to: number): [number, number][] {
+	const ranges: [number, number][] = [];
+
+	syntaxTree(state).iterate({
+		enter: (node) => {
+			if (!CODE_NODE_NAMES.has(node.name)) return;
+
+			ranges.push([node.from, node.to]);
+
+			return false;
+		},
+		from,
+		to
+	});
+
+	return ranges;
+}
diff --git a/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte b/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte
index 048043954..9d91a03fe 100644
--- a/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte
+++ b/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte
@@ -1,10 +1,7 @@
 <script lang="ts">
-	import { browser } from '$app/environment';
-	import { SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX, UI_DATA_ATTRS } from '$lib/constants';
-	import { BooleanString, ColorMode } from '$lib/enums';
-	import { highlightCode } from '$lib/utils';
-	import githubLightCss from 'highlight.js/styles/github.css?inline';
-	import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
+	import { SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants';
+	import { ColorMode } from '$lib/enums';
+	import { highlightCode, loadHighlightTheme } from '$lib/utils';
 	import { mode } from 'mode-watcher';

 	interface Props {
@@ -35,23 +32,6 @@
 	const SCROLL_BOTTOM_THRESHOLD_PX = SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX;
 	let pendingFrame: number | null = null;

-	function loadHighlightTheme(isDark: boolean) {
-		if (!browser) return;
-
-		const existingThemes = document.querySelectorAll(
-			`style[${UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW}]`
-		);
-
-		existingThemes.forEach((style) => style.remove());
-
-		const style = document.createElement('style');
-
-		style.setAttribute(UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW, BooleanString.TRUE);
-		style.textContent = isDark ? githubDarkCss : githubLightCss;
-
-		document.head.appendChild(style);
-	}
-
 	function isAtBottom(): boolean {
 		if (!scrollEl) return false;

diff --git a/tools/ui/src/lib/components/app/content/index.ts b/tools/ui/src/lib/components/app/content/index.ts
index 9b43fe9cc..9f6bcf0c6 100644
--- a/tools/ui/src/lib/components/app/content/index.ts
+++ b/tools/ui/src/lib/components/app/content/index.ts
@@ -31,6 +31,21 @@
  */
 export { default as MarkdownContent } from './MarkdownContent/MarkdownContent.svelte';

+/**
+ * **MarkdownEditor** - Live-preview markdown editor (CodeMirror)
+ *
+ * Edits markdown source in place: every block the cursor is not in renders
+ * exactly like MarkdownContent, and the block being edited shows its raw text
+ * in the same typography. The document is never rewritten, so the value stays
+ * byte-exact. Used by the chat form input.
+ *
+ * @example
+ * ```svelte
+ * <MarkdownEditor value={text} onChange={(v) => (text = v)} placeholder="Write..." />
+ * ```
+ */
+export { default as MarkdownEditor } from './MarkdownEditor/MarkdownEditor.svelte';
+
 /**
  * **MentionText** - Plain text with file mention badges
  *
diff --git a/tools/ui/src/lib/constants/chat-form.constants.ts b/tools/ui/src/lib/constants/chat-form.constants.ts
index 16bab566c..aaf40226b 100644
--- a/tools/ui/src/lib/constants/chat-form.constants.ts
+++ b/tools/ui/src/lib/constants/chat-form.constants.ts
@@ -1,6 +1,3 @@
-/** Data attribute that tags ChatFormInputRich code spans and blocks. */
-export const CODE_TOKEN_ATTR = 'data-code-token';
-
 export const INITIAL_FILE_SIZE = 0;
 export const PROMPT_CONTENT_SEPARATOR = '\n\n';
 export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
diff --git a/tools/ui/src/lib/constants/mention-badge.constants.ts b/tools/ui/src/lib/constants/mention-badge.constants.ts
index e69211ec8..f0873dc4a 100644
--- a/tools/ui/src/lib/constants/mention-badge.constants.ts
+++ b/tools/ui/src/lib/constants/mention-badge.constants.ts
@@ -1,7 +1,7 @@
 /**
  * Shared visual contract between the two DOM-only badge paths (the
- * ChatFormInputRich tokenizer + the rehype plugin). Svelte cannot be
- * mounted at the per-keystroke tokenizer hot path nor from a hast tree,
+ * chat form input's CodeMirror widget + the rehype plugin). Svelte cannot
+ * be mounted from a CodeMirror widget nor from a hast tree,
  * so both emit the badge with the same class string literal; Tailwind's
  * scanner picks it up in both sources.
  */
@@ -10,19 +10,12 @@ export const MENTION_BADGE_CLASSNAME =

 export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0';

-/** Full `data-*` attribute names that tag ChatFormInputRich mention badges. */
-export const MENTION_BADGE_DATA_ATTRS = {
-	BADGE: 'data-mention-badge',
-	NAME: 'data-mention-name',
-	PATH: 'data-mention-path'
-} as const;
-
 /** Regex flag that makes the mention scanner walk every link in a message instead of the first. */
 export const MENTION_LINK_SCAN_FLAGS = 'g';

 /**
  * SVG attributes shared by the DOM-built and hast-built badge icons.
- * The tokenizer applies them via `setAttribute`, the rehype plugin
+ * The editor widget applies them via `setAttribute`, the rehype plugin
  * spreads them onto the hast `<svg>` `properties`; string values are
  * valid for both.
  */
diff --git a/tools/ui/src/lib/constants/settings-keys.constants.ts b/tools/ui/src/lib/constants/settings-keys.constants.ts
index d553fb448..947bd5cc2 100644
--- a/tools/ui/src/lib/constants/settings-keys.constants.ts
+++ b/tools/ui/src/lib/constants/settings-keys.constants.ts
@@ -34,6 +34,7 @@ export const SETTINGS_KEYS = {
 	FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
 	GROUP_MODELS_BY_FAMILY: 'groupModelsByFamily',
 	JS_SANDBOX_ENABLED: 'jsSandboxEnabled',
+	LIVE_MARKDOWN_PREVIEW: 'liveMarkdownPreview',
 	MAX_IMAGE_RESOLUTION: 'maxImageMPixels',
 	MAX_TOKENS: 'max_tokens',
 	MCP_REQUEST_TIMEOUT_SECONDS: 'mcpRequestTimeoutSeconds',
diff --git a/tools/ui/src/lib/constants/settings.constants.ts b/tools/ui/src/lib/constants/settings.constants.ts
index fc80e94b0..25c708f4f 100644
--- a/tools/ui/src/lib/constants/settings.constants.ts
+++ b/tools/ui/src/lib/constants/settings.constants.ts
@@ -112,6 +112,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
 				label: 'Send message on Enter',
 				type: SettingsFieldType.CHECKBOX
 			},
+			{
+				defaultValue: true,
+				help: 'Render the markdown in the message input as you write it: every line but the one you are on shows as it will look in the conversation. When disabled, the input shows the plain markdown text.',
+				key: SETTINGS_KEYS.LIVE_MARKDOWN_PREVIEW,
+				label: 'Live markdown preview in the message input',
+				type: SettingsFieldType.CHECKBOX
+			},
 			{
 				defaultValue: true,
 				help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
diff --git a/tools/ui/src/lib/enums/chat.enums.ts b/tools/ui/src/lib/enums/chat.enums.ts
index 6bd63b6b7..e1e8ace47 100644
--- a/tools/ui/src/lib/enums/chat.enums.ts
+++ b/tools/ui/src/lib/enums/chat.enums.ts
@@ -91,13 +91,3 @@ export enum FileMentionEntryType {
 	DIRECTORY = 'directory',
 	FILE = 'file'
 }
-
-/**
- * Kinds of tokens the chat-form-input-rich produces.
- */
-export enum ChatFormInputRichTokenKind {
-	BADGE = 'badge',
-	CODE_BLOCK = 'code_block',
-	CODE_INLINE = 'code_inline',
-	TEXT = 'text'
-}
diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts
index 90037a8fc..3527100b4 100644
--- a/tools/ui/src/lib/enums/index.ts
+++ b/tools/ui/src/lib/enums/index.ts
@@ -26,8 +26,7 @@ export {
 	PdfViewMode,
 	ReasoningFormat,
 	ChatFormCommandAction,
-	FileMentionEntryType,
-	ChatFormInputRichTokenKind
+	FileMentionEntryType
 } from './chat.enums';

 export { SessionRecordType } from './conversation-import.enums';
diff --git a/tools/ui/src/lib/types/chat-form-input-rich.d.ts b/tools/ui/src/lib/types/chat-form-input-rich.d.ts
deleted file mode 100644
index 307bc3381..000000000
--- a/tools/ui/src/lib/types/chat-form-input-rich.d.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { ChatFormInputRichTokenKind } from '$lib/enums';
-
-/**
- * A single token produced by the chat-form-input-rich tokenizer:
- * plain text, a file/folder mention badge, or an inline/fenced code span.
- */
-export type ChatFormInputRichToken =
-	| { kind: ChatFormInputRichTokenKind.TEXT; text: string }
-	| { kind: ChatFormInputRichTokenKind.BADGE; name: string; path: string }
-	| { kind: ChatFormInputRichTokenKind.CODE_INLINE; text: string }
-	| { kind: ChatFormInputRichTokenKind.CODE_BLOCK; text: string };
diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts
index ae3d68151..29e331db9 100644
--- a/tools/ui/src/lib/types/index.ts
+++ b/tools/ui/src/lib/types/index.ts
@@ -211,9 +211,6 @@ export type {
 	GlobSearchChildResult
 } from './glob';

-// ChatFormInputRich token types (chat form)
-export type { ChatFormInputRichToken } from './chat-form-input-rich';
-
 // Agentic types
 export type {
 	AgenticConfig,
diff --git a/tools/ui/src/lib/utils/chat-form-input-rich-tokenizer.ts b/tools/ui/src/lib/utils/chat-form-input-rich-tokenizer.ts
deleted file mode 100644
index 626b10b29..000000000
--- a/tools/ui/src/lib/utils/chat-form-input-rich-tokenizer.ts
+++ /dev/null
@@ -1,1074 +0,0 @@
-/**
- * Maps between the chat-form-input-rich's markdown source and the
- * badge/code/text token stream the DOM is built from. A badge is one
- * opaque source contribution (`[name](file://path)`); its own subtree
- * is never walked, and the caret cannot land inside it, so offsets
- * resolve to the nearest badge edge. Code spans (`<code data-code-token>`)
- * are EDITABLE, unlike badges: they carry the full source segment
- * (backtick fences included) as their text, so their textContent
- * serializes verbatim and source offsets map 1:1 to text offsets.
- *
- * The tokenizer emits a flat DOM (text nodes + badges + code spans),
- * but browsers restructure it on Enter (`<div>` line wrappers, `<br>`
- * shapes). Serialization folds those back into `\n` so the source
- * never diverges from what is on screen; both offset mappers
- * understand the same shapes.
- *
- * The newline separating a fenced block from adjacent content is a
- * SOURCE-level concept, never stored in the DOM: the block is
- * display:block, so a leading `\n` in the following text node would
- * render as a phantom empty line. Serialization synthesizes exactly
- * one `\n` at every block boundary and `buildFragment` strips it from
- * text tokens. A text node's own leading/trailing `\n` next to a
- * block is an ADDITIONAL blank line.
- */
-
-import {
-	decodeFileLinkPath,
-	fileMentionLinkRe,
-	getMentionBadgeIconPaths,
-	getMentionBadgeLabel
-} from './mention-badge';
-import {
-	CODE_TOKEN_ATTR,
-	MENTION_BADGE_CLASSNAME,
-	MENTION_BADGE_DATA_ATTRS,
-	MENTION_BADGE_ICON_CLASSNAME,
-	MENTION_BADGE_SVG_ATTRIBUTES,
-	SETTINGS_KEYS
-} from '$lib/constants';
-import { BooleanString, ChatFormInputRichTokenKind } from '$lib/enums';
-import { settingsStore } from '$lib/stores/settings/index.svelte';
-import { toolsStore } from '$lib/stores/tools.svelte';
-import type { ChatFormInputRichToken } from '$lib/types/chat-form-input-rich';
-
-// Block wrappers browsers insert for newlines; each folds back into a
-// single `\n` during serialization.
-const BLOCK_TAG_NAMES = new Set(['DIV', 'P']);
-// `file://` is required so plain URLs stay as text; `)` terminates only
-// when not followed by whitespace or `[` (adjacent badges keep working).
-const MENTION_BADGE_RE = fileMentionLinkRe('g');
-
-function badgeSourceLength(name: string, path: string): number {
-	if (!name || !path) return 0;
-
-	return `[${name}](file://${path})`.length;
-}
-
-/**
- * Recognize complete code spans. Fenced blocks (triple backticks,
- * optional language, possibly multiline) take priority over inline
- * spans (single backticks, single line, non-empty). Only CLOSED
- * spans match: an unclosed fence stays plain text until the closing
- * backticks land. The match includes the fences so the token's
- * source length equals its rendered text length.
- */
-const CODE_SPAN_RE = /(```[\s\S]*?```)|(`[^`\n]+`)/g;
-
-/**
- * Cheap gate check for `ChatForm`: does the buffer contain a
- * complete code span (inline or fenced)? Used to promote the plain
- * textarea to the chat-form-input-rich renderer.
- */
-export function containsCodeSpan(value: string): boolean {
-	CODE_SPAN_RE.lastIndex = 0;
-
-	return CODE_SPAN_RE.test(value);
-}
-
-const CODE_FENCE_RE = /```/g;
-
-/**
- * Is `offset` inside a fenced code block region? Toggle-based: an
- * odd number of ``` fences before the offset means the position
- * sits in block content. Unlike `containsCodeSpan` this also
- * counts the still-OPEN fence while the user is typing a block
- * (no closing ``` yet), so Enter can add a line instead of
- * submitting the message.
- */
-export function isOffsetInCodeBlock(source: string, offset: number): boolean {
-	let inside = false;
-
-	CODE_FENCE_RE.lastIndex = 0;
-
-	let match: RegExpExecArray | null;
-
-	while ((match = CODE_FENCE_RE.exec(source)) !== null) {
-		if (match.index + match[0].length > offset) break;
-
-		inside = !inside;
-	}
-
-	return inside;
-}
-
-/**
- * Tokenize a markdown source value into the segments the
- * chat-form-input-rich will render. Code spans are carved out first
- * (their content is literal - a `file://` link inside backticks
- * must NOT render as a badge), then plain text and badges
- * interleave in the remaining gaps. Any whitespace after a badge
- * stays in a plain text token so the round trip is byte-exact.
- */
-export function tokenizeContent(input: string): ChatFormInputRichToken[] {
-	const tokens: ChatFormInputRichToken[] = [];
-
-	let cursor = 0;
-
-	CODE_SPAN_RE.lastIndex = 0;
-
-	let match: RegExpExecArray | null;
-
-	while ((match = CODE_SPAN_RE.exec(input)) !== null) {
-		const start = match.index;
-
-		if (start > cursor) {
-			pushTextAndBadgeTokens(input.slice(cursor, start), tokens);
-		}
-
-		tokens.push(
-			match[1] !== undefined
-				? { kind: ChatFormInputRichTokenKind.CODE_BLOCK, text: match[1] }
-				: { kind: ChatFormInputRichTokenKind.CODE_INLINE, text: match[2] }
-		);
-		cursor = start + match[0].length;
-	}
-
-	if (cursor < input.length) {
-		pushTextAndBadgeTokens(input.slice(cursor), tokens);
-	}
-
-	return tokens;
-}
-
-/**
- * Tokenize a code-free segment into text and badge tokens.
- */
-function pushTextAndBadgeTokens(input: string, tokens: ChatFormInputRichToken[]) {
-	let cursor = 0;
-
-	MENTION_BADGE_RE.lastIndex = 0;
-
-	let match: RegExpExecArray | null;
-
-	while ((match = MENTION_BADGE_RE.exec(input)) !== null) {
-		const [whole, name, path] = match;
-		const start = match.index;
-
-		if (start > cursor) {
-			tokens.push({ kind: ChatFormInputRichTokenKind.TEXT, text: input.slice(cursor, start) });
-		}
-
-		tokens.push({ kind: ChatFormInputRichTokenKind.BADGE, name, path });
-		cursor = start + whole.length;
-	}
-
-	if (cursor < input.length) {
-		tokens.push({ kind: ChatFormInputRichTokenKind.TEXT, text: input.slice(cursor) });
-	}
-}
-
-function isCodeBlockElement(node: Node | null): node is HTMLElement {
-	return (
-		node instanceof HTMLElement &&
-		node.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
-	);
-}
-
-/**
- * Serialize a chat-form-input-rich subtree back to source. `<br>` and block
- * wrappers the browser inserted for newlines fold back into `\n` (a
- * trailing `<br>` is the browser's caret placeholder, not a newline);
- * any other element is transparent. Code spans serialize their
- * textContent verbatim (fences included). One separator `\n` is
- * synthesized at every fenced-block boundary (the DOM never stores
- * it), and a `<br>` adjacent to a code block is an escape hatch, not
- * a newline.
- */
-export function serializeContent(root: HTMLElement): string {
-	let out = '';
-	let pendingBlockBoundary = false;
-
-	const walk = (parent: Node) => {
-		let first = true; // no source-contributing sibling seen yet
-
-		for (const child of Array.from(parent.childNodes)) {
-			if (child.nodeType === Node.TEXT_NODE) {
-				const text = child.textContent ?? '';
-
-				if (text.length > 0) {
-					if (pendingBlockBoundary) {
-						out += '\n';
-						pendingBlockBoundary = false;
-					}
-
-					out += text;
-					first = false;
-				}
-
-				continue;
-			}
-
-			if (child.nodeType !== Node.ELEMENT_NODE) continue;
-
-			const el = child as HTMLElement;
-
-			if (el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE) {
-				const name = el.getAttribute(MENTION_BADGE_DATA_ATTRS.NAME) ?? '';
-				const path = el.getAttribute(MENTION_BADGE_DATA_ATTRS.PATH) ?? '';
-
-				if (name && path) {
-					if (pendingBlockBoundary) {
-						out += '\n';
-						pendingBlockBoundary = false;
-					}
-
-					out += `[${name}](file://${path})`;
-					first = false;
-				}
-
-				continue;
-			}
-
-			const codeToken = el.getAttribute(CODE_TOKEN_ATTR);
-
-			if (codeToken !== null) {
-				const isBlock = codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
-
-				if (isBlock && (pendingBlockBoundary || !first)) out += '\n';
-
-				pendingBlockBoundary = false;
-				walk(el);
-				first = false;
-
-				if (isBlock) pendingBlockBoundary = true;
-
-				continue;
-			}
-
-			if (el.tagName === 'BR') {
-				const isHatch =
-					isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
-
-				if (!isHatch && el.nextSibling) {
-					if (pendingBlockBoundary) {
-						out += '\n';
-						pendingBlockBoundary = false;
-					}
-
-					out += '\n';
-					first = false;
-				}
-
-				continue;
-			}
-
-			if (BLOCK_TAG_NAMES.has(el.tagName)) {
-				if (pendingBlockBoundary || !first) out += '\n';
-
-				pendingBlockBoundary = false;
-				walk(el);
-				first = false;
-
-				continue;
-			}
-
-			walk(el);
-
-			if (pendingBlockBoundary) first = false;
-		}
-	};
-
-	walk(root);
-
-	return out;
-}
-
-/**
- * Compare the live DOM's non-text structure against a token stream.
- * Only element contributions are compared (badges by name/path, code
- * spans by kind and source segment): text nodes are owned by the
- * browser between rebuilds, so their split/merge state is irrelevant.
- * A mismatch means token boundaries shifted (a code span was just
- * completed or broken) and the DOM needs a rebuild to restyle.
- */
-export function domMatchesTokens(root: HTMLElement, tokens: ChatFormInputRichToken[]): boolean {
-	const expected = tokens.filter((token) => token.kind !== ChatFormInputRichTokenKind.TEXT);
-
-	let index = 0;
-
-	const walk = (parent: Node): boolean => {
-		for (const child of Array.from(parent.childNodes)) {
-			if (child.nodeType !== Node.ELEMENT_NODE) continue;
-
-			const el = child as HTMLElement;
-			const isBadge = el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE;
-			const isCode = el.getAttribute(CODE_TOKEN_ATTR) !== null;
-
-			if (!isBadge && !isCode) {
-				if (!walk(el)) return false;
-
-				continue;
-			}
-
-			const token = expected[index++];
-
-			if (!token) return false;
-
-			if (isBadge) {
-				if (token.kind !== ChatFormInputRichTokenKind.BADGE) return false;
-
-				if (token.name !== (el.getAttribute(MENTION_BADGE_DATA_ATTRS.NAME) ?? '')) return false;
-
-				if (token.path !== (el.getAttribute(MENTION_BADGE_DATA_ATTRS.PATH) ?? '')) return false;
-
-				continue;
-			}
-
-			const codeKind: ChatFormInputRichTokenKind =
-				el.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
-					? ChatFormInputRichTokenKind.CODE_BLOCK
-					: ChatFormInputRichTokenKind.CODE_INLINE;
-
-			if (token.kind !== codeKind) return false;
-
-			if (
-				token.kind === ChatFormInputRichTokenKind.CODE_INLINE ||
-				token.kind === ChatFormInputRichTokenKind.CODE_BLOCK
-			) {
-				if (token.text !== (el.textContent ?? '')) return false;
-			}
-		}
-
-		return true;
-	};
-
-	return walk(root) && index === expected.length;
-}
-
-/**
- * Plain-text offset of a `Range` in the root; null range (selection
- * lost) falls back to buffer length. Walked against the live DOM (not
- * a clone) so a `<br>` keeps its trailing/not-trailing context. Code
- * spans count their full textContent (fences included) and the caret
- * may land inside them; synthesized block boundaries count one `\n`
- * once the caret is past them.
- */
-export function rangeToTextOffset(root: HTMLElement, range: Range | null): number {
-	if (!range) return serializeContent(root).length;
-
-	// A point is at/before the caret iff it falls inside [root start, caret].
-	const pre = range.cloneRange();
-
-	pre.selectNodeContents(root);
-	pre.setEnd(range.endContainer, range.endOffset);
-	const atOrBeforeCaret = (node: Node, offset: number) => pre.comparePoint(node, offset) !== 1;
-
-	let total = 0;
-	let done = false;
-	// DOM position of a code block's synthesized after-boundary, set
-	// when walking past a block and consumed by the next contributing
-	// sibling (counts one `\n` once the caret is past it).
-	let pendingPoint: { node: Node; index: number } | null = null;
-
-	const walk = (parent: Node) => {
-		let first = true;
-
-		for (const child of Array.from(parent.childNodes)) {
-			if (done) return;
-
-			if (child.nodeType === Node.TEXT_NODE) {
-				const text = child.textContent ?? '';
-
-				if (text.length === 0) continue;
-
-				if (pendingPoint) {
-					const { index, node } = pendingPoint;
-
-					pendingPoint = null;
-
-					if (!atOrBeforeCaret(node, index)) {
-						done = true;
-
-						return;
-					}
-
-					total += 1;
-				}
-
-				if (!atOrBeforeCaret(child, 0)) {
-					done = true;
-
-					return;
-				}
-
-				if (range.endContainer === child) {
-					total += range.endOffset;
-					done = true;
-
-					return;
-				}
-
-				total += text.length;
-				first = false;
-
-				continue;
-			}
-
-			if (child.nodeType !== Node.ELEMENT_NODE) continue;
-
-			const el = child as HTMLElement;
-			const parentNode = el.parentNode as Node;
-			const elIndex = Array.prototype.indexOf.call(parentNode.childNodes, el);
-
-			if (pendingPoint) {
-				const { index, node } = pendingPoint;
-
-				pendingPoint = null;
-
-				if (!atOrBeforeCaret(node, index)) {
-					done = true;
-
-					return;
-				}
-
-				total += 1;
-			}
-
-			if (el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE) {
-				const len = badgeSourceLength(
-					el.getAttribute(MENTION_BADGE_DATA_ATTRS.NAME) ?? '',
-					el.getAttribute(MENTION_BADGE_DATA_ATTRS.PATH) ?? ''
-				);
-
-				if (len === 0) continue;
-
-				if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
-					done = true;
-
-					return;
-				}
-
-				total += len;
-				first = false;
-
-				continue;
-			}
-
-			const codeToken = el.getAttribute(CODE_TOKEN_ATTR);
-
-			if (codeToken !== null) {
-				const isBlock = codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
-
-				if (isBlock && !first) {
-					if (!atOrBeforeCaret(el, 0)) {
-						done = true;
-
-						return;
-					}
-
-					total += 1;
-				}
-
-				walk(el);
-				first = false;
-
-				if (isBlock) pendingPoint = { index: elIndex + 1, node: parentNode };
-
-				continue;
-			}
-
-			if (el.tagName === 'BR') {
-				const isHatch =
-					isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
-
-				if (isHatch || !el.nextSibling) continue;
-
-				if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
-					done = true;
-
-					return;
-				}
-
-				total += 1;
-				first = false;
-
-				continue;
-			}
-
-			if (BLOCK_TAG_NAMES.has(el.tagName)) {
-				if (!first) {
-					if (!atOrBeforeCaret(el, 0)) {
-						done = true;
-
-						return;
-					}
-
-					total += 1;
-				}
-
-				walk(el);
-				first = false;
-
-				continue;
-			}
-
-			const before = total;
-
-			walk(el);
-
-			if (total > before) first = false;
-		}
-	};
-
-	walk(root);
-
-	return total;
-}
-
-/**
- * Materialize a token stream into a DOM subtree: text nodes for text
- * tokens, `<span data-mention-badge="true">` elements for badges,
- * `<code data-code-token>` elements for code spans. The badge's class
- * string + inline SVG are shared with the rehype plugin via
- * `$lib/constants`.
- */
-export function buildFragment(tokens: ChatFormInputRichToken[]): DocumentFragment {
-	const fragment = document.createDocumentFragment();
-
-	for (let index = 0; index < tokens.length; index++) {
-		const token = tokens[index];
-
-		if (token.kind === ChatFormInputRichTokenKind.TEXT) {
-			let text = token.text;
-
-			// The separator \n at a fenced-block boundary is synthesized
-			// at serialization time; keeping it in the DOM would render a
-			// phantom empty line next to the block.
-			if (
-				tokens[index - 1]?.kind === ChatFormInputRichTokenKind.CODE_BLOCK &&
-				text.startsWith('\n')
-			) {
-				text = text.slice(1);
-			}
-
-			if (
-				tokens[index + 1]?.kind === ChatFormInputRichTokenKind.CODE_BLOCK &&
-				text.endsWith('\n')
-			) {
-				text = text.slice(0, -1);
-			}
-
-			if (text.length === 0) continue;
-
-			fragment.appendChild(document.createTextNode(text));
-
-			continue;
-		}
-
-		if (
-			token.kind === ChatFormInputRichTokenKind.CODE_INLINE ||
-			token.kind === ChatFormInputRichTokenKind.CODE_BLOCK
-		) {
-			const code = document.createElement('code');
-
-			code.setAttribute(CODE_TOKEN_ATTR, token.kind);
-			code.textContent = token.text;
-			fragment.appendChild(code);
-
-			continue;
-		}
-
-		// A leading badge gets an empty text node prepended: without a real
-		// text position at the buffer start, the spot before the badge is
-		// unreachable via keyboard (ArrowLeft/Home).
-		if (!fragment.lastChild) {
-			fragment.appendChild(document.createTextNode(''));
-		}
-
-		const badge = document.createElement('span');
-
-		badge.setAttribute(MENTION_BADGE_DATA_ATTRS.BADGE, BooleanString.TRUE);
-		badge.setAttribute(MENTION_BADGE_DATA_ATTRS.NAME, token.name);
-		badge.setAttribute(MENTION_BADGE_DATA_ATTRS.PATH, token.path);
-		badge.title = decodeFileLinkPath(token.path);
-		badge.className = MENTION_BADGE_CLASSNAME;
-		badge.contentEditable = 'false';
-
-		const svg = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'svg');
-
-		for (const [attr, value] of Object.entries(MENTION_BADGE_SVG_ATTRIBUTES)) {
-			svg.setAttribute(attr, value);
-		}
-		for (const cls of MENTION_BADGE_ICON_CLASSNAME.split(/\s+/).filter(Boolean)) {
-			svg.classList.add(cls);
-		}
-
-		for (const d of getMentionBadgeIconPaths(token.path)) {
-			const path = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'path');
-
-			path.setAttribute('d', d);
-			svg.appendChild(path);
-		}
-
-		const label = document.createElement('span');
-
-		label.classList.add('shrink-0', 'truncate');
-		label.textContent = getMentionBadgeLabel(
-			token.name,
-			decodeFileLinkPath(token.path),
-			settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
-			toolsStore.serverHome
-		);
-
-		badge.appendChild(svg);
-		badge.appendChild(label);
-		fragment.appendChild(badge);
-	}
-
-	return fragment;
-}
-
-// A sibling provides a reachable caret line when it is an element
-// (badge, another block, an existing hatch) or a non-empty text node.
-function hasLineBeside(node: Node | null): boolean {
-	if (!node) return false;
-
-	if (node.nodeType === Node.ELEMENT_NODE) return true;
-
-	return (node.textContent ?? '') !== '';
-}
-
-/**
- * A code block at the END of the buffer needs an editable line after
- * it: without one the caret cannot leave the block with
- * ArrowDown/ArrowRight. A trailing `<br>` provides that line while
- * staying transparent to serialization (skipped as a hatch), and is
- * removed again once real content takes its place.
- *
- * No hatch is added BEFORE a leading block: the empty line above it
- * is transient and managed by the component (created when the caret
- * arrows onto it, removed when the caret leaves). A transient
- * leading hatch found here is kept; the browser's lone placeholder
- * `<br>` in an empty root is left untouched.
- */
-export function syncCodeBlockHatches(root: HTMLElement) {
-	for (const child of Array.from(root.childNodes)) {
-		if (child.nodeName !== 'BR') continue;
-
-		const isPlaceholder = root.childNodes.length === 1;
-		const isLeadingHatch = !child.previousSibling && isCodeBlockElement(child.nextSibling);
-		const isTrailingHatch = !child.nextSibling && isCodeBlockElement(child.previousSibling);
-
-		// A hatch goes stale once real content takes over its line:
-		// content before a leading hatch, content after a trailing one,
-		// or a text node after the block already providing the line.
-		// A `<br>` with no code block around is a real newline (browser
-		// Shift+Enter shape) and stays.
-		let prevElement = child.previousSibling;
-
-		while (prevElement && prevElement.nodeType !== Node.ELEMENT_NODE) {
-			prevElement = prevElement.previousSibling;
-		}
-		const nearBlock =
-			isCodeBlockElement(child.nextSibling) ||
-			isCodeBlockElement(child.previousSibling) ||
-			isCodeBlockElement(prevElement);
-
-		if (!isPlaceholder && !isLeadingHatch && !isTrailingHatch && nearBlock) {
-			child.remove();
-		}
-	}
-
-	for (const child of Array.from(root.childNodes)) {
-		if (!isCodeBlockElement(child)) continue;
-
-		if (!hasLineBeside(child.nextSibling)) {
-			child.after(document.createElement('br'));
-		}
-	}
-}
-
-/**
- * Strip the separator and artificial newlines from an all-newline text
- * node directly after a fenced block. Chromium's line break at the
- * buffer end inserts an extra artificial `\n` so the new line has
- * height, and the first `\n` after a block doubles as the fence's
- * separator line (synthesized at serialization time). Removing both
- * makes Shift+Enter after a block land the caret on the line directly
- * below the block, like a plain textarea would.
- *
- * Only all-newline text nodes are touched: a node with real content
- * carries intentional blank lines and is left alone. Returns true when
- * the DOM changed.
- */
-export function stripBlockBoundaryLineBreaks(root: HTMLElement): boolean {
-	let changed = false;
-
-	for (const child of Array.from(root.childNodes)) {
-		if (child.nodeType !== Node.TEXT_NODE) continue;
-
-		if (!isCodeBlockElement(child.previousSibling)) continue;
-
-		let text = child.textContent ?? '';
-
-		if (!/^\n{2,}$/.test(text)) continue;
-
-		text = text.slice(1);
-
-		const atBufferEnd = !child.nextSibling || child.nextSibling.nodeName === 'BR';
-
-		if (atBufferEnd) {
-			text = text.slice(0, -1);
-		}
-
-		child.textContent = text;
-		changed = true;
-	}
-
-	return changed;
-}
-
-const WORD_CHAR_RE = /[\p{L}\p{N}_]/u;
-
-/**
- * Word-jump target (Option+Arrow / Ctrl+Arrow) in source offsets, or null
- * when the jump crosses no badge and native word movement should handle
- * it. Badge spans are masked to word characters, so a badge counts as
- * exactly one word.
- */
-export function badgeAwareWordJump(
-	source: string,
-	offset: number,
-	direction: 'forward' | 'backward'
-): number | null {
-	let masked = '';
-
-	const badgeSpans: Array<[number, number]> = [];
-
-	for (const token of tokenizeContent(source)) {
-		const len =
-			token.kind === ChatFormInputRichTokenKind.BADGE
-				? badgeSourceLength(token.name, token.path)
-				: token.text.length;
-
-		if (token.kind === ChatFormInputRichTokenKind.BADGE)
-			badgeSpans.push([masked.length, masked.length + len]);
-
-		masked += token.kind === ChatFormInputRichTokenKind.BADGE ? 'a'.repeat(len) : token.text;
-	}
-
-	if (badgeSpans.length === 0) return null;
-
-	const isWord = (index: number) => WORD_CHAR_RE.test(masked[index]);
-	const spanStartingAt = (index: number) => badgeSpans.find(([start]) => start === index);
-	const spanEndingAt = (index: number) => badgeSpans.find(([, end]) => end === index);
-	const n = masked.length;
-
-	let i = offset;
-
-	if (direction === 'forward') {
-		// Entering a badge completes the word phase at the badge's end edge.
-		if (!(i < n && isWord(i))) {
-			while (i < n && !isWord(i)) i++;
-		}
-
-		while (i < n && isWord(i)) {
-			const span = spanStartingAt(i);
-
-			if (span) {
-				i = span[1];
-
-				break;
-			}
-
-			i++;
-		}
-	} else {
-		if (!(i > 0 && isWord(i - 1))) {
-			while (i > 0 && !isWord(i - 1)) i--;
-		}
-
-		while (i > 0 && isWord(i - 1)) {
-			const span = spanEndingAt(i);
-
-			if (span) {
-				i = span[0];
-
-				break;
-			}
-
-			i--;
-		}
-	}
-
-	if (i === offset) return null;
-
-	const lo = Math.min(offset, i);
-	const hi = Math.max(offset, i);
-
-	return badgeSpans.some(([start, end]) => start < hi && end > lo) ? i : null;
-}
-
-/**
- * 0 when `caret` sits exactly at a leading badge's end edge, null
- * otherwise. Plain ArrowLeft there has no native previous position, so
- * the host snaps the caret to the buffer start manually.
- */
-export function leadingBadgeEdgeOffset(source: string, caret: number): number | null {
-	const [first] = tokenizeContent(source);
-
-	if (!first || first.kind !== ChatFormInputRichTokenKind.BADGE) return null;
-
-	return caret === badgeSourceLength(first.name, first.path) ? 0 : null;
-}
-
-/**
- * Translate a plain-text offset into a degenerate `Range` at that
- * position in the DOM; out-of-range offsets clamp to buffer end (before
- * a trailing escape hatch, not after it). Zero offset lands BEFORE a
- * badge or code span, and an offset exactly at a code span's end lands
- * AFTER it, so typing at a code span's edge extends the surrounding
- * text. Interior code-span offsets land in the element's text.
- * Understands the same block/`<br>` newline shapes as
- * `serializeContent`.
- */
-export function textOffsetToRange(root: HTMLElement, offset: number): Range {
-	const range = document.createRange();
-
-	let remaining = offset;
-	let landed = false;
-	let pendingBlockBoundary = false;
-
-	const land = (node: Node, nodeOffset: number) => {
-		range.setStart(node, nodeOffset);
-		range.setEnd(node, nodeOffset);
-		landed = true;
-	};
-	const walk = (parent: Node) => {
-		let first = true;
-
-		for (const child of Array.from(parent.childNodes)) {
-			if (landed) return;
-
-			if (child.nodeType === Node.TEXT_NODE) {
-				const text = child.textContent ?? '';
-
-				if (text.length === 0) continue;
-
-				if (pendingBlockBoundary) {
-					// The synthesized separator maps to the near edge of the
-					// content that follows the block.
-					pendingBlockBoundary = false;
-
-					if (remaining === 0) {
-						land(child, 0);
-
-						return;
-					}
-
-					remaining -= 1;
-				}
-
-				if (remaining <= text.length) {
-					land(child, remaining);
-
-					return;
-				}
-
-				remaining -= text.length;
-				first = false;
-
-				continue;
-			}
-
-			if (child.nodeType !== Node.ELEMENT_NODE) continue;
-
-			const el = child as HTMLElement;
-
-			if (el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE) {
-				const len = badgeSourceLength(
-					el.getAttribute(MENTION_BADGE_DATA_ATTRS.NAME) ?? '',
-					el.getAttribute(MENTION_BADGE_DATA_ATTRS.PATH) ?? ''
-				);
-
-				if (len === 0) continue;
-
-				if (pendingBlockBoundary) {
-					pendingBlockBoundary = false;
-
-					if (remaining === 0) {
-						range.setStartBefore(el);
-						range.setEndBefore(el);
-						landed = true;
-
-						return;
-					}
-
-					remaining -= 1;
-				}
-
-				if (remaining <= len) {
-					if (remaining === 0) {
-						range.setStartBefore(el);
-						range.setEndBefore(el);
-					} else {
-						range.setStartAfter(el);
-						range.setEndAfter(el);
-					}
-
-					landed = true;
-
-					return;
-				}
-
-				remaining -= len;
-				first = false;
-
-				continue;
-			}
-
-			const codeToken = el.getAttribute(CODE_TOKEN_ATTR);
-
-			if (codeToken !== null) {
-				const isBlock = codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
-
-				if (isBlock && (pendingBlockBoundary || !first)) {
-					pendingBlockBoundary = false;
-
-					if (remaining === 0) {
-						range.setStartBefore(el);
-						range.setEndBefore(el);
-						landed = true;
-
-						return;
-					}
-
-					remaining -= 1;
-				}
-
-				const len = (el.textContent ?? '').length;
-
-				if (remaining === 0) {
-					range.setStartBefore(el);
-					range.setEndBefore(el);
-					landed = true;
-
-					return;
-				}
-
-				if (remaining === len) {
-					range.setStartAfter(el);
-					range.setEndAfter(el);
-					landed = true;
-
-					return;
-				}
-
-				if (remaining < len) {
-					walk(el);
-
-					return;
-				}
-
-				remaining -= len;
-
-				if (isBlock) remaining -= 1;
-
-				first = false;
-
-				continue;
-			}
-
-			if (el.tagName === 'BR') {
-				const isHatch =
-					isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
-
-				if (isHatch) {
-					// Escape hatch: no source length; offset 0 lands before it
-					// so text typed there takes its place.
-					if (remaining === 0) {
-						range.setStartBefore(el);
-						range.setEndBefore(el);
-						landed = true;
-					}
-
-					continue;
-				}
-
-				if (!el.nextSibling) continue;
-
-				if (pendingBlockBoundary) {
-					pendingBlockBoundary = false;
-
-					if (remaining === 0) {
-						range.setStartBefore(el);
-						range.setEndBefore(el);
-						landed = true;
-
-						return;
-					}
-
-					remaining -= 1;
-				}
-
-				if (remaining === 0) {
-					range.setStartBefore(el);
-					range.setEndBefore(el);
-					landed = true;
-
-					return;
-				}
-
-				remaining -= 1;
-				first = false;
-
-				continue;
-			}
-
-			if (BLOCK_TAG_NAMES.has(el.tagName)) {
-				if (pendingBlockBoundary || !first) {
-					pendingBlockBoundary = false;
-
-					if (remaining === 0) {
-						// The boundary newline belongs to the previous line.
-						range.setStartBefore(el);
-						range.setEndBefore(el);
-						landed = true;
-
-						return;
-					}
-
-					remaining -= 1;
-				}
-
-				walk(el);
-				first = false;
-
-				continue;
-			}
-
-			const before = remaining;
-
-			walk(el);
-
-			if (remaining < before) first = false;
-		}
-	};
-
-	walk(root);
-
-	if (!landed) {
-		const last = root.lastChild;
-
-		if (last && last.nodeName === 'BR') {
-			range.setStartBefore(last);
-			range.setEndBefore(last);
-		} else {
-			range.selectNodeContents(root);
-			range.collapse(false);
-		}
-	}
-
-	return range;
-}
diff --git a/tools/ui/src/lib/utils/highlight-theme.ts b/tools/ui/src/lib/utils/highlight-theme.ts
new file mode 100644
index 000000000..43ef760b9
--- /dev/null
+++ b/tools/ui/src/lib/utils/highlight-theme.ts
@@ -0,0 +1,30 @@
+import { UI_DATA_ATTRS } from '$lib/constants';
+import { BooleanString } from '$lib/enums';
+import githubLightCss from 'highlight.js/styles/github.css?inline';
+import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
+
+/**
+ * Loads the highlight.js theme for the given color mode into one shared style
+ * element, so every highlighted code block on the page uses the same copy. Every
+ * message and editor asks for it as it mounts: when the theme is already there,
+ * nothing changes, as swapping the stylesheet restyles the whole page.
+ */
+export function loadHighlightTheme(isDark: boolean): void {
+	if (typeof document === 'undefined') return;
+
+	const css = isDark ? githubDarkCss : githubLightCss;
+	const styles = document.querySelectorAll<HTMLStyleElement>(
+		`style[${UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW}]`
+	);
+
+	if (styles.length === 1 && styles[0].textContent === css) return;
+
+	styles.forEach((style) => style.remove());
+
+	const style = document.createElement('style');
+
+	style.setAttribute(UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW, BooleanString.TRUE);
+	style.textContent = css;
+
+	document.head.appendChild(style);
+}
diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
index fb4ee2006..b51531fad 100644
--- a/tools/ui/src/lib/utils/index.ts
+++ b/tools/ui/src/lib/utils/index.ts
@@ -139,6 +139,7 @@ export { hapticTap } from './haptics';
 export { normalizeFloatingPoint, normalizeNumber } from './precision';

 // Syntax highlighting utilities
+export { loadHighlightTheme } from './highlight-theme';
 export { getLanguageFromFilename } from './syntax-highlight-language';

 // Text file utilities
@@ -238,26 +239,7 @@ export {
 	type CommandDismissSnapshot
 } from './command-token';

-// Tokenization for the ChatFormInputRich (mention links + code spans <-> chip DOM)
-export {
-	tokenizeContent,
-	containsCodeSpan,
-	isOffsetInCodeBlock,
-	domMatchesTokens,
-	syncCodeBlockHatches,
-	stripBlockBoundaryLineBreaks,
-	serializeContent,
-	buildFragment,
-	rangeToTextOffset,
-	textOffsetToRange,
-	badgeAwareWordJump,
-	leadingBadgeEdgeOffset
-} from './chat-form-input-rich-tokenizer';
-
-// Source-space undo/redo history for the ChatFormInputRich
-export { SourceHistory, type SourceHistoryEntry } from './source-history';
-
-// Mention-badge visual contract (used by the ChatFormInputRich / rehype
+// Mention-badge visual contract (used by the chat form input / rehype
 // DOM paths that build the same chip without a Svelte mount)
 export {
 	containsFileMentionLink,
diff --git a/tools/ui/src/lib/utils/source-history.ts b/tools/ui/src/lib/utils/source-history.ts
deleted file mode 100644
index 6228ae7e4..000000000
--- a/tools/ui/src/lib/utils/source-history.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * Source-space undo/redo history for the ChatFormInputRich, whose
- * imperative DOM rebuilds destroy the browser's native undo stack.
- * Entries record the state BEFORE an edit; edits within `groupWindowMs`
- * extend the open group so a typing burst undoes as a unit, while
- * structural edits (paste, mention insert, clear) pass `newGroup`.
- */
-
-export interface SourceHistoryEntry {
-	value: string;
-	caret: number;
-}
-
-export class SourceHistory {
-	private lastPush = 0;
-	private redoStack: SourceHistoryEntry[] = [];
-	private undoStack: SourceHistoryEntry[] = [];
-
-	constructor(
-		private limit = 100,
-		private groupWindowMs = 800
-	) {}
-
-	push(entry: SourceHistoryEntry, now: number, newGroup = false): void {
-		if (newGroup || now - this.lastPush >= this.groupWindowMs || this.undoStack.length === 0) {
-			this.undoStack.push(entry);
-
-			if (this.undoStack.length > this.limit) this.undoStack.shift();
-		}
-
-		this.lastPush = now;
-		this.redoStack = [];
-	}
-
-	redo(current: SourceHistoryEntry): SourceHistoryEntry | null {
-		const entry = this.redoStack.pop();
-
-		if (!entry) return null;
-
-		this.undoStack.push(current);
-		this.lastPush = 0;
-
-		return entry;
-	}
-
-	undo(current: SourceHistoryEntry): SourceHistoryEntry | null {
-		const entry = this.undoStack.pop();
-
-		if (!entry) return null;
-
-		this.redoStack.push(current);
-		this.lastPush = 0; // the next edit after an undo starts a new group
-
-		return entry;
-	}
-}
diff --git a/tools/ui/svelte.config.js b/tools/ui/svelte.config.js
index a56ec0af8..286a45997 100644
--- a/tools/ui/svelte.config.js
+++ b/tools/ui/svelte.config.js
@@ -28,6 +28,10 @@ const config = {

 	// Consult https://svelte.dev/docs/kit/integrations
 	// for more information about preprocessors
+	// mdsvex is for content pages we write ourselves (e.g. release notes reachable from the
+	// chat app): .svx files compiled to components at build time. No page uses it yet.
+	// It can't render chat input or messages, which are typed at runtime and go through
+	// the remark/rehype pipeline in MarkdownContent instead.
 	preprocess: [vitePreprocess(), mdsvex()]
 };

diff --git a/tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts b/tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts
index 3454170b6..6e62ab496 100644
--- a/tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts
+++ b/tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts
@@ -1,11 +1,11 @@
 // Guards the Enter-key contract of the chat form against the
-// fenced-code-block flow: while the caret sits inside a fenced
-// block region - closed, or still OPEN while the user is typing
-// one - plain Enter adds a line instead of submitting the message.
-// The textarea path is covered here end-to-end (the ChatFormInputRich
-// consumes the same case locally; see chat-form-input-rich).
+// code-block flow: while the caret sits inside a code block - ```
+// or ~~~ fenced, closed or still OPEN while the user is typing one -
+// plain Enter adds a line instead of submitting the message.
+// The form gates submit; the editor then adds the line itself.

 import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
+import { EditorView } from '@codemirror/view';
 import { SETTINGS_KEYS } from '$lib/constants';
 import { settingsStore } from '$lib/stores/settings/index.svelte';
 import { tick } from 'svelte';
@@ -13,12 +13,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
 import { userEvent } from 'vitest/browser';
 import { render } from 'vitest-browser-svelte';

-function textareaIn(container: HTMLElement): HTMLTextAreaElement {
-	const el = container.querySelector('textarea');
+function editorIn(container: HTMLElement): EditorView {
+	const el = container.querySelector<HTMLElement>('.cm-content');
+	const view = el && EditorView.findFromDOM(el);

-	if (!(el instanceof HTMLTextAreaElement)) throw new Error('textarea not rendered');
+	if (!view) throw new Error('editor not rendered');

-	return el;
+	return view;
 }

 describe('ChatForm Enter in code blocks', () => {
@@ -32,9 +33,9 @@ describe('ChatForm Enter in code blocks', () => {

 		await tick();

-		const textarea = textareaIn(container);
+		const editor = editorIn(container);

-		await userEvent.click(textarea);
+		await userEvent.click(editor.contentDOM);
 		await userEvent.keyboard('```');
 		await tick();

@@ -42,7 +43,7 @@ describe('ChatForm Enter in code blocks', () => {
 		await tick();

 		expect(onSubmit).not.toHaveBeenCalled();
-		expect(textarea.value).toBe('```\n');
+		expect(editor.state.doc.toString()).toBe('```\n');
 	});

 	it('keeps adding lines while the block stays open', async () => {
@@ -51,9 +52,9 @@ describe('ChatForm Enter in code blocks', () => {

 		await tick();

-		const textarea = textareaIn(container);
+		const editor = editorIn(container);

-		await userEvent.click(textarea);
+		await userEvent.click(editor.contentDOM);
 		await userEvent.keyboard('```js');
 		await tick();

@@ -63,7 +64,47 @@ describe('ChatForm Enter in code blocks', () => {
 		await tick();

 		expect(onSubmit).not.toHaveBeenCalled();
-		expect(textarea.value).toBe('```js\nconst a = 1;\n');
+		expect(editor.state.doc.toString()).toBe('```js\nconst a = 1;\n');
+	});
+
+	it('adds a line in a tilde-fenced block', async () => {
+		const onSubmit = vi.fn();
+		const { container } = render(ChatFormTestWrapper, { onSubmit });
+
+		await tick();
+
+		const editor = editorIn(container);
+
+		await userEvent.click(editor.contentDOM);
+		await userEvent.keyboard('~~~');
+		await tick();
+
+		await userEvent.keyboard('{Enter}');
+		await tick();
+
+		expect(onSubmit).not.toHaveBeenCalled();
+		expect(editor.state.doc.toString()).toBe('~~~\n');
+	});
+
+	it('submits right after a closing fence', async () => {
+		const onSubmit = vi.fn();
+		const { container } = render(ChatFormTestWrapper, { onSubmit });
+
+		await tick();
+
+		const editor = editorIn(container);
+
+		await userEvent.click(editor.contentDOM);
+		editor.dispatch({
+			changes: { from: 0, insert: '```\ncode\n```' },
+			selection: { anchor: '```\ncode\n```'.length }
+		});
+		await tick();
+
+		await userEvent.keyboard('{Enter}');
+		await tick();
+
+		expect(onSubmit).toHaveBeenCalledTimes(1);
 	});

 	it('submits when the caret is before the opening fence', async () => {
@@ -72,13 +113,13 @@ describe('ChatForm Enter in code blocks', () => {

 		await tick();

-		const textarea = textareaIn(container);
+		const editor = editorIn(container);

-		await userEvent.click(textarea);
+		await userEvent.click(editor.contentDOM);
 		await userEvent.keyboard('```');
 		await tick();

-		textarea.setSelectionRange(0, 0);
+		editor.dispatch({ selection: { anchor: 0 } });

 		await userEvent.keyboard('{Enter}');
 		await tick();
@@ -92,9 +133,9 @@ describe('ChatForm Enter in code blocks', () => {

 		await tick();

-		const textarea = textareaIn(container);
+		const editor = editorIn(container);

-		await userEvent.click(textarea);
+		await userEvent.click(editor.contentDOM);
 		await userEvent.keyboard('hello');
 		await tick();

@@ -110,9 +151,9 @@ describe('ChatForm Enter in code blocks', () => {

 		await tick();

-		const textarea = textareaIn(container);
+		const editor = editorIn(container);

-		await userEvent.click(textarea);
+		await userEvent.click(editor.contentDOM);
 		await userEvent.keyboard('```');
 		await tick();

diff --git a/tools/ui/tests/client/chat-form-input-rich-blocks.svelte.test.ts b/tools/ui/tests/client/chat-form-input-rich-blocks.svelte.test.ts
deleted file mode 100644
index 7c59ff951..000000000
--- a/tools/ui/tests/client/chat-form-input-rich-blocks.svelte.test.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-// Guards the newline contract of the ChatFormInputRich: browsers
-// restructure the flat DOM on Enter (`<div>` wrappers, `<br>` shapes) and
-// serialization must fold those back into `\n` so the emitted value never
-// diverges from what is on screen.
-
-import ChatFormInputRichHarness from './components/ChatFormInputRichHarness.svelte';
-import { tick } from 'svelte';
-import { describe, expect, it } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-
-const SOURCE = 'see [docs](file:///a/b) here';
-
-function editableIn(container: HTMLElement): HTMLElement {
-	const el = container.querySelector('[role="textbox"]');
-
-	if (!(el instanceof HTMLElement)) throw new Error('ChatFormInputRich not rendered');
-
-	return el;
-}
-
-function fireInput(root: HTMLElement) {
-	root.dispatchEvent(new InputEvent('input', { bubbles: true }));
-}
-
-function setCaret(node: Node, offset: number) {
-	const range = document.createRange();
-
-	range.setStart(node, offset);
-	range.setEnd(node, offset);
-	const selection = window.getSelection();
-
-	if (!selection) throw new Error('no selection');
-
-	selection.removeAllRanges();
-	selection.addRange(range);
-}
-
-describe('ChatFormInputRich browser newline shapes', () => {
-	it('serializes a Chromium Enter <div> wrapper as a newline', async () => {
-		const screen = render(ChatFormInputRichHarness, { value: SOURCE });
-
-		await tick();
-
-		const root = editableIn(screen.container);
-		const div = document.createElement('div');
-
-		div.textContent = 'second line';
-		root.appendChild(div);
-		fireInput(root);
-		await tick();
-
-		expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
-	});
-
-	it('serializes a Firefox full <div> wrap as lines, badge included', async () => {
-		const screen = render(ChatFormInputRichHarness, { value: SOURCE });
-
-		await tick();
-
-		const root = editableIn(screen.container);
-		const first = document.createElement('div');
-
-		while (root.firstChild) first.appendChild(root.firstChild);
-		const second = document.createElement('div');
-
-		second.textContent = 'second line';
-		root.appendChild(first);
-		root.appendChild(second);
-		fireInput(root);
-		await tick();
-
-		expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
-	});
-
-	it('serializes a <br> as a newline', async () => {
-		const screen = render(ChatFormInputRichHarness, { value: 'here' });
-
-		await tick();
-
-		const root = editableIn(screen.container);
-
-		root.appendChild(document.createElement('br'));
-		root.appendChild(document.createTextNode('second line'));
-		fireInput(root);
-		await tick();
-
-		expect(screen.component.getValue()).toBe('here\nsecond line');
-	});
-
-	it('ignores a trailing <br> (browser caret placeholder)', async () => {
-		const screen = render(ChatFormInputRichHarness, { value: 'abc' });
-
-		await tick();
-
-		const root = editableIn(screen.container);
-
-		root.appendChild(document.createElement('br'));
-		fireInput(root);
-		await tick();
-
-		expect(screen.component.getValue()).toBe('abc');
-	});
-
-	it('serializes one newline per empty-line <div><br></div>', async () => {
-		const screen = render(ChatFormInputRichHarness, { value: 'abc' });
-
-		await tick();
-
-		const root = editableIn(screen.container);
-
-		for (let i = 0; i < 2; i++) {
-			const div = document.createElement('div');
-
-			div.appendChild(document.createElement('br'));
-			root.appendChild(div);
-		}
-		fireInput(root);
-		await tick();
-
-		expect(screen.component.getValue()).toBe('abc\n\n');
-	});
-
-	it('treats a <div><br></div>-only buffer as empty for the placeholder', async () => {
-		const screen = render(ChatFormInputRichHarness, { value: 'abc' });
-
-		await tick();
-
-		const root = editableIn(screen.container);
-		const div = document.createElement('div');
-
-		div.appendChild(document.createElement('br'));
-		root.replaceChildren(div);
-		fireInput(root);
-		await tick();
-
-		expect(screen.component.getValue()).toBe('');
-		expect(root.dataset.empty).toBe('true');
-	});
-
-	it('maps the caret across block boundaries in both directions', async () => {
-		const screen = render(ChatFormInputRichHarness, { value: 'abc\ndef' });
-
-		await tick();
-
-		// Rebuild into the Chromium block shape; the source is unchanged,
-		// so no re-render fires.
-		const root = editableIn(screen.container);
-		const div = document.createElement('div');
-
-		div.textContent = 'def';
-		root.replaceChildren(document.createTextNode('abc'), div);
-		fireInput(root);
-		await tick();
-		expect(screen.component.getValue()).toBe('abc\ndef');
-
-		const divText = div.firstChild;
-
-		if (!divText) throw new Error('div text missing');
-
-		setCaret(divText, 2);
-		expect(screen.component.getCaretOffset()).toBe(6);
-
-		screen.component.setCaretOffset(6);
-		const selection = window.getSelection();
-
-		expect(selection?.anchorNode).toBe(divText);
-		expect(selection?.anchorOffset).toBe(2);
-
-		// The boundary newline itself: offset 3 is the end of "abc", offset
-		// 4 the start of the "def" line.
-		screen.component.setCaretOffset(4);
-		expect(window.getSelection()?.anchorNode).toBe(divText);
-		expect(window.getSelection()?.anchorOffset).toBe(0);
-
-		screen.component.setCaretOffset(3);
-		expect(window.getSelection()?.anchorNode).toBe(root.firstChild);
-		expect(window.getSelection()?.anchorOffset).toBe(3);
-	});
-});
diff --git a/tools/ui/tests/client/chat-form-input-rich-undo.svelte.test.ts b/tools/ui/tests/client/chat-form-input-rich-undo.svelte.test.ts
deleted file mode 100644
index 00f07f482..000000000
--- a/tools/ui/tests/client/chat-form-input-rich-undo.svelte.test.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-// Guards the editing-key contract of the ChatFormInputRich:
-// undo/redo is replayed from source snapshots (the token rebuilds destroy
-// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
-// keyboard trap), matching the plain textarea.
-
-import ChatFormInputRichHarness from './components/ChatFormInputRichHarness.svelte';
-import { tick } from 'svelte';
-import { describe, expect, it } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-
-const SOURCE = 'see [docs](file:///a/b)';
-
-function editableIn(container: HTMLElement): HTMLElement {
-	const el = container.querySelector('[role="textbox"]');
-
-	if (!(el instanceof HTMLElement)) throw new Error('ChatFormInputRich not rendered');
-
-	return el;
-}
-
-function type(root: HTMLElement, text: string, inputType = 'insertText') {
-	root.appendChild(document.createTextNode(text));
-	root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType }));
-}
-
-function keydown(root: HTMLElement, init: KeyboardEventInit) {
-	const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
-
-	root.dispatchEvent(event);
-
-	return event;
-}
-
-describe('ChatFormInputRich undo/redo', () => {
-	it('undoes and redoes an edit across a badge-containing buffer', async () => {
-		const screen = render(ChatFormInputRichHarness, { value: SOURCE });
-
-		await tick();
-
-		const root = editableIn(screen.container);
-
-		type(root, ' more');
-		await tick();
-		expect(screen.component.getValue()).toBe(`${SOURCE} more`);
-
-		const undoEvent = keydown(root, { ctrlKey: true, key: 'z' });
-
-		await tick();
-		expect(undoEvent.defaultPrevented).toBe(true);
-		expect(screen.component.getValue()).toBe(SOURCE);
-
-		const redoEvent = keydown(root, { ctrlKey: true, key: 'z', shiftKey: true });
-
-		await tick();
-		expect(redoEvent.defaultPrevented).toBe(true);
-		expect(screen.component.getValue()).toBe(`${SOURCE} more`);
-	});
-
-	it('redoes with Ctrl+Y as well', async () => {
-		const screen = render(ChatFormInputRichHarness, { value: SOURCE });
-
-		await tick();
-
-		const root = editableIn(screen.container);
-
-		type(root, ' more');
-		await tick();
-		keydown(root, { key: 'z', metaKey: true });
-		await tick();
-		expect(screen.component.getValue()).toBe(SOURCE);
-
-		keydown(root, { ctrlKey: true, key: 'y' });
-		await tick();
-		expect(screen.component.getValue()).toBe(`${SOURCE} more`);
-	});
-
-	it('coalesces a typing burst into one undo step', async () => {
-		const screen = render(ChatFormInputRichHarness, { value: 'abc' });
-
-		await tick();
-
-		const root = editableIn(screen.container);
-
-		type(root, 'd');
-		type(root, 'e');
-		await tick();
-		expect(screen.component.getValue()).toBe('abcde');
-
-		keydown(root, { ctrlKey: true, key: 'z' });
-		await tick();
-		expect(screen.component.getValue()).toBe('abc');
-	});
-
-	it('keeps a newline as its own undo step', async () => {
-		const screen = render(ChatFormInputRichHarness, { value: 'abc' });
-
-		await tick();
-
-		const root = editableIn(screen.container);
-
-		type(root, 'd');
-		type(root, '\n', 'insertLineBreak');
-		await tick();
-		expect(screen.component.getValue()).toBe('abcd\n');
-
-		keydown(root, { ctrlKey: true, key: 'z' });
-		await tick();
-		expect(screen.component.getValue()).toBe('abcd');
-
-		keydown(root, { ctrlKey: true, key: 'z' });
-		await tick();
-		expect(screen.component.getValue()).toBe('abc');
-	});
-
-	it('is a no-op when there is nothing to undo', async () => {
-		const screen = render(ChatFormInputRichHarness, { value: 'abc' });
-
-		await tick();
-
-		const root = editableIn(screen.container);
-		const event = keydown(root, { ctrlKey: true, key: 'z' });
-
-		await tick();
-
-		expect(event.defaultPrevented).toBe(true);
-		expect(screen.component.getValue()).toBe('abc');
-	});
-
-	it('abandons the redo branch after a fresh edit', async () => {
-		const screen = render(ChatFormInputRichHarness, { value: 'abc' });
-
-		await tick();
-
-		const root = editableIn(screen.container);
-
-		type(root, 'd');
-		await tick();
-		keydown(root, { ctrlKey: true, key: 'z' });
-		await tick();
-		expect(screen.component.getValue()).toBe('abc');
-
-		type(root, 'e');
-		await tick();
-		keydown(root, { ctrlKey: true, key: 'z', shiftKey: true });
-		await tick();
-		expect(screen.component.getValue()).toBe('abce');
-	});
-});
-
-describe('ChatFormInputRich Tab key', () => {
-	it('does not trap Tab (focus can leave the editable)', async () => {
-		const screen = render(ChatFormInputRichHarness, { value: SOURCE });
-
-		await tick();
-
-		const root = editableIn(screen.container);
-		const event = keydown(root, { key: 'Tab' });
-
-		expect(event.defaultPrevented).toBe(false);
-	});
-});
diff --git a/tools/ui/tests/client/chat-form-input-rich.svelte.test.ts b/tools/ui/tests/client/chat-form-input-rich.svelte.test.ts
deleted file mode 100644
index d3403030d..000000000
--- a/tools/ui/tests/client/chat-form-input-rich.svelte.test.ts
+++ /dev/null
@@ -1,804 +0,0 @@
-// Guards the clipboard contract of the ChatFormInputRich:
-// copy/cut expose the markdown SOURCE of the selection (each badge
-// contributes its full `[name](file://...)` link) and pasting such
-// markdown re-renders the badges.
-
-import ChatFormInputRich from '$lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte';
-import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
-import { tick } from 'svelte';
-import { describe, expect, it, vi } from 'vitest';
-import { userEvent } from 'vitest/browser';
-import { render } from 'vitest-browser-svelte';
-
-const SOURCE = 'hello [docs](file:///a/b) world';
-const BADGE_SELECTOR = '[data-mention-badge="true"]';
-
-function editableIn(container: HTMLElement): HTMLElement {
-	const el = container.querySelector('[role="textbox"]');
-
-	if (!(el instanceof HTMLElement)) throw new Error('ChatFormInputRich not rendered');
-
-	return el;
-}
-
-function setSelection(root: HTMLElement, place: (range: Range, root: HTMLElement) => void) {
-	const range = document.createRange();
-
-	place(range, root);
-	const selection = window.getSelection();
-
-	if (!selection) throw new Error('no selection');
-
-	selection.removeAllRanges();
-	selection.addRange(range);
-}
-
-function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') {
-	const data = new DataTransfer();
-
-	if (text) data.setData('text/plain', text);
-
-	const event = new ClipboardEvent(type, { bubbles: true, cancelable: true, clipboardData: data });
-
-	return { data, event };
-}
-
-describe('ChatFormInputRich clipboard', () => {
-	it('copy exposes the markdown source of the selection', async () => {
-		const { container } = render(ChatFormInputRich, { value: SOURCE });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		setSelection(root, (range) => range.selectNodeContents(root));
-
-		const { data, event } = clipboardEvent('copy');
-
-		root.dispatchEvent(event);
-
-		expect(event.defaultPrevented).toBe(true);
-		expect(data.getData('text/plain')).toBe(SOURCE);
-	});
-
-	it('cut exposes the markdown source and removes the slice', async () => {
-		const { container } = render(ChatFormInputRich, { value: SOURCE });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		setSelection(root, (range) => {
-			const badge = root.querySelector(BADGE_SELECTOR);
-
-			if (!badge) throw new Error('badge not rendered');
-
-			range.setStartBefore(badge);
-			range.setEndAfter(badge);
-		});
-
-		const { data, event } = clipboardEvent('cut');
-
-		root.dispatchEvent(event);
-
-		expect(event.defaultPrevented).toBe(true);
-		expect(data.getData('text/plain')).toBe('[docs](file:///a/b)');
-		expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
-		expect(root.textContent).toBe('hello  world');
-	});
-
-	it('paste of markdown mention links re-renders badges', async () => {
-		const { container } = render(ChatFormInputRich, { value: 'hello ' });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		setSelection(root, (range) => {
-			range.selectNodeContents(root);
-			range.collapse(false);
-		});
-
-		const { event } = clipboardEvent('paste', '[docs](file:///a/b) world');
-
-		root.dispatchEvent(event);
-		await tick();
-
-		expect(event.defaultPrevented).toBe(true);
-		const badge = root.querySelector(BADGE_SELECTOR);
-
-		expect(badge).not.toBeNull();
-		expect(badge!.getAttribute('data-mention-name')).toBe('docs');
-		expect(root.textContent).toContain('world');
-	});
-
-	it('paste without mention links keeps the DOM untouched', async () => {
-		const { container } = render(ChatFormInputRich, { value: 'hello ' });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		setSelection(root, (range) => {
-			range.selectNodeContents(root);
-			range.collapse(false);
-		});
-		const firstChild = root.firstChild;
-		const { event } = clipboardEvent('paste', 'plain text');
-
-		root.dispatchEvent(event);
-		await tick();
-
-		expect(event.defaultPrevented).toBe(true);
-		expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
-		// no rebuild: the live text node is the same instance
-		expect(root.firstChild).toBe(firstChild);
-	});
-});
-
-describe('ChatFormInputRich code spans', () => {
-	it('renders inline code from the initial value', async () => {
-		const { container } = render(ChatFormInputRich, { value: 'run `npm test` now' });
-
-		await tick();
-
-		const root = editableIn(container);
-		const code = root.querySelector('code[data-code-token="code_inline"]');
-
-		expect(code).not.toBeNull();
-		expect(code!.textContent).toBe('`npm test`');
-	});
-
-	it('renders a fenced code block with a language', async () => {
-		const source = 'before\n```js\nconst a = 1;\n```\nafter';
-		const { container } = render(ChatFormInputRich, { value: source });
-
-		await tick();
-
-		const root = editableIn(container);
-		const code = root.querySelector('code[data-code-token="code_block"]');
-
-		expect(code).not.toBeNull();
-		expect(code!.textContent).toBe('```js\nconst a = 1;\n```');
-	});
-
-	it('copy exposes the markdown source of a selection spanning code', async () => {
-		const source = 'run `npm test` now';
-		const { container } = render(ChatFormInputRich, { value: source });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		setSelection(root, (range) => range.selectNodeContents(root));
-
-		const { data, event } = clipboardEvent('copy');
-
-		root.dispatchEvent(event);
-
-		expect(event.defaultPrevented).toBe(true);
-		expect(data.getData('text/plain')).toBe(source);
-	});
-
-	it('paste of a code span renders the styled element', async () => {
-		const { container } = render(ChatFormInputRich, { value: 'run ' });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		setSelection(root, (range) => {
-			range.selectNodeContents(root);
-			range.collapse(false);
-		});
-
-		const { event } = clipboardEvent('paste', '`npm test` now');
-
-		root.dispatchEvent(event);
-		await tick();
-
-		expect(event.defaultPrevented).toBe(true);
-		const code = root.querySelector('code[data-code-token="code_inline"]');
-
-		expect(code).not.toBeNull();
-		expect(code!.textContent).toBe('`npm test`');
-		expect(root.textContent).toContain('now');
-	});
-
-	it('highlights a fenced block content and stays byte-exact', async () => {
-		const source = '```js\nconst a = 1;\n```';
-		const { container } = render(ChatFormInputRich, { value: source });
-
-		await tick();
-
-		const root = editableIn(container);
-		const code = root.querySelector('code[data-code-token="code_block"]');
-
-		expect(code).not.toBeNull();
-		expect(code!.querySelector('.hljs-keyword')).not.toBeNull();
-		expect(code!.textContent).toBe(source);
-	});
-
-	it('does not highlight inline code', async () => {
-		const { container } = render(ChatFormInputRich, { value: 'run `const` now' });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		expect(root.querySelector('[class*="hljs-"]')).toBeNull();
-	});
-});
-
-describe('ChatFormInputRich code block escape hatches', () => {
-	const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
-	const BLOCK_SELECTOR = 'code[data-code-token="code_block"]';
-
-	function blockIn(root: HTMLElement): HTMLElement {
-		const el = root.querySelector(BLOCK_SELECTOR);
-
-		if (!(el instanceof HTMLElement)) throw new Error('code block not rendered');
-
-		return el;
-	}
-
-	// Caret at the very start/end of the block's text (across highlight spans)
-	function placeCaretInBlock(root: HTMLElement, where: 'start' | 'end') {
-		const code = blockIn(root);
-		const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
-
-		let target: Node | null = null;
-
-		for (let n = walker.nextNode(); n; n = walker.nextNode()) {
-			target = where === 'start' ? (target ?? n) : n;
-		}
-
-		if (!target) throw new Error('no text inside code block');
-
-		setSelection(root, (range) => {
-			range.setStart(target!, where === 'start' ? 0 : (target!.textContent ?? '').length);
-			range.collapse(true);
-		});
-	}
-
-	function caretContainer(): Node {
-		const selection = window.getSelection();
-
-		if (!selection || selection.rangeCount === 0) throw new Error('no selection');
-
-		return selection.getRangeAt(0).startContainer;
-	}
-
-	it('pads a trailing code block with a br hatch that stays invisible to copy', async () => {
-		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		// no permanent empty line above a leading block
-		expect(root.firstChild).toBe(blockIn(root));
-		expect(root.lastChild?.nodeName).toBe('BR');
-
-		setSelection(root, (range) => range.selectNodeContents(root));
-		const { data, event } = clipboardEvent('copy');
-
-		root.dispatchEvent(event);
-
-		expect(data.getData('text/plain')).toBe(BLOCK_SOURCE);
-	});
-
-	it('escapes a trailing code block with ArrowDown and types after it', async () => {
-		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		placeCaretInBlock(root, 'end');
-
-		await userEvent.keyboard('{ArrowDown}');
-		expect(blockIn(root).contains(caretContainer())).toBe(false);
-
-		await userEvent.keyboard('x');
-		await tick();
-
-		expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
-		// the DOM holds no separator newline (it would render as a
-		// phantom empty line); serialization synthesizes it so the
-		// markdown source keeps the text below the block
-		expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
-		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
-		// the stale trailing hatch is removed once real text follows the block
-		expect(root.lastChild?.nodeName).not.toBe('BR');
-	});
-
-	it('escapes a leading code block with ArrowUp and types before it', async () => {
-		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		placeCaretInBlock(root, 'start');
-
-		await userEvent.keyboard('{ArrowUp}');
-		expect(blockIn(root).contains(caretContainer())).toBe(false);
-		// the transient hatch line exists while the caret sits on it
-		expect(root.firstChild?.nodeName).toBe('BR');
-
-		await userEvent.keyboard('y');
-		await tick();
-
-		expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
-		expect(root.textContent).toBe('y' + BLOCK_SOURCE);
-		expect(serializeContent(root)).toBe('y\n' + BLOCK_SOURCE);
-		// the typed text consumed the hatch
-		expect(root.firstChild?.nodeName).not.toBe('BR');
-	});
-
-	it('escapes a leading code block with ArrowLeft from its first character', async () => {
-		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		placeCaretInBlock(root, 'start');
-
-		await userEvent.keyboard('{ArrowLeft}');
-		expect(blockIn(root).contains(caretContainer())).toBe(false);
-		expect(root.firstChild?.nodeName).toBe('BR');
-	});
-
-	it('removes the transient leading hatch when the caret moves back into the block', async () => {
-		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		placeCaretInBlock(root, 'start');
-
-		await userEvent.keyboard('{ArrowUp}');
-		expect(root.firstChild?.nodeName).toBe('BR');
-
-		await userEvent.keyboard('{ArrowDown}');
-		await tick();
-
-		expect(blockIn(root).contains(caretContainer())).toBe(true);
-		expect(root.firstChild).toBe(blockIn(root));
-	});
-
-	it('extends the selection out of the block with Shift+ArrowDown', async () => {
-		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		placeCaretInBlock(root, 'end');
-
-		await userEvent.keyboard('{Shift>}{ArrowDown}{/Shift}');
-
-		const selection = window.getSelection();
-
-		expect(selection).not.toBeNull();
-		expect(selection!.isCollapsed).toBe(false);
-		expect(blockIn(root).contains(selection!.getRangeAt(0).endContainer)).toBe(false);
-	});
-
-	it('line-separates text typed right after the closing fence', async () => {
-		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		placeCaretInBlock(root, 'end');
-
-		// no arrow keys: the caret sits at the block's end edge, where the
-		// post-rebuild restore lands it, and the typed text renders on the
-		// line below the block
-		await userEvent.keyboard('x');
-		await tick();
-
-		// the text stays on the caret's line in the DOM (no phantom empty
-		// line); the source gets the separator newline
-		expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
-		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
-	});
-
-	it('does not double the newline when Shift+Enter already added one', async () => {
-		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		placeCaretInBlock(root, 'end');
-
-		await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
-		await userEvent.keyboard('x');
-		await tick();
-
-		expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
-		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
-	});
-
-	it('moves a caret stuck before the inserted newline onto the new line', async () => {
-		const { container } = render(ChatFormInputRich, {
-			value: BLOCK_SOURCE + '\ntext after the code block'
-		});
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-
-		// post-break DOM some browsers produce: the inserted newline plus
-		// the artificial trailing one, with the caret stuck BEFORE the
-		// inserted one (visually at the end of the old line)
-		root.appendChild(document.createTextNode('\n'));
-		root.appendChild(document.createTextNode('\n'));
-		setSelection(root, (range) => {
-			range.setStart(root.childNodes[2], 0);
-			range.collapse(true);
-		});
-
-		root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' }));
-		await tick();
-
-		const selection = window.getSelection();
-
-		expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
-			(BLOCK_SOURCE + '\ntext after the code block\n').length
-		);
-		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
-	});
-
-	it('appends the artificial trailing newline when the browser did not add one', async () => {
-		const { container } = render(ChatFormInputRich, {
-			value: BLOCK_SOURCE + '\ntext after the code block'
-		});
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-
-		// post-break DOM some browsers produce: a lone trailing \n (or a
-		// <br> the hatch sync strips). Collapsed by the renderer, so the
-		// caret looks stuck on the old line and the next typed character
-		// would consume the newline.
-		root.appendChild(document.createTextNode('\n'));
-		setSelection(root, (range) => {
-			range.setStart(root.childNodes[2], 1);
-			range.collapse(true);
-		});
-
-		root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' }));
-		await tick();
-
-		const selection = window.getSelection();
-
-		expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
-			(BLOCK_SOURCE + '\ntext after the code block\n').length
-		);
-		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
-	});
-
-	it('lands the caret on the new line with a single Shift+Enter after text below a block', async () => {
-		const { container } = render(ChatFormInputRich, {
-			value: BLOCK_SOURCE + '\ntext after the code block'
-		});
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		setSelection(root, (range) => {
-			const text = root.childNodes[1];
-
-			range.setStart(text, (text.textContent ?? '').length);
-			range.collapse(true);
-		});
-
-		await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
-		await tick();
-
-		const selection = window.getSelection();
-
-		expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
-			(BLOCK_SOURCE + '\ntext after the code block\n').length
-		);
-		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
-
-		// the next typed character lands on the new line
-		await userEvent.keyboard('x');
-		await tick();
-		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\nx');
-	});
-
-	it('lets Backspace at the text start move into the block without a source fight', async () => {
-		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		placeCaretInBlock(root, 'end');
-
-		await userEvent.keyboard('{ArrowDown}');
-		await userEvent.keyboard('create');
-		await tick();
-		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
-
-		// Backspace at the start of the text line: the separator newline
-		// is structural (synthesized while text follows the block), so
-		// the caret just moves to the block's edge - nothing is re-added
-		await userEvent.keyboard('{Home}');
-		await userEvent.keyboard('{Backspace}');
-		await tick();
-
-		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
-		expect(caretContainer()).toBe(root);
-	});
-
-	it('lets forward Delete eat the text after a block normally', async () => {
-		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		placeCaretInBlock(root, 'end');
-
-		await userEvent.keyboard('{ArrowDown}');
-		await userEvent.keyboard('create');
-		await tick();
-
-		await userEvent.keyboard('{Home}');
-		await userEvent.keyboard('{Delete}');
-		await tick();
-
-		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nreate');
-	});
-
-	it('renders text after a block without a phantom empty line', async () => {
-		const { container } = render(ChatFormInputRich, {
-			value: BLOCK_SOURCE + '\nhello'
-		});
-
-		await tick();
-
-		const root = editableIn(container);
-
-		expect(root.textContent).toBe(BLOCK_SOURCE + 'hello');
-		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nhello');
-	});
-
-	it('keeps an intentional blank line after a block out of the separator', async () => {
-		const { container } = render(ChatFormInputRich, {
-			value: BLOCK_SOURCE + '\n\nhello'
-		});
-
-		await tick();
-
-		const root = editableIn(container);
-
-		expect(root.textContent).toBe(BLOCK_SOURCE + '\nhello');
-		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\n\nhello');
-	});
-
-	it('re-highlights while typing inside a block and keeps the caret', async () => {
-		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-
-		// caret at the start of the block content (after the opening fence)
-		setSelection(root, (range) => {
-			const target = textOffsetToRange(root, 6);
-
-			range.setStart(target.startContainer, target.startOffset);
-			range.collapse(true);
-		});
-
-		await userEvent.keyboard('x');
-		await tick();
-
-		const code = blockIn(root);
-
-		expect(serializeContent(root)).toBe('```js\nxconst a = 1;\n```');
-		expect(code.textContent).toBe('```js\nxconst a = 1;\n```');
-		expect(code.querySelector('.hljs-number')).not.toBeNull();
-
-		const selection = window.getSelection();
-
-		expect(code.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
-		expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
-	});
-});
-
-describe('ChatFormInputRich Enter in code blocks', () => {
-	const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
-
-	it('adds a line instead of submitting on plain Enter inside a block', async () => {
-		const onKeydown = vi.fn();
-		const { container } = render(ChatFormInputRich, {
-			onKeydown,
-			value: BLOCK_SOURCE
-		});
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-
-		// caret at the start of the block content (after the opening fence)
-		setSelection(root, (range) => {
-			const target = textOffsetToRange(root, 6);
-
-			range.setStart(target.startContainer, target.startOffset);
-			range.collapse(true);
-		});
-
-		await userEvent.keyboard('{Enter}');
-		await tick();
-
-		// consumed locally: the parent's submit handler never sees it
-		expect(onKeydown).not.toHaveBeenCalled();
-		expect(serializeContent(root)).toBe('```js\n\nconst a = 1;\n```');
-
-		const code = root.querySelector('code[data-code-token="code_block"]');
-		const selection = window.getSelection();
-
-		expect(code!.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
-		expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
-	});
-
-	it('adds a line after a still-open fence (no closing ``` yet)', async () => {
-		const onKeydown = vi.fn();
-		const { container } = render(ChatFormInputRich, {
-			onKeydown,
-			value: '```js\nconst a = 1;'
-		});
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-
-		// caret at the start of the block content (after the opening fence)
-		setSelection(root, (range) => {
-			const target = textOffsetToRange(root, 6);
-
-			range.setStart(target.startContainer, target.startOffset);
-			range.collapse(true);
-		});
-
-		await userEvent.keyboard('{Enter}');
-		await tick();
-
-		expect(onKeydown).not.toHaveBeenCalled();
-		expect(serializeContent(root)).toBe('```js\n\nconst a = 1;');
-	});
-
-	it('forwards plain Enter to the parent when the caret is outside a block', async () => {
-		const onKeydown = vi.fn();
-		const { container } = render(ChatFormInputRich, {
-			onKeydown,
-			value: BLOCK_SOURCE + '\nafter'
-		});
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		setSelection(root, (range) => {
-			range.selectNodeContents(root);
-			range.collapse(false);
-		});
-
-		await userEvent.keyboard('{Enter}');
-
-		expect(onKeydown).toHaveBeenCalledTimes(1);
-		expect(onKeydown.mock.calls[0][0].defaultPrevented).toBe(false);
-	});
-
-	it('forwards plain Enter on the trailing hatch line after a block', async () => {
-		const onKeydown = vi.fn();
-		const { container } = render(ChatFormInputRich, {
-			onKeydown,
-			value: BLOCK_SOURCE
-		});
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		// root-level caret between the block and its trailing br hatch
-		setSelection(root, (range) => {
-			range.setStart(root, 1);
-			range.collapse(true);
-		});
-
-		await userEvent.keyboard('{Enter}');
-
-		expect(onKeydown).toHaveBeenCalledTimes(1);
-	});
-
-	it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => {
-		const onKeydown = vi.fn();
-		const { container } = render(ChatFormInputRich, {
-			onKeydown,
-			value: BLOCK_SOURCE
-		});
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		setSelection(root, (range) => {
-			const target = textOffsetToRange(root, 6);
-
-			range.setStart(target.startContainer, target.startOffset);
-			range.collapse(true);
-		});
-
-		await userEvent.keyboard('{Control>}{Enter}{/Control}');
-
-		expect(onKeydown).toHaveBeenCalledWith(
-			expect.objectContaining({ ctrlKey: true, key: 'Enter' })
-		);
-		expect(serializeContent(root)).toBe(BLOCK_SOURCE);
-	});
-
-	it('forwards Enter inside an inline code span', async () => {
-		const onKeydown = vi.fn();
-		const { container } = render(ChatFormInputRich, {
-			onKeydown,
-			value: 'run `npm test` now'
-		});
-
-		await tick();
-
-		const root = editableIn(container);
-
-		root.focus();
-		const code = root.querySelector('code[data-code-token="code_inline"]')!;
-
-		setSelection(root, (range) => {
-			range.setStart(code.firstChild!, 3);
-			range.collapse(true);
-		});
-
-		await userEvent.keyboard('{Enter}');
-
-		expect(onKeydown).toHaveBeenCalledTimes(1);
-	});
-});
diff --git a/tools/ui/tests/client/chat-form-input.svelte.test.ts b/tools/ui/tests/client/chat-form-input.svelte.test.ts
new file mode 100644
index 000000000..e16e9a15a
--- /dev/null
+++ b/tools/ui/tests/client/chat-form-input.svelte.test.ts
@@ -0,0 +1,153 @@
+// Guards the chat form input contract: a live-preview markdown editor whose value
+// stays the exact markdown source, with mention links shown as badges while their
+// block is edited and every block rendered once the editor loses focus. The form's
+// key and paste handlers run first and win when they handle the event.
+
+import { EditorView } from '@codemirror/view';
+import ChatFormInput from '$lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte';
+import { SETTINGS_KEYS } from '$lib/constants';
+import { settingsStore } from '$lib/stores/settings/index.svelte';
+import { tick } from 'svelte';
+import { describe, expect, it, vi } from 'vitest';
+import { userEvent } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
+
+const MENTION = '[notes.md](file:///tmp/notes.md)';
+
+function editorIn(container: HTMLElement): EditorView {
+	const el = container.querySelector<HTMLElement>('.cm-content');
+	const view = el && EditorView.findFromDOM(el);
+
+	if (!view) throw new Error('editor not rendered');
+
+	return view;
+}
+
+async function settle() {
+	await tick();
+	await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
+}
+
+describe('ChatFormInput', () => {
+	it('keeps the typed markdown byte-exact in the value', async () => {
+		const screen = render(ChatFormInput, { value: '' });
+		const editor = editorIn(screen.container);
+
+		await userEvent.click(editor.contentDOM);
+		await userEvent.keyboard('## Title{Shift>}{Enter}{Enter}{/Shift}- **bold** `code`');
+		await settle();
+
+		expect(editor.state.doc.toString()).toBe('## Title\n\n- **bold** `code`');
+	});
+
+	it('shows a mention link as a badge in the block being edited', async () => {
+		const screen = render(ChatFormInput, { value: `see ${MENTION} ok` });
+		const editor = editorIn(screen.container);
+
+		editor.focus();
+		editor.dispatch({ selection: { anchor: 0 } });
+		await settle();
+
+		const line = screen.container.querySelector('.cm-line');
+
+		expect(line?.textContent).toContain('notes.md');
+		expect(line?.textContent).not.toContain('file://');
+		expect(editor.state.doc.toString()).toBe(`see ${MENTION} ok`);
+	});
+
+	it('leaves a mention link in a code block as text', async () => {
+		const screen = render(ChatFormInput, { value: `~~~\n${MENTION}\n~~~` });
+		const editor = editorIn(screen.container);
+
+		editor.focus();
+		editor.dispatch({ selection: { anchor: 0 } });
+		await settle();
+
+		expect(screen.container.querySelector('.cm-content')?.textContent).toContain('file://');
+	});
+
+	it('steps over a mention badge as one unit', async () => {
+		const screen = render(ChatFormInput, { value: `a ${MENTION}` });
+		const editor = editorIn(screen.container);
+
+		editor.focus();
+		editor.dispatch({ selection: { anchor: editor.state.doc.length } });
+		await settle();
+
+		await userEvent.keyboard('{Backspace}');
+		await settle();
+
+		expect(editor.state.doc.toString()).toBe('a ');
+	});
+
+	it('renders every block like a chat message once blurred', async () => {
+		const screen = render(ChatFormInput, { value: '# Title\n\nSome **bold** text' });
+		const editor = editorIn(screen.container);
+
+		editor.contentDOM.blur();
+		await vi.waitFor(() => {
+			expect(screen.container.querySelector('.cm-md-block h1')?.textContent).toBe('Title');
+			expect(screen.container.querySelector('.cm-md-block strong')?.textContent).toBe('bold');
+		});
+	});
+
+	it('shows the plain markdown, with mention badges, while the live preview setting is off', async () => {
+		settingsStore.updateConfig(SETTINGS_KEYS.LIVE_MARKDOWN_PREVIEW, false);
+
+		try {
+			const screen = render(ChatFormInput, { value: `# Title\n\nsee ${MENTION}` });
+			const editor = editorIn(screen.container);
+
+			editor.contentDOM.blur();
+			await settle();
+
+			const lines = [...screen.container.querySelectorAll('.cm-line')].map((l) => l.textContent);
+
+			expect(screen.container.querySelector('.cm-md-block')).toBeNull();
+			expect(lines[0]).toBe('# Title');
+			expect(lines.at(-1)).toContain('notes.md');
+			expect(lines.at(-1)).not.toContain('file://');
+
+			settingsStore.updateConfig(SETTINGS_KEYS.LIVE_MARKDOWN_PREVIEW, true);
+
+			await vi.waitFor(() =>
+				expect(screen.container.querySelector('.cm-md-block h1')?.textContent).toBe('Title')
+			);
+		} finally {
+			settingsStore.updateConfig(SETTINGS_KEYS.LIVE_MARKDOWN_PREVIEW, true);
+		}
+	});
+
+	it('lets the form handle a key first and keeps it from the editor', async () => {
+		const onKeydown = vi.fn((event: KeyboardEvent) => {
+			if (event.key === 'Enter') event.preventDefault();
+		});
+		const screen = render(ChatFormInput, { onKeydown, value: 'hello' });
+		const editor = editorIn(screen.container);
+
+		editor.focus();
+		editor.dispatch({ selection: { anchor: 5 } });
+		await userEvent.keyboard('{Enter}');
+		await settle();
+
+		expect(onKeydown).toHaveBeenCalled();
+		expect(editor.state.doc.toString()).toBe('hello');
+	});
+
+	it('leaves a paste the form handles out of the editor', async () => {
+		const onPaste = vi.fn((event: ClipboardEvent) => event.preventDefault());
+		const screen = render(ChatFormInput, { onPaste, value: '' });
+		const editor = editorIn(screen.container);
+		const clipboardData = new DataTransfer();
+
+		clipboardData.setData('text/plain', 'pasted');
+		editor.focus();
+		editor.contentDOM.dispatchEvent(
+			new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData })
+		);
+		await settle();
+
+		expect(onPaste).toHaveBeenCalled();
+		expect(editor.state.doc.toString()).toBe('');
+	});
+});
diff --git a/tools/ui/tests/client/components/ChatFormInputRichHarness.svelte b/tools/ui/tests/client/components/ChatFormInputRichHarness.svelte
deleted file mode 100644
index 58768a1e8..000000000
--- a/tools/ui/tests/client/components/ChatFormInputRichHarness.svelte
+++ /dev/null
@@ -1,27 +0,0 @@
-<script lang="ts">
-	import ChatFormInputRich from '$lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte';
-	import { untrack } from 'svelte';
-
-	interface Props {
-		value?: string;
-	}
-
-	let { value: initial = '' }: Props = $props();
-
-	let value = $state(untrack(() => initial));
-	let inputRef: ChatFormInputRich | undefined = $state(undefined);
-
-	export function getValue() {
-		return value;
-	}
-
-	export function getCaretOffset() {
-		return inputRef?.getCaretOffset();
-	}
-
-	export function setCaretOffset(offset: number) {
-		inputRef?.setCaretOffset(offset);
-	}
-</script>
-
-<ChatFormInputRich bind:this={inputRef} bind:value />
diff --git a/tools/ui/tests/client/highlight-theme.svelte.test.ts b/tools/ui/tests/client/highlight-theme.svelte.test.ts
new file mode 100644
index 000000000..50de8d926
--- /dev/null
+++ b/tools/ui/tests/client/highlight-theme.svelte.test.ts
@@ -0,0 +1,26 @@
+// Every message and editor loads the highlight theme as it mounts; swapping the style
+// element restyles the whole page, so it only happens when the mode changes.
+
+import { UI_DATA_ATTRS } from '$lib/constants';
+import { loadHighlightTheme } from '$lib/utils';
+import { describe, expect, it } from 'vitest';
+
+const themes = () => document.querySelectorAll(`style[${UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW}]`);
+
+describe('loadHighlightTheme', () => {
+	it('keeps the style element while the mode stays and swaps it on a change', () => {
+		loadHighlightTheme(false);
+
+		const light = themes()[0];
+
+		loadHighlightTheme(false);
+
+		expect(themes()).toHaveLength(1);
+		expect(themes()[0]).toBe(light);
+
+		loadHighlightTheme(true);
+
+		expect(themes()).toHaveLength(1);
+		expect(themes()[0]).not.toBe(light);
+	});
+});
diff --git a/tools/ui/tests/client/markdown-editor-margins.svelte.test.ts b/tools/ui/tests/client/markdown-editor-margins.svelte.test.ts
new file mode 100644
index 000000000..015b17b46
--- /dev/null
+++ b/tools/ui/tests/client/markdown-editor-margins.svelte.test.ts
@@ -0,0 +1,61 @@
+// The live preview spaces its rendered blocks from a table of block margins, as
+// CodeMirror can't measure the margins of block widgets. This measures what a chat
+// message actually shows for each block kind, so the table can't drift from
+// markdown-content.css.
+
+import MarkdownContent from '$lib/components/app/content/MarkdownContent/MarkdownContent.svelte';
+import {
+	BLOCK_MARGINS_REM,
+	PARAGRAPH_MARGINS_REM
+} from '$lib/components/app/content/MarkdownEditor/markdown-blocks';
+import { describe, expect, it, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+// Each kind sits between two paragraphs, as most blocks do
+const SAMPLES: Record<string, { markdown: string }> = {
+	ATXHeading1: { markdown: '# x' },
+	ATXHeading2: { markdown: '## x' },
+	ATXHeading3: { markdown: '### x' },
+	ATXHeading4: { markdown: '#### x' },
+	ATXHeading5: { markdown: '##### x' },
+	ATXHeading6: { markdown: '###### x' },
+	Blockquote: { markdown: '> x' },
+	BulletList: { markdown: '- x' },
+	CodeBlock: { markdown: '    x' },
+	FencedCode: { markdown: '```\nx\n```' },
+	HorizontalRule: { markdown: '***' },
+	OrderedList: { markdown: '1. x' },
+	Paragraph: { markdown: 'x' },
+	SetextHeading1: { markdown: 'x\n===' },
+	SetextHeading2: { markdown: 'x\n---' },
+	Table: { markdown: '| a |\n| - |\n| b |' }
+};
+
+function rem(px: string): number {
+	return parseFloat(px) / parseFloat(getComputedStyle(document.documentElement).fontSize);
+}
+
+describe('live preview block margins', () => {
+	for (const [name, sample] of Object.entries(SAMPLES)) {
+		it(`match a chat message for ${name}`, async () => {
+			const screen = render(MarkdownContent, { content: `before\n\n${sample.markdown}\n\nafter` });
+			const blocks = () => screen.container.querySelectorAll('.markdown-block');
+
+			await vi.waitFor(() => expect(blocks().length).toBe(3));
+
+			// the block's outer elements carry the margins that space it from its neighbours
+			const block = blocks()[1];
+			const first = block.firstElementChild;
+			const last = block.lastElementChild;
+
+			if (!first || !last) throw new Error(`${name} not rendered`);
+
+			const expected = BLOCK_MARGINS_REM[name] ?? PARAGRAPH_MARGINS_REM;
+
+			expect([
+				rem(getComputedStyle(first).marginTop),
+				rem(getComputedStyle(last).marginBottom)
+			]).toEqual(expected);
+		});
+	}
+});
diff --git a/tools/ui/tests/client/markdown-editor.svelte.test.ts b/tools/ui/tests/client/markdown-editor.svelte.test.ts
new file mode 100644
index 000000000..eba7e601c
--- /dev/null
+++ b/tools/ui/tests/client/markdown-editor.svelte.test.ts
@@ -0,0 +1,305 @@
+// Guards the MarkdownEditor contract beyond the chat form: which edits reach onChange,
+// undo of a programmatic replace, props changed after mount, blocks the live preview
+// keeps raw or joins, the gap after a raw block, and selecting text in rendered blocks.
+
+import { undo } from '@codemirror/commands';
+import { EditorView, type WidgetType } from '@codemirror/view';
+import MarkdownEditor from '$lib/components/app/content/MarkdownEditor/MarkdownEditor.svelte';
+import { SVG } from '$lib/constants';
+import { tick } from 'svelte';
+import { describe, expect, it, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+function editorIn(container: HTMLElement): EditorView {
+	const el = container.querySelector<HTMLElement>('.cm-content');
+	const view = el && EditorView.findFromDOM(el);
+
+	if (!view) throw new Error('editor not rendered');
+
+	return view;
+}
+
+async function settle() {
+	await tick();
+	await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
+}
+
+function mouse(type: string, target: Element, x: number, y: number) {
+	target.dispatchEvent(
+		new MouseEvent(type, { bubbles: true, button: 0, cancelable: true, clientX: x, clientY: y })
+	);
+}
+
+describe('MarkdownEditor', () => {
+	it('reports every edit but the syncs from value', async () => {
+		const onChange = vi.fn();
+		const screen = render(MarkdownEditor, { onChange, value: 'a' });
+		const editor = editorIn(screen.container);
+
+		await screen.rerender({ value: 'ab' });
+		await settle();
+
+		expect(editor.state.doc.toString()).toBe('ab');
+		expect(onChange).not.toHaveBeenCalled();
+
+		// an edit with no user event, e.g. from another extension
+		editor.dispatch({ changes: { from: 0, insert: 'x' } });
+
+		expect(onChange).toHaveBeenCalledWith('xab');
+	});
+
+	it('undoes a replaced text in one step', async () => {
+		const onChange = vi.fn();
+		const screen = render(MarkdownEditor, { onChange, value: 'see @no' });
+		const editor = editorIn(screen.container);
+
+		screen.component.replaceText('see [notes.md](file:///notes.md) ', 33);
+
+		expect(onChange).toHaveBeenLastCalledWith('see [notes.md](file:///notes.md) ');
+		expect(editor.state.selection.main.head).toBe(33);
+
+		undo(editor);
+
+		expect(editor.state.doc.toString()).toBe('see @no');
+	});
+
+	it('follows placeholder and label changes after mount', async () => {
+		const screen = render(MarkdownEditor, { placeholder: 'First', value: '' });
+		const editor = editorIn(screen.container);
+
+		await screen.rerender({ placeholder: 'Second' });
+		await settle();
+
+		expect(screen.container.querySelector('.cm-placeholder')?.textContent).toBe('Second');
+		expect(editor.contentDOM.getAttribute('aria-label')).toBe('Second');
+	});
+
+	it('reconfigures only when a prop changes, not when it mounts', async () => {
+		const reconfigured = vi.fn();
+		const extensions = [
+			EditorView.updateListener.of((update) => {
+				if (update.transactions.some((tr) => tr.reconfigured)) reconfigured();
+			})
+		];
+		const screen = render(MarkdownEditor, { extensions, placeholder: 'First', value: '' });
+
+		await settle();
+
+		expect(reconfigured).not.toHaveBeenCalled();
+
+		await screen.rerender({ value: 'typed' });
+		await settle();
+
+		expect(reconfigured).not.toHaveBeenCalled();
+
+		await screen.rerender({ placeholder: 'Second' });
+		await settle();
+
+		expect(reconfigured).toHaveBeenCalledTimes(1);
+	});
+
+	it('keeps the block being edited raw when the render key changes', async () => {
+		const screen = render(MarkdownEditor, { renderKey: 'short', value: 'one\n\ntwo' });
+		const editor = editorIn(screen.container);
+		const blocks = () => [...screen.container.querySelectorAll<HTMLElement>('.cm-md-block')];
+
+		await vi.waitFor(() => expect(blocks()).toHaveLength(2));
+
+		editor.focus();
+		editor.dispatch({ selection: { anchor: 0 } });
+
+		await vi.waitFor(() => expect(blocks().map((block) => block.textContent)).toEqual(['two']));
+
+		await screen.rerender({ renderKey: 'full' });
+		await settle();
+
+		expect(blocks().map((block) => block.textContent)).toEqual(['two']);
+	});
+
+	it('leaves the other rendered blocks untouched while a block is typed in', async () => {
+		const screen = render(MarkdownEditor, { value: '# One\n\ntwo\n\n- three\n\nfour' });
+		const editor = editorIn(screen.container);
+		const blocks = () => [...screen.container.querySelectorAll<HTMLElement>('.cm-md-block')];
+
+		await vi.waitFor(() =>
+			expect(blocks().map((block) => block.textContent?.trim())).toEqual([
+				'One',
+				'two',
+				'three',
+				'four'
+			])
+		);
+
+		editor.focus();
+		editor.dispatch({ selection: { anchor: '# One\n\nt'.length } });
+
+		await vi.waitFor(() => expect(blocks()).toHaveLength(3));
+
+		const others = blocks();
+		const mutations: MutationRecord[] = [];
+		const observer = new MutationObserver((records) => mutations.push(...records));
+
+		for (const block of others) {
+			observer.observe(block, {
+				attributes: true,
+				characterData: true,
+				childList: true,
+				subtree: true
+			});
+		}
+
+		for (const char of 'yped') {
+			editor.dispatch(editor.state.replaceSelection(char), { userEvent: 'input.type' });
+			await settle();
+		}
+
+		observer.disconnect();
+
+		expect(editor.state.doc.toString()).toBe('# One\n\ntypedwo\n\n- three\n\nfour');
+		expect(blocks()).toEqual(others);
+		expect(mutations).toEqual([]);
+	});
+
+	it('keeps a link reference definition raw', async () => {
+		const screen = render(MarkdownEditor, { value: 'see [docs][d]\n\n[d]: https://example.com' });
+
+		await vi.waitFor(() => {
+			expect(screen.container.querySelectorAll('.cm-md-block')).toHaveLength(1);
+			expect(screen.container.querySelector('.cm-md-raw-definition')?.textContent).toBe(
+				'[d]: https://example.com'
+			);
+		});
+	});
+
+	it('keeps a setext heading raw, so its underline never disappears', async () => {
+		const screen = render(MarkdownEditor, { value: 'test\n---\nafter' });
+
+		await vi.waitFor(() => {
+			expect(
+				[...screen.container.querySelectorAll('.cm-md-block')].map((b) => b.textContent)
+			).toEqual(['after']);
+			expect([...screen.container.querySelectorAll('.cm-line')].map((l) => l.textContent)).toEqual([
+				'test',
+				'---'
+			]);
+		});
+	});
+
+	it('renders a $$ math block with a blank line as one block', async () => {
+		const screen = render(MarkdownEditor, { value: '$$\na\n\nb\n$$' });
+
+		await vi.waitFor(() => {
+			const blocks = screen.container.querySelectorAll('.cm-md-block');
+
+			expect(blocks).toHaveLength(1);
+			expect(blocks[0].querySelector('.katex')).not.toBeNull();
+		});
+	});
+
+	it('draws an svg block like a chat message', async () => {
+		const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect/></svg>';
+		const screen = render(MarkdownEditor, { value: `\`\`\`svg\n${svg}\n\`\`\`` });
+
+		await vi.waitFor(() => {
+			expect(screen.container.querySelector(`.cm-md-block [${SVG.RENDERED_ATTR}]`)).not.toBeNull();
+		});
+	});
+
+	it('lets the blank lines after a raw block be the gap to the next one', async () => {
+		const screen = render(MarkdownEditor, { value: 'first\n\nsecond' });
+		const editor = editorIn(screen.container);
+		const second = () => screen.container.querySelector<HTMLElement>('.cm-md-block');
+
+		await vi.waitFor(() =>
+			expect(screen.container.querySelectorAll('.cm-md-block')).toHaveLength(2)
+		);
+
+		editor.focus();
+		editor.dispatch({ selection: { anchor: 0 } });
+		await settle();
+
+		expect(screen.container.querySelectorAll('.cm-md-block')).toHaveLength(1);
+		expect(second()?.style.paddingTop).toBe('0rem');
+	});
+
+	it('estimates a rendered block by its measured height', async () => {
+		const screen = render(MarkdownEditor, { value: 'A paragraph\nover two lines' });
+		const editor = editorIn(screen.container);
+		const widget = () => {
+			for (const source of editor.state.facet(EditorView.decorations)) {
+				if (typeof source === 'function') continue;
+
+				const cursor = source.iter();
+
+				if (cursor.value?.spec.widget) return cursor.value.spec.widget as WidgetType;
+			}
+		};
+
+		await vi.waitFor(() => {
+			const block = screen.container.querySelector<HTMLElement>('.cm-md-block');
+
+			expect(block?.querySelector('p')).not.toBeNull();
+			expect(widget()?.estimatedHeight).toBe(block!.offsetHeight);
+		});
+	});
+
+	it('copies a selection across rendered blocks', async () => {
+		const screen = render(MarkdownEditor, { value: '# Plan\n\n- one\n- two' });
+		const blocks = () => screen.container.querySelectorAll('.cm-md-block');
+
+		await vi.waitFor(() => expect(blocks()[1]?.querySelectorAll('li')).toHaveLength(2));
+
+		const range = document.createRange();
+
+		range.setStart(blocks()[0].querySelector('h1')!.firstChild!, 0);
+		range.setEnd(blocks()[1].querySelectorAll('li')[1].firstChild!, 3);
+		document.getSelection()?.removeAllRanges();
+		document.getSelection()?.addRange(range);
+
+		const clipboardData = new DataTransfer();
+
+		blocks()[0].dispatchEvent(
+			new ClipboardEvent('copy', { bubbles: true, cancelable: true, clipboardData })
+		);
+
+		expect(clipboardData.getData('text/plain').replace(/\s+/g, ' ')).toBe('Plan one two');
+		expect(clipboardData.getData('text/html')).toContain('<h1');
+	});
+
+	it('selects text in a rendered block on a drag and opens it on a click', async () => {
+		const screen = render(MarkdownEditor, { value: 'Some rendered text' });
+		const editor = editorIn(screen.container);
+		const paragraph = () => screen.container.querySelector('.cm-md-block p');
+
+		await vi.waitFor(() => expect(paragraph()?.textContent).toBe('Some rendered text'));
+
+		const rect = paragraph()!.getBoundingClientRect();
+		const y = rect.top + rect.height / 2;
+
+		mouse('mousedown', paragraph()!, rect.left + 1, y);
+		mouse('mousemove', paragraph()!, rect.left + rect.width / 2, y);
+		mouse('mouseup', paragraph()!, rect.left + rect.width / 2, y);
+		await settle();
+
+		expect(document.getSelection()?.toString().length).toBeGreaterThan(0);
+		expect(paragraph()).not.toBeNull();
+		expect(editor.hasFocus).toBe(false);
+
+		const clipboardData = new DataTransfer();
+
+		paragraph()!.dispatchEvent(
+			new ClipboardEvent('copy', { bubbles: true, cancelable: true, clipboardData })
+		);
+
+		expect(clipboardData.getData('text/plain')).toBe(document.getSelection()?.toString());
+
+		mouse('mousedown', paragraph()!, rect.left + 1, y);
+		mouse('mouseup', paragraph()!, rect.left + 1, y);
+
+		// the focus change reaches the editor a moment later
+		await vi.waitFor(() => {
+			expect(screen.container.querySelector('.cm-md-block')).toBeNull();
+			expect(editor.hasFocus).toBe(true);
+		});
+	});
+});
diff --git a/tools/ui/tests/stories/ChatScreenForm.stories.svelte b/tools/ui/tests/stories/ChatScreenForm.stories.svelte
index b41ccf0a1..ab23974b6 100644
--- a/tools/ui/tests/stories/ChatScreenForm.stories.svelte
+++ b/tools/ui/tests/stories/ChatScreenForm.stories.svelte
@@ -45,21 +45,21 @@
 	args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
 	name="Default"
 	play={async ({ canvas, userEvent }) => {
-		const textarea = await canvas.findByRole('textbox');
+		const editor = await canvas.findByRole('textbox');
 		const submitButton = await canvas.findByRole('button', { name: 'Send' });

 		// Expect the input to be focused after the component is mounted
-		await expect(textarea).toHaveFocus();
+		await expect(editor).toHaveFocus();

 		// Expect the submit button to be disabled
 		await expect(submitButton).toBeDisabled();

 		const text = 'What is the meaning of life?';

-		await userEvent.clear(textarea);
-		await userEvent.type(textarea, text);
+		await userEvent.clear(editor);
+		await userEvent.type(editor, text);

-		await expect(textarea).toHaveValue(text);
+		await expect(editor).toHaveTextContent(text);

 		const fileInput = document.querySelector('input[type="file"]');

diff --git a/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte b/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte
index c56bfd767..e96b9e881 100644
--- a/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte
+++ b/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte
@@ -18,10 +18,10 @@
 	args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
 	name="AddButtonSingleTabStop"
 	play={async ({ canvas, userEvent }) => {
-		const textarea = await canvas.findByRole('textbox');
+		const editor = await canvas.findByRole('textbox');

-		await userEvent.clear(textarea);
-		await userEvent.type(textarea, 'What is the meaning of life?');
+		await userEvent.clear(editor);
+		await userEvent.type(editor, 'What is the meaning of life?');

 		const trigger = await canvas.findByRole('button', { name: ATTACHMENT_TOOLTIP_TEXT });

diff --git a/tools/ui/tests/unit/chat-form-input-rich-tokenizer.test.ts b/tools/ui/tests/unit/chat-form-input-rich-tokenizer.test.ts
deleted file mode 100644
index 86baad207..000000000
--- a/tools/ui/tests/unit/chat-form-input-rich-tokenizer.test.ts
+++ /dev/null
@@ -1,215 +0,0 @@
-import { containsCodeSpan, isOffsetInCodeBlock, tokenizeContent } from '$lib/utils';
-import { describe, expect, it } from 'vitest';
-
-describe('tokenizeContent', () => {
-	it('tokenizes a plain text buffer with no badges', () => {
-		expect(tokenizeContent('hello world')).toEqual([{ kind: 'text', text: 'hello world' }]);
-	});
-
-	it('tokenizes a single badge', () => {
-		expect(tokenizeContent('[docs](file:///a/b)')).toEqual([
-			{ kind: 'badge', name: 'docs', path: '/a/b' }
-		]);
-	});
-
-	it('tokenizes text around a single badge', () => {
-		expect(tokenizeContent('hello [docs](file:///a/b) world')).toEqual([
-			{ kind: 'text', text: 'hello ' },
-			{ kind: 'badge', name: 'docs', path: '/a/b' },
-			{ kind: 'text', text: ' world' }
-		]);
-	});
-
-	it('tokenizes adjacent badges as separate tokens', () => {
-		expect(tokenizeContent('[a](file:///x)[b](file:///y)')).toEqual([
-			{ kind: 'badge', name: 'a', path: '/x' },
-			{ kind: 'badge', name: 'b', path: '/y' }
-		]);
-	});
-
-	it('leaves non-file links untouched in the stream', () => {
-		expect(tokenizeContent('see [foo](https://example.com) for details')).toEqual([
-			{ kind: 'text', text: 'see [foo](https://example.com) for details' }
-		]);
-	});
-
-	it('recognizes badges whose path contains spaces (macOS screenshots)', () => {
-		const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
-		const source = `[Screenshot 2026-07-28 at 17.21.50.png](file://${path}) `;
-
-		expect(tokenizeContent(source)).toEqual([
-			{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
-			{ kind: 'text', text: ' ' }
-		]);
-	});
-
-	it('recognizes badges whose path lives in the macOS temp folder', () => {
-		const path =
-			'/var/folders/78/j28m7pn57wb34bfjwlskh62h0000gn/T/TemporaryItems/NSIRD_screencaptureui_GD0A2R/Screenshot 2026-07-28 at 17.23.28.png';
-		const source = `[Screenshot 2026-07-28 at 17.23.28.png](file://${path}) `;
-
-		expect(tokenizeContent(source)).toEqual([
-			{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.23.28.png', path },
-			{ kind: 'text', text: ' ' }
-		]);
-	});
-
-	it('keeps text around a badge with spaces in the path', () => {
-		const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
-		const source = `see [Screenshot 2026-07-28 at 17.21.50.png](file://${path}) done`;
-
-		expect(tokenizeContent(source)).toEqual([
-			{ kind: 'text', text: 'see ' },
-			{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
-			{ kind: 'text', text: ' done' }
-		]);
-	});
-
-	it('recognizes badges whose path contains a close parenthesis (macOS duplicate files)', () => {
-		const path = '/Users/foo/Screenshot (1).png';
-		const source = `[Screenshot (1).png](file://${path}) `;
-
-		expect(tokenizeContent(source)).toEqual([
-			{ kind: 'badge', name: 'Screenshot (1).png', path },
-			{ kind: 'text', text: ' ' }
-		]);
-	});
-
-	it('recognizes badges whose folder name is wrapped in parentheses', () => {
-		const path = '/Users/foo/Project (Stuff)/main.rs';
-		const source = `[main.rs](file://${path}) `;
-
-		expect(tokenizeContent(source)).toEqual([
-			{ kind: 'badge', name: 'main.rs', path },
-			{ kind: 'text', text: ' ' }
-		]);
-	});
-
-	it('recognizes adjacent badges back-to-back with no separator', () => {
-		const source = '[a](file:///p)[b](file:///q)';
-
-		expect(tokenizeContent(source)).toEqual([
-			{ kind: 'badge', name: 'a', path: '/p' },
-			{ kind: 'badge', name: 'b', path: '/q' }
-		]);
-	});
-
-	it('tokenizes inline code with the backticks included', () => {
-		expect(tokenizeContent('run `npm test` now')).toEqual([
-			{ kind: 'text', text: 'run ' },
-			{ kind: 'code_inline', text: '`npm test`' },
-			{ kind: 'text', text: ' now' }
-		]);
-	});
-
-	it('tokenizes a fenced code block without a language', () => {
-		const source = 'before\n```\nconst a = 1;\n```\nafter';
-
-		expect(tokenizeContent(source)).toEqual([
-			{ kind: 'text', text: 'before\n' },
-			{ kind: 'code_block', text: '```\nconst a = 1;\n```' },
-			{ kind: 'text', text: '\nafter' }
-		]);
-	});
-
-	it('tokenizes a fenced code block with a language', () => {
-		const source = '```js\nconst a = 1;\n```';
-
-		expect(tokenizeContent(source)).toEqual([
-			{ kind: 'code_block', text: '```js\nconst a = 1;\n```' }
-		]);
-	});
-
-	it('prefers the fenced block over inline spans at triple backticks', () => {
-		expect(tokenizeContent('```a``` ```b```')).toEqual([
-			{ kind: 'code_block', text: '```a```' },
-			{ kind: 'text', text: ' ' },
-			{ kind: 'code_block', text: '```b```' }
-		]);
-	});
-
-	it('leaves an unclosed fence as plain text', () => {
-		expect(tokenizeContent('```js\nconst a = 1;')).toEqual([
-			{ kind: 'text', text: '```js\nconst a = 1;' }
-		]);
-	});
-
-	it('leaves an unclosed inline backtick as plain text', () => {
-		expect(tokenizeContent('run `npm test')).toEqual([{ kind: 'text', text: 'run `npm test' }]);
-	});
-
-	it('does not recognize badges inside code spans', () => {
-		expect(tokenizeContent('`[a](file:///p)`')).toEqual([
-			{ kind: 'code_inline', text: '`[a](file:///p)`' }
-		]);
-	});
-
-	it('tokenizes badges and code spans side by side', () => {
-		expect(tokenizeContent('[a](file:///p) `x`')).toEqual([
-			{ kind: 'badge', name: 'a', path: '/p' },
-			{ kind: 'text', text: ' ' },
-			{ kind: 'code_inline', text: '`x`' }
-		]);
-	});
-});
-
-describe('containsCodeSpan', () => {
-	it('detects inline code', () => {
-		expect(containsCodeSpan('run `npm test` now')).toBe(true);
-	});
-
-	it('detects a fenced block with a language', () => {
-		expect(containsCodeSpan('```js\nconst a = 1;\n```')).toBe(true);
-	});
-
-	it('detects a fenced block without a language', () => {
-		expect(containsCodeSpan('```\ncode\n```')).toBe(true);
-	});
-
-	it('ignores unclosed fences and lone backticks', () => {
-		expect(containsCodeSpan('```js\nconst a = 1;')).toBe(false);
-		expect(containsCodeSpan('run `npm test')).toBe(false);
-		expect(containsCodeSpan('``')).toBe(false);
-	});
-
-	it('ignores plain text and mention links', () => {
-		expect(containsCodeSpan('hello world')).toBe(false);
-		expect(containsCodeSpan('[a](file:///p)')).toBe(false);
-	});
-});
-
-describe('isOffsetInCodeBlock', () => {
-	const BLOCK = '```js\nconst a = 1;\n```';
-
-	it('is false with no fences in the buffer', () => {
-		expect(isOffsetInCodeBlock('hello world', 5)).toBe(false);
-		expect(isOffsetInCodeBlock('run `npm test` now', 10)).toBe(false);
-	});
-
-	it('is true right after the opening fence, before any content', () => {
-		expect(isOffsetInCodeBlock('```', 3)).toBe(true);
-		expect(isOffsetInCodeBlock('```js', 5)).toBe(true);
-	});
-
-	it('is true inside a still-open block while it is being typed', () => {
-		const open = '```js\nconst a = 1;';
-
-		expect(isOffsetInCodeBlock(open, open.length)).toBe(true);
-	});
-
-	it('is true inside a closed block and false outside it', () => {
-		expect(isOffsetInCodeBlock(BLOCK, 6)).toBe(true);
-		expect(isOffsetInCodeBlock(BLOCK, 0)).toBe(false);
-		expect(isOffsetInCodeBlock(BLOCK, BLOCK.length)).toBe(false);
-		expect(isOffsetInCodeBlock(BLOCK + '\nafter', BLOCK.length + 5)).toBe(false);
-	});
-
-	it('toggles per fence across multiple blocks', () => {
-		const two = BLOCK + '\ntext\n' + BLOCK;
-		const secondBlock = two.lastIndexOf(BLOCK);
-
-		expect(isOffsetInCodeBlock(two, secondBlock - 2)).toBe(false);
-		expect(isOffsetInCodeBlock(two, secondBlock + 6)).toBe(true);
-		expect(isOffsetInCodeBlock(two, two.length)).toBe(false);
-	});
-});
diff --git a/tools/ui/tests/unit/chat-form-input-rich-word-jump.test.ts b/tools/ui/tests/unit/chat-form-input-rich-word-jump.test.ts
deleted file mode 100644
index 03eb974e5..000000000
--- a/tools/ui/tests/unit/chat-form-input-rich-word-jump.test.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { badgeAwareWordJump, leadingBadgeEdgeOffset } from '$lib/utils';
-import { describe, expect, it } from 'vitest';
-
-// Layout of `hello [docs](file:///a/b) world foo`:
-//   "hello" 0-4, " " 5, badge 6-24 (length 19), " " 25, "world" 26-30, " " 31, "foo" 32-34
-const BADGE = '[docs](file:///a/b)';
-const SOURCE = `hello ${BADGE} world foo`;
-const BADGE_START = 6;
-const BADGE_END = 25;
-
-describe('badgeAwareWordJump', () => {
-	it('returns null when the buffer has no badge', () => {
-		expect(badgeAwareWordJump('hello world', 0, 'forward')).toBeNull();
-		expect(badgeAwareWordJump('hello world', 11, 'backward')).toBeNull();
-	});
-
-	it('jumps forward onto a badge landing at its end, not the next word', () => {
-		expect(badgeAwareWordJump(SOURCE, BADGE_START, 'forward')).toBe(BADGE_END);
-	});
-
-	it('jumps forward from the space before a badge landing at its end', () => {
-		expect(badgeAwareWordJump(SOURCE, BADGE_START - 1, 'forward')).toBe(BADGE_END);
-	});
-
-	it('jumps backward over a badge landing at its start', () => {
-		expect(badgeAwareWordJump(SOURCE, BADGE_END, 'backward')).toBe(BADGE_START);
-	});
-
-	it('jumps backward from the next word onto the badge start', () => {
-		// caret at the start of "world"
-		expect(badgeAwareWordJump(SOURCE, BADGE_END + 1, 'backward')).toBe(BADGE_START);
-	});
-
-	it('returns null for jumps that cross no badge', () => {
-		// forward over "hello" only
-		expect(badgeAwareWordJump(SOURCE, 0, 'forward')).toBeNull();
-		// backward over "foo" only
-		expect(badgeAwareWordJump(SOURCE, SOURCE.length, 'backward')).toBeNull();
-		// backward away from the badge (over "hello")
-		expect(badgeAwareWordJump(SOURCE, BADGE_START, 'backward')).toBeNull();
-	});
-
-	it('treats a leading badge as one word in both directions', () => {
-		const source = `${BADGE} rest`;
-
-		expect(badgeAwareWordJump(source, 0, 'forward')).toBe(BADGE.length);
-		expect(badgeAwareWordJump(source, BADGE.length, 'backward')).toBe(0);
-	});
-
-	it('treats adjacent badges as separate words', () => {
-		// each badge is 14 chars: "[a](file:///x)" / "[b](file:///y)"
-		const source = '[a](file:///x)[b](file:///y)';
-
-		expect(badgeAwareWordJump(source, 0, 'forward')).toBe(14);
-		expect(badgeAwareWordJump(source, 14, 'forward')).toBe(28);
-		expect(badgeAwareWordJump(source, 28, 'backward')).toBe(14);
-		expect(badgeAwareWordJump(source, 14, 'backward')).toBe(0);
-	});
-
-	it('jumps over a badge following punctuation', () => {
-		// "foo," 0-3, " " 4, badge 5-23 (end 24), " bar" 24-27
-		const source = `foo, ${BADGE} bar`;
-
-		expect(badgeAwareWordJump(source, 0, 'forward')).toBeNull();
-		expect(badgeAwareWordJump(source, 3, 'forward')).toBe(24);
-	});
-});
-
-describe('leadingBadgeEdgeOffset', () => {
-	it('returns 0 when the caret sits exactly at a leading badge end', () => {
-		expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length)).toBe(0);
-	});
-
-	it('returns null when the caret is anywhere else', () => {
-		expect(leadingBadgeEdgeOffset(`${BADGE} rest`, 0)).toBeNull();
-		expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length + 2)).toBeNull();
-	});
-
-	it('returns null when the buffer does not start with a badge', () => {
-		expect(leadingBadgeEdgeOffset(SOURCE, BADGE_END)).toBeNull();
-		expect(leadingBadgeEdgeOffset('', 0)).toBeNull();
-	});
-});
diff --git a/tools/ui/tests/unit/markdown-syntax.test.ts b/tools/ui/tests/unit/markdown-syntax.test.ts
new file mode 100644
index 000000000..e003b8edb
--- /dev/null
+++ b/tools/ui/tests/unit/markdown-syntax.test.ts
@@ -0,0 +1,64 @@
+import { markdownLanguage } from '@codemirror/lang-markdown';
+import { LanguageSupport } from '@codemirror/language';
+import { EditorState } from '@codemirror/state';
+import {
+	codeRanges,
+	isInCodeBlock
+} from '$lib/components/app/content/MarkdownEditor/markdown-syntax';
+import { describe, expect, it } from 'vitest';
+
+function stateOf(doc: string): EditorState {
+	return EditorState.create({ doc, extensions: new LanguageSupport(markdownLanguage) });
+}
+
+function inCode(doc: string, pos = doc.length): boolean {
+	return isInCodeBlock(stateOf(doc), pos);
+}
+
+describe('isInCodeBlock', () => {
+	it('is false in plain text and inline code', () => {
+		expect(inCode('hello world', 5)).toBe(false);
+		expect(inCode('run `npm test` now', 10)).toBe(false);
+	});
+
+	it('is true right after an opening fence and in a still-open block', () => {
+		expect(inCode('```')).toBe(true);
+		expect(inCode('```js')).toBe(true);
+		expect(inCode('```js\nconst a = 1;')).toBe(true);
+	});
+
+	it('knows tilde fences and indented code', () => {
+		expect(inCode('~~~\ncode')).toBe(true);
+		expect(inCode('text\n\n    indented')).toBe(true);
+	});
+
+	it('is true inside a closed block and false after or before it', () => {
+		const block = '```\ncode\n```';
+
+		expect(inCode(block, 6)).toBe(true);
+		expect(inCode(block, 0)).toBe(false);
+		expect(inCode(block)).toBe(false);
+		expect(inCode(`${block}\nafter`)).toBe(false);
+	});
+
+	it('tells two blocks apart', () => {
+		const two = '```\na\n```\nmid\n```\nb\n```';
+		const second = two.lastIndexOf('```\nb');
+
+		expect(inCode(two, two.indexOf('mid') + 1)).toBe(false);
+		expect(inCode(two, second + 5)).toBe(true);
+		expect(inCode(two)).toBe(false);
+	});
+});
+
+describe('codeRanges', () => {
+	it('lists inline code and code blocks in range', () => {
+		const doc = 'a `b` c\n\n~~~\nd\n~~~';
+		const state = stateOf(doc);
+
+		expect(codeRanges(state, 0, doc.length)).toEqual([
+			[2, 5],
+			[9, doc.length]
+		]);
+	});
+});
diff --git a/tools/ui/tests/unit/source-history.test.ts b/tools/ui/tests/unit/source-history.test.ts
deleted file mode 100644
index 33869a6e8..000000000
--- a/tools/ui/tests/unit/source-history.test.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { SourceHistory } from '$lib/utils';
-import { describe, expect, it } from 'vitest';
-
-describe('SourceHistory', () => {
-	it('coalesces pushes inside the group window into one undo step', () => {
-		const h = new SourceHistory(100, 800);
-
-		h.push({ caret: 0, value: '' }, 1000);
-		h.push({ caret: 1, value: 'a' }, 1200);
-		h.push({ caret: 2, value: 'ab' }, 1500);
-
-		expect(h.undo({ caret: 3, value: 'abc' })).toEqual({ caret: 0, value: '' });
-		expect(h.undo({ caret: 0, value: '' })).toBeNull();
-	});
-
-	it('starts a new group once the window has passed', () => {
-		const h = new SourceHistory(100, 800);
-
-		h.push({ caret: 0, value: '' }, 1000);
-		h.push({ caret: 3, value: 'abc' }, 2000);
-
-		expect(h.undo({ caret: 6, value: 'abcdef' })).toEqual({ caret: 3, value: 'abc' });
-		expect(h.undo({ caret: 3, value: 'abc' })).toEqual({ caret: 0, value: '' });
-	});
-
-	it('newGroup forces a separate entry even inside the window', () => {
-		const h = new SourceHistory(100, 800);
-
-		h.push({ caret: 0, value: '' }, 1000);
-		h.push({ caret: 3, value: 'abc' }, 1100, true);
-
-		expect(h.undo({ caret: 4, value: 'abc\n' })).toEqual({ caret: 3, value: 'abc' });
-		expect(h.undo({ caret: 3, value: 'abc' })).toEqual({ caret: 0, value: '' });
-	});
-
-	it('redo round-trips and a fresh push clears the redo stack', () => {
-		const h = new SourceHistory(100, 800);
-
-		h.push({ caret: 0, value: '' }, 1000);
-
-		const undone = h.undo({ caret: 3, value: 'abc' });
-
-		expect(undone).toEqual({ caret: 0, value: '' });
-		expect(h.redo({ caret: 0, value: '' })).toEqual({ caret: 3, value: 'abc' });
-
-		h.undo({ caret: 3, value: 'abc' });
-		h.push({ caret: 0, value: '' }, 5000);
-		expect(h.redo({ caret: 1, value: 'x' })).toBeNull();
-	});
-
-	it('starts a new group on the first edit after an undo', () => {
-		const h = new SourceHistory(100, 800);
-
-		h.push({ caret: 0, value: '' }, 1000);
-		h.undo({ caret: 3, value: 'abc' });
-
-		h.push({ caret: 0, value: '' }, 1200);
-		expect(h.undo({ caret: 1, value: 'x' })).toEqual({ caret: 0, value: '' });
-	});
-
-	it('evicts the oldest entry past the limit', () => {
-		const h = new SourceHistory(2, 800);
-
-		h.push({ caret: 0, value: 'one' }, 1000);
-		h.push({ caret: 0, value: 'two' }, 2000);
-		h.push({ caret: 0, value: 'three' }, 3000);
-
-		expect(h.undo({ caret: 0, value: 'cur' })).toEqual({ caret: 0, value: 'three' });
-		expect(h.undo({ caret: 0, value: 'three' })).toEqual({ caret: 0, value: 'two' });
-		expect(h.undo({ caret: 0, value: 'two' })).toBeNull();
-	});
-});