Render composer code blocks from typed fences

Switch the chat composer to a Markdown-backed contenteditable editor so typing a triple-backtick fence and pressing Enter creates a visual code block while pasted fenced Markdown stays plain text. Serialize visual code blocks back to fenced Markdown for send/history, keep full-screen input in sync with the backing store, and update the focused composer regression plus DOX contract.

Alessandro committed Jul 1, 2026 at 16:01 UTC b805773c91ae95e61785a29841e1095977814118
6 files changed +302 -85
tests/test_welcome_composer_static.py
+10 -5
@@ -145,13 +145,18 @@ def test_welcome_hover_and_focus_borders_use_neutral_tokens() -> None:
145 assert "border-color: color-mix(in srgb, var(--color-border) 88%, var(--color-text) 12%)" in surfaces
146
147
148 -def test_composer_uses_rubik_until_caret_enters_fenced_code() -> None:
148 +def test_composer_creates_visual_code_blocks_from_entered_fence() -> None:
149 chat_bar = _read("webui/components/chat/input/chat-bar-input.html")
150 input_store = _read("webui/components/chat/input/input-store.js")
151
152 + assert 'contenteditable="true"' in chat_bar
153 assert "font-family: var(--font-family-main" in chat_bar
153 - assert "#chat-input.is-code-context" in chat_bar
154 + assert ".composer-code-content" in chat_bar
155 assert "font-family: var(--font-family-code" in chat_bar
155 - assert "'is-code-context': $store.chatInput.isCodeContext" in chat_bar
156 - assert "_isCaretInsideFence(text, index)" in input_store
157 - assert "matches.length % 2 === 1" in input_store
156 + assert "FENCE_LINE_RE" in input_store
157 + assert "_tryCreateCodeBlock($event)" in input_store
158 + assert 'data-code-block' in input_store
159 + assert "handlePaste($event)" in input_store
160 + assert "_insertPlainText(text)" in input_store
161 + assert "is-code-context" not in chat_bar
162 + assert "_isCaretInsideFence" not in input_store
webui/components/chat/AGENTS.md
+1 -1
@@ -18,7 +18,7 @@
18 - Use shared API, WebSocket, notification, and attachment helpers where available.
19 - Do not bypass CSRF or WebSocket state-sync expectations.
20 - The shared composer can be mounted on the Welcome screen with no selected chat; sending from that state must create and select a chat context before dispatch.
21 -- Composer text uses the main UI font by default and switches to the code font while the caret is inside an open triple-backtick fenced block.
21 +- Composer text uses the main UI font by default; typing a triple-backtick fence and pressing Enter turns that line into a visual code block that serializes back to fenced Markdown, while pasted fenced Markdown stays plain text.
22
23 ## Work Guidance
24
webui/components/chat/input/chat-bar-input.html
+33 -18
@@ -24,17 +24,16 @@
24 </div>
25 </div>
26
27 - <!-- Container for textarea and expand button -->
27 + <!-- Container for composer and expand button -->
28 <div id="chat-input-container">
29 - <textarea id="chat-input" :placeholder="$store.chatInput.inputPlaceholder" rows="1"
30 - @keydown.enter="if (!$event.shiftKey && !$event.isComposing && $event.keyCode !== 229) { $event.preventDefault(); $store.chatInput.sendMessage(); }"
31 - @keydown.up="$store.chatInput.historyPrev($event)" @keydown.down="$store.chatInput.historyNext($event)"
29 + <div id="chat-input" role="textbox" aria-label="Message" aria-multiline="true" contenteditable="true"
30 + spellcheck="true" :data-placeholder="$store.chatInput.inputPlaceholder"
31 + @keydown="$store.chatInput.handleKeydown($event)"
32 @input="$store.chatInput.handleInput($event)"
33 - @keyup="$store.chatInput._syncCaretFromEvent($event)"
34 - @click="$store.chatInput._syncCaretFromEvent($event)"
35 - @select="$store.chatInput._syncCaretFromEvent($event)"
36 - :class="{ 'progress-active': $store.chatInput.progressActive, 'is-code-context': $store.chatInput.isCodeContext }"
37 - x-model="$store.chatInput.message"></textarea>
33 + @paste="$store.chatInput.handlePaste($event)"
34 + :class="{ 'progress-active': $store.chatInput.progressActive, 'is-empty': !$store.chatInput.message }"
35 + x-create="$store.chatInput.mountEditor($el)"
36 + x-destroy="$store.chatInput.unmountEditor($el)"></div>
37 <div id="chat-input-progress-placeholder" x-show="$store.chatInput.showProgressPlaceholder"
38 x-html="$store.chatInput.progressPlaceholderHtml"
39 :class="{ 'progress-active': $store.chatInput.progressActive }" aria-hidden="true" style="display: none;">
@@ -174,6 +173,8 @@
173 padding: 0.68rem 2.2rem 0.68rem 0.1rem;
174 margin: 0;
175 overflow-y: auto;
176 + white-space: pre-wrap;
177 + word-break: break-word;
178 scroll-behavior: smooth;
179 resize: none;
180 align-content: center;
@@ -183,10 +184,30 @@
184 -webkit-transition: color 0.16s ease, background-color 0.16s ease;
185 }
186
186 - #chat-input.is-code-context {
187 + #chat-input.is-empty::before {
188 + content: attr(data-placeholder);
189 + color: color-mix(in srgb, var(--color-text) 62%, transparent);
190 + opacity: 0.78;
191 + pointer-events: none;
192 + }
193 +
194 + #chat-input .composer-code-block {
195 + display: block;
196 + margin: 0.48rem 0;
197 + padding: 0.56rem 0.7rem;
198 + border-radius: 6px;
199 + background: color-mix(in srgb, var(--color-border) 28%, transparent);
200 + }
201 +
202 + #chat-input .composer-code-content {
203 + min-height: 1.35rem;
204 + margin: 0;
205 + outline: none;
206 + white-space: pre-wrap;
207 + word-break: break-word;
208 font-family: var(--font-family-code, "Roboto Mono", monospace);
209 font-size: 0.94rem;
189 - align-content: start;
210 + line-height: 1.45;
211 }
212
213 #chat-input::-webkit-scrollbar {
@@ -216,14 +237,8 @@
237 background-color: transparent;
238 }
239
219 - #chat-input::placeholder {
220 - color: color-mix(in srgb, var(--color-text) 62%, transparent);
221 - opacity: 0.78;
222 - transition: color 0.16s ease;
223 - }
224 -
240 /* Progress ghost text animation — gentle pulse on placeholder */
226 - #chat-input.progress-active::placeholder {
241 + #chat-input.progress-active.is-empty::before {
242 animation: placeholder-pulse 2s ease-in-out infinite;
243 }
244
webui/components/chat/input/input-store.js
+251 -50
@@ -7,6 +7,8 @@ import { store as attachmentsStore } from "/components/chat/attachments/attachme
7 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
8
9 const ICON_MARKER_RE = /icon:\/\/([a-zA-Z0-9_]+)(\[(?:\\.|[^\]])*\])?/g;
10 +const FENCE_LINE_RE = /^```([A-Za-z0-9_-]*)?$/;
11 +const BLOCK_TAGS = new Set(["DIV", "P", "LI"]);
12
13 function escapeHTML(value) {
14 return String(value ?? "")
@@ -48,7 +50,8 @@ function convertIconMarkersToHtml(value) {
50
51 const model = {
52 paused: false,
51 - message: "",
53 + _message: "",
54 + _editorEl: null,
55 _history: [],
56 _historyIndex: null,
57 _draft: "",
@@ -57,7 +60,15 @@ const model = {
60 chatMoreMenuOpen: false,
61 progressText: "",
62 progressActive: false,
60 - _caretIndex: 0,
63 +
64 + get message() {
65 + return this._message;
66 + },
67 +
68 + set message(value) {
69 + this._message = String(value ?? "");
70 + this._renderEditorFromText(this._message);
71 + },
72
73 toggleChatMoreMenu() {
74 this.chatMoreMenuOpen = !this.chatMoreMenuOpen;
@@ -85,10 +96,6 @@ const model = {
96 return "Type your message here...";
97 },
98
88 - get isCodeContext() {
89 - return this._isCaretInsideFence(this.message, this._caretIndex);
90 - },
91 -
99 get showProgressPlaceholder() {
100 return (
101 !!chatsStore.selected &&
@@ -135,6 +142,8 @@ const model = {
142 },
143
144 async sendMessage() {
145 + this._syncMessageFromEditor();
146 +
147 // Capture sent prompt to per-chat history (bash-style)
148 try { this._pushHistory(this.message); } catch (_e) { /* ignore */ }
149
@@ -149,48 +158,244 @@ const model = {
158 }
159 },
160
161 + mountEditor(editor) {
162 + this._editorEl = editor;
163 + this._renderEditorFromText(this._message);
164 + this.adjustTextareaHeight({ target: editor });
165 + },
166 +
167 + unmountEditor(editor) {
168 + if (this._editorEl === editor) this._editorEl = null;
169 + },
170 +
171 _composerTextareas(target = null) {
153 - if (target?.tagName === "TEXTAREA") return [target];
154 - return ["chat-input"]
155 - .map((id) => document.getElementById(id))
156 - .filter(Boolean);
172 + const editor = target?.closest?.("#chat-input") || this._editorEl || document.getElementById("chat-input");
173 + return editor ? [editor] : [];
174 },
175
176 _activeTextarea() {
177 const active = document.activeElement;
161 - if (active?.id === "chat-input") {
162 - return active;
178 + const activeEditor = active?.closest?.("#chat-input");
179 + if (activeEditor) {
180 + return activeEditor;
181 }
164 - return document.getElementById("chat-input");
182 + return this._editorEl || document.getElementById("chat-input");
183 },
184
167 - _syncCaretFromEvent($event = null) {
168 - const ta = ($event && $event.target?.tagName === "TEXTAREA")
169 - ? $event.target
170 - : this._activeTextarea();
171 - if (!ta) {
172 - this._caretIndex = this.message.length;
173 - return;
185 + _renderEditorFromText(text) {
186 + const editor = this._editorEl;
187 + if (!editor) return;
188 + if (editor.textContent !== text || editor.querySelector("[data-code-block]")) {
189 + editor.textContent = text;
190 + }
191 + this._setEditorEmptyState();
192 + },
193 +
194 + _setEditorEmptyState() {
195 + const editor = this._editorEl;
196 + if (editor) editor.classList.toggle("is-empty", !this._message);
197 + },
198 +
199 + _createCodeBlock(lang = "") {
200 + const block = document.createElement("div");
201 + block.className = "composer-code-block";
202 + block.dataset.codeBlock = "true";
203 + block.dataset.lang = lang;
204 + block.contentEditable = "false";
205 +
206 + const code = document.createElement("pre");
207 + code.className = "composer-code-content";
208 + code.dataset.codeContent = "true";
209 + code.contentEditable = "true";
210 + code.spellcheck = false;
211 + code.setAttribute("aria-label", "Code block");
212 + block.append(code);
213 +
214 + return block;
215 + },
216 +
217 + _plainText(node) {
218 + if (!node) return "";
219 + if (node.nodeType === Node.TEXT_NODE) return node.nodeValue || "";
220 + if (node.nodeType !== Node.ELEMENT_NODE) return "";
221 + if (node.matches?.("[data-code-block]")) {
222 + const lang = node.dataset.lang || "";
223 + const code = this._plainText(node.querySelector("[data-code-content]")).replace(/\n$/, "");
224 + return "```" + lang + "\n" + code + "\n```";
225 + }
226 + if (node.tagName === "BR") return "\n";
227 +
228 + let text = "";
229 + for (const child of node.childNodes) text += this._plainText(child);
230 + if (node !== this._editorEl && BLOCK_TAGS.has(node.tagName) && text && !text.endsWith("\n")) {
231 + text += "\n";
232 + }
233 + return text;
234 + },
235 +
236 + _editorToMarkdown() {
237 + return this._plainText(this._editorEl).replace(/\n$/, "");
238 + },
239 +
240 + _syncMessageFromEditor() {
241 + if (!this._editorEl) return;
242 + this._message = this._editorToMarkdown();
243 + this._setEditorEmptyState();
244 + },
245 +
246 + _isInCodeBlock(target) {
247 + return Boolean(target?.closest?.("[data-code-content]"));
248 + },
249 +
250 + _selectionOffsets(editor) {
251 + const selection = document.getSelection?.();
252 + if (!selection || selection.rangeCount === 0 || !editor) {
253 + return { start: this._message.length, end: this._message.length };
254 }
175 - this._caretIndex = Number.isFinite(ta.selectionStart) ? ta.selectionStart : this.message.length;
255 + const range = selection.getRangeAt(0);
256 + if (!editor.contains(range.startContainer) || !editor.contains(range.endContainer)) {
257 + return { start: this._message.length, end: this._message.length };
258 + }
259 +
260 + const before = range.cloneRange();
261 + before.selectNodeContents(editor);
262 + before.setEnd(range.startContainer, range.startOffset);
263 +
264 + const selected = range.cloneRange();
265 + return {
266 + start: before.toString().length,
267 + end: before.toString().length + selected.toString().length,
268 + };
269 + },
270 +
271 + _setEditorCaret(offset) {
272 + const editor = this._activeTextarea();
273 + const selection = document.getSelection?.();
274 + if (!editor || !selection) return;
275 +
276 + editor.focus();
277 + const range = document.createRange();
278 + let remaining = Math.max(0, Number(offset) || 0);
279 + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
280 + let node = walker.nextNode();
281 +
282 + while (node) {
283 + const length = node.nodeValue.length;
284 + if (remaining <= length) {
285 + range.setStart(node, remaining);
286 + range.collapse(true);
287 + selection.removeAllRanges();
288 + selection.addRange(range);
289 + return;
290 + }
291 + remaining -= length;
292 + node = walker.nextNode();
293 + }
294 +
295 + range.selectNodeContents(editor);
296 + range.collapse(false);
297 + selection.removeAllRanges();
298 + selection.addRange(range);
299 + },
300 +
301 + _focusCodeBlock(block) {
302 + queueMicrotask(() => {
303 + const code = block.querySelector("[data-code-content]");
304 + const selection = document.getSelection?.();
305 + if (!code || !selection) return;
306 + code.focus();
307 + const range = document.createRange();
308 + range.selectNodeContents(code);
309 + range.collapse(false);
310 + selection.removeAllRanges();
311 + selection.addRange(range);
312 + });
313 + },
314 +
315 + _insertPlainText(text) {
316 + const selection = document.getSelection?.();
317 + if (!selection || selection.rangeCount === 0) return false;
318 + const range = selection.getRangeAt(0);
319 + range.deleteContents();
320 + const node = document.createTextNode(text);
321 + range.insertNode(node);
322 + range.setStart(node, node.nodeValue.length);
323 + range.collapse(true);
324 + selection.removeAllRanges();
325 + selection.addRange(range);
326 + return true;
327 },
328
178 - _isCaretInsideFence(text, index) {
179 - const source = String(text || "");
180 - const caret = Math.max(0, Math.min(source.length, Number(index) || 0));
181 - const matches = source.slice(0, caret).match(/```/g);
182 - return Boolean(matches && matches.length % 2 === 1);
329 + _tryCreateCodeBlock($event) {
330 + const editor = this._activeTextarea();
331 + const selection = document.getSelection?.();
332 + if (!editor || !selection || selection.rangeCount === 0) return false;
333 +
334 + const range = selection.getRangeAt(0);
335 + if (!range.collapsed || !editor.contains(range.startContainer)) return false;
336 + if (this._isInCodeBlock(range.startContainer.parentElement)) return false;
337 +
338 + const before = range.cloneRange();
339 + before.selectNodeContents(editor);
340 + before.setEnd(range.startContainer, range.startOffset);
341 +
342 + const after = range.cloneRange();
343 + after.selectNodeContents(editor);
344 + after.setStart(range.startContainer, range.startOffset);
345 +
346 + const lineBefore = before.toString().split("\n").pop() || "";
347 + const lineAfter = (after.toString().split("\n")[0] || "");
348 + const match = (lineBefore + lineAfter).match(FENCE_LINE_RE);
349 + if (!match || lineAfter) return false;
350 + if (range.startContainer.nodeType !== Node.TEXT_NODE || range.startOffset < lineBefore.length) return false;
351 +
352 + $event.preventDefault();
353 + const editRange = range.cloneRange();
354 + editRange.setStart(range.startContainer, range.startOffset - lineBefore.length);
355 + editRange.deleteContents();
356 +
357 + const block = this._createCodeBlock(match[1] || "");
358 + editRange.insertNode(block);
359 + block.after(document.createTextNode("\n"));
360 + this._focusCodeBlock(block);
361 + this._syncMessageFromEditor();
362 + this.adjustTextareaHeight({ target: editor });
363 + return true;
364 },
365
366 handleInput($event) {
186 - this._syncCaretFromEvent($event);
367 + this._syncMessageFromEditor();
368 this.adjustTextareaHeight($event);
369 },
370
371 + handlePaste($event) {
372 + const text = $event.clipboardData?.getData("text/plain");
373 + if (text === undefined) return;
374 + $event.preventDefault();
375 + this._insertPlainText(text);
376 + this.handleInput($event);
377 + },
378 +
379 + handleKeydown($event) {
380 + if ($event.isComposing || $event.keyCode === 229) return;
381 +
382 + if ($event.key === "Enter") {
383 + if (this._isInCodeBlock($event.target)) return;
384 + if (!$event.shiftKey) {
385 + if (this._tryCreateCodeBlock($event)) return;
386 + $event.preventDefault();
387 + this.sendMessage();
388 + }
389 + return;
390 + }
391 +
392 + if ($event.key === "ArrowUp") this.historyPrev($event);
393 + if ($event.key === "ArrowDown") this.historyNext($event);
394 + },
395 +
396 adjustTextareaHeight($event = null) {
397 const target = $event?.target || null;
398 for (const chatInput of this._composerTextareas(target)) {
193 - if (!this.message) chatInput.value = "";
399 chatInput.style.height = "auto";
400 chatInput.style.height = chatInput.scrollHeight + "px";
401 // pick up any layout shift triggered by the height assignment
@@ -437,11 +642,10 @@ const model = {
642
643 _setCaretStart() {
644 queueMicrotask(() => {
440 - const ta = this._activeTextarea();
441 - if (ta) {
442 - try { ta.setSelectionRange(0, 0); } catch (_e) { /* ignore */ }
443 - try { ta.scrollTop = 0; } catch (_e) { /* ignore */ }
444 - this._syncCaretFromEvent({ target: ta });
645 + const editor = this._activeTextarea();
646 + if (editor) {
647 + this._setEditorCaret(0);
648 + try { editor.scrollTop = 0; } catch (_e) { /* ignore */ }
649 }
650 this.adjustTextareaHeight();
651 });
@@ -449,12 +653,10 @@ const model = {
653
654 _setCaretEnd() {
655 queueMicrotask(() => {
452 - const ta = this._activeTextarea();
453 - if (ta) {
454 - const end = ta.value.length;
455 - try { ta.setSelectionRange(end, end); } catch (_e) { /* ignore */ }
456 - try { ta.scrollTop = ta.scrollHeight; } catch (_e) { /* ignore */ }
457 - this._syncCaretFromEvent({ target: ta });
656 + const editor = this._activeTextarea();
657 + if (editor) {
658 + this._setEditorCaret(editor.innerText.length);
659 + try { editor.scrollTop = editor.scrollHeight; } catch (_e) { /* ignore */ }
660 }
661 this.adjustTextareaHeight();
662 });
@@ -462,10 +664,10 @@ const model = {
664
665 historyPrev($event) {
666 if ($event && ($event.isComposing || $event.keyCode === 229)) return;
465 - const ta = ($event && $event.target) ? $event.target : this._activeTextarea();
466 - if (!ta) return;
467 - const start = ta.selectionStart;
468 - const end = ta.selectionEnd;
667 + if (this._isInCodeBlock($event?.target)) return;
668 + const editor = this._activeTextarea();
669 + if (!editor) return;
670 + const { start, end } = this._selectionOffsets(editor);
671 if (start !== 0 || end !== 0) return;
672 $event.preventDefault();
673 this._ensureHistoryLoaded();
@@ -484,12 +686,12 @@ const model = {
686
687 historyNext($event) {
688 if ($event && ($event.isComposing || $event.keyCode === 229)) return;
487 - const ta = ($event && $event.target) ? $event.target : this._activeTextarea();
488 - if (!ta) return;
489 - const value = ta.value;
490 - const start = ta.selectionStart;
491 - const end = ta.selectionEnd;
492 - if (start !== value.length || end !== value.length) return;
689 + if (this._isInCodeBlock($event?.target)) return;
690 + const editor = this._activeTextarea();
691 + if (!editor) return;
692 + const { start, end } = this._selectionOffsets(editor);
693 + const valueLength = editor.innerText.length;
694 + if (start !== valueLength || end !== valueLength) return;
695 $event.preventDefault();
696 if (this._historyIndex === null) return;
697 if (this._historyIndex < this._history.length - 1) {
@@ -507,7 +709,6 @@ const model = {
709 this.message = "";
710 attachmentsStore.clearAttachments();
711 this.chatMoreMenuOpen = false;
510 - this._caretIndex = 0;
712 this._historyIndex = null;
713 this._draft = "";
714 this.adjustTextareaHeight();
webui/components/modals/full-screen-input/full-screen-store.js
+4 -8
@@ -1,4 +1,5 @@
1 import { createStore } from "/js/AlpineStore.js";
2 +import { store as chatInputStore } from "/components/chat/input/input-store.js";
3
4 // Store model for the Full-Screen Input Modal
5 const model = {
@@ -18,8 +19,7 @@ const model = {
19
20 // Open modal with current chat input content
21 openModal() {
21 - const chatInput = document.getElementById("chat-input");
22 - this.inputText = chatInput ? chatInput.value : this.inputText;
22 + this.inputText = chatInputStore.message || "";
23 this.lastSavedState = this.inputText;
24 this.isOpen = true;
25 this.undoStack = [];
@@ -34,11 +34,8 @@ const model = {
34
35 // Close modal and write value back into main chat input
36 handleClose() {
37 - const chatInput = document.getElementById("chat-input");
38 - if (chatInput) {
39 - chatInput.value = this.inputText;
40 - chatInput.dispatchEvent(new Event("input")); // trigger auto-resize
41 - }
37 + chatInputStore.message = this.inputText;
38 + chatInputStore.adjustTextareaHeight();
39 this.isOpen = false;
40 },
41
@@ -88,4 +85,3 @@ const model = {
85
86 export const store = createStore("fullScreenInputModal", model);
87
91 -
webui/components/welcome/welcome-screen.html
+3 -3
@@ -862,12 +862,12 @@
862 align-content: center;
863 }
864
865 - .welcome-composer #chat-input:placeholder-shown {
865 + .welcome-composer #chat-input.is-empty {
866 overflow-y: hidden;
867 scrollbar-width: none;
868 }
869
870 - .welcome-composer #chat-input:placeholder-shown::-webkit-scrollbar {
870 + .welcome-composer #chat-input.is-empty::-webkit-scrollbar {
871 display: none;
872 }
873
@@ -938,7 +938,7 @@
938 }
939
940 @media (max-width: 420px) {
941 - .welcome-composer #chat-input:placeholder-shown {
941 + .welcome-composer #chat-input.is-empty {
942 min-height: 5rem;
943 padding-top: 0.52rem;
944 padding-bottom: 0.52rem;