diff --git a/static/ui.js b/static/ui.js index cdd95429..c6bc2104 100644 --- a/static/ui.js +++ b/static/ui.js @@ -51,6 +51,43 @@ function _setCompressionSessionLock(sid){ } const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +/** + * Render fenced code blocks inside user messages. + * Extracts ```…``` fences, replaces them with placeholders, + * escapes remaining text as plain HTML, then restores code blocks + * with the same
pipeline used by renderMd().
+ * All non-fenced text stays escaped (no bold/italic/link interpretation).
+ */
+function _renderUserFencedBlocks(text){
+ const stash=[];
+ let s=String(text||'');
+ // Extract fenced code blocks → stash, replace with null-token placeholder
+ s=s.replace(/```([a-zA-Z0-9_+-]*)\n([\s\S]*?)```/g,(_,lang,code)=>{
+ lang=(lang||'').trim().toLowerCase();
+ // Remove one trailing newline if present (the fence consumes its own)
+ if(code.endsWith('\n')) code=code.slice(0,-1);
+ const h=lang?`${esc(lang)}`:'';
+ const langAttr=lang?` class="language-${esc(lang)}"`:'';
+ if(lang==='diff'||lang==='patch'){
+ const colored=esc(code).split('\n').map(line=>{
+ if(line.startsWith('@@')) return `${line}`;
+ if(line.startsWith('+')) return `${line}`;
+ if(line.startsWith('-')) return `${line}`;
+ return `${line}`;
+ }).join('\n');
+ stash.push(`${h}${colored}
`);
+ } else {
+ stash.push(`${h}${esc(code)}
`);
+ }
+ return '\x00UF'+(stash.length-1)+'\x00';
+ });
+ // Escape remaining plain text and convert newlines to
+ s=esc(s).replace(/\n/g,'
');
+ // Restore stashed code blocks
+ s=s.replace(/\x00UF(\d+)\x00/g,(_,i)=>stash[+i]);
+ return s;
+}
+
/* ── Image lightbox — click any .msg-media-img to enlarge ─────────────────── */
function _openImgLightbox(src, alt) {
const lb = document.createElement('div');
@@ -3047,7 +3084,7 @@ function renderMessages(){
return _renderAttachmentHtml(fname,fileUrl);
}).join('')}`;
}
- const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'
') : renderMd(_stripXmlToolCallsDisplay(String(content)));
+ const bodyHtml = isUser ? _renderUserFencedBlocks(content) : renderMd(_stripXmlToolCallsDisplay(String(content)));
const isEditableUser=isUser&&rawIdx===lastUserRawIdx;
const editBtn = isEditableUser ? `` : '';
const undoBtn = isLastAssistant ? `` : '';
diff --git a/tests/test_1325_user_fenced_code.py b/tests/test_1325_user_fenced_code.py
new file mode 100644
index 00000000..80c1be39
--- /dev/null
+++ b/tests/test_1325_user_fenced_code.py
@@ -0,0 +1,123 @@
+"""Tests for issue #1325 — fenced code blocks in user message bubbles."""
+import os
+import subprocess
+import tempfile
+
+UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
+
+
+def _extract_js_functions():
+ """Extract esc and _renderUserFencedBlocks from ui.js by line numbers."""
+ lines = open(UI_JS).read().split('\n')
+ # esc is on line 52 (0-indexed: 51)
+ esc_def = lines[51]
+ # _renderUserFencedBlocks starts at line 61 (0-indexed: 60)
+ # Find the end by matching closing brace at column 0
+ fn_lines = []
+ i = 60 # 0-indexed
+ depth = 0
+ while i < len(lines):
+ fn_lines.append(lines[i])
+ depth += lines[i].count('{') - lines[i].count('}')
+ if depth <= 0:
+ break
+ i += 1
+ fn_def = '\n'.join(fn_lines)
+ return esc_def, fn_def
+
+
+def _run_user_render(text_input):
+ """Return the HTML output of _renderUserFencedBlocks for the given input text."""
+ import json
+ esc_def, fn_def = _extract_js_functions()
+ js_code = esc_def + '\n' + fn_def + '\n'
+ js_code += 'var input = JSON.parse(process.argv[2]);\n'
+ js_code += 'process.stdout.write(_renderUserFencedBlocks(input));\n'
+ tf = tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False, encoding='utf-8')
+ tf.write(js_code)
+ tf.close()
+ try:
+ result = subprocess.run(
+ ['node', tf.name, json.dumps(text_input)],
+ capture_output=True, text=True, timeout=10
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"node error: {result.stderr}")
+ return result.stdout
+ finally:
+ os.unlink(tf.name)
+
+
+class TestUserFencedBlocks:
+ """Fenced code blocks in user messages should render as ."""
+
+ def test_simple_fenced_block(self):
+ out = _run_user_render("hello\n```python\nprint(1)\n```\nworld")
+ assert '' in out
+ assert 'print(1)' in out
+ # Newlines around the fenced block become
(same as original plain-text path)
+ assert 'hello
' in out
+ assert '
world' in out
+
+ def test_fenced_block_escaped_html(self):
+ """HTML in code blocks should be escaped."""
+ out = _run_user_render("```html\nhi\n```")
+ assert '<div>' in out
+ # No raw in code content
+ assert '' not in out.replace('<div>', '').replace('>', '')
+
+ def test_plain_text_not_interpreted_as_markdown(self):
+ """Bold/italic/links in non-fenced text should stay escaped."""
+ out = _run_user_render("**bold** and *italic* and ")
+ assert '**bold**' in out
+ assert '*italic*' in out
+ assert '<script>' in out
+ assert '' not in out
+
+ def test_language_header_shown(self):
+ out = _run_user_render("```javascript\nconst x = 1;\n```")
+ assert 'class="pre-header"' in out
+ assert 'javascript' in out
+
+ def test_no_language_no_header(self):
+ out = _run_user_render("```\nsome code\n```")
+ assert 'class="pre-header"' not in out
+ assert '' in out
+ assert 'some code' in out
+
+ def test_diff_block_colored(self):
+ out = _run_user_render("```diff\n+added\n-removed\n```")
+ assert 'diff-block' in out
+ assert 'diff-plus' in out
+ assert 'diff-minus' in out
+
+ def test_multiple_fenced_blocks(self):
+ out = _run_user_render("first\n```python\n1\n```\nmiddle\n```js\n2\n```\nlast")
+ assert 'language-python' in out
+ assert 'language-js' in out
+ assert 'first
' in out
+ assert '
last' in out
+
+ def test_fenced_block_with_ampersand(self):
+ out = _run_user_render("```python\nx & y\n```")
+ assert 'x & y' in out
+
+ def test_empty_code_block(self):
+ out = _run_user_render("```\n```")
+ assert '' in out
+
+ def test_special_chars_outside_blocks_escaped(self):
+ out = _run_user_render("a < b > c & d")
+ assert 'a < b > c & d' in out
+
+ def test_links_not_rendered_in_plain_text(self):
+ """URLs in plain text should NOT become clickable links."""
+ out = _run_user_render("Check https://example.com for details")
+ assert '' not in out