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;