From 89a79a3bd3f4e481c57394171cb908970f19477c Mon Sep 17 00:00:00 2001 From: Teigen Date: Thu, 23 Apr 2026 13:43:09 +0800 Subject: [PATCH 1/8] fix: restore clear message separation + proper table layout in response viewer --- src/web/public/app.js | 14 +++-- src/web/public/styles.css | 122 +++++++++++++++++++++++++++++++------- 2 files changed, 109 insertions(+), 27 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index bb85ef1e..ace61732 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -936,7 +936,12 @@ class CodemanApp { _renderMarkdown(text) { if (typeof marked !== 'undefined' && marked.parse) { try { - return this._sanitizeHtml(marked.parse(text, { breaks: true, gfm: true })); + const prepared = this._preprocessAsciiArt(src); + const html = marked.parse(prepared, { breaks: true, gfm: true }); + // Wrap tables in a horizontal-scroll container so they overflow gracefully + // on mobile without collapsing into block-level cells. + return html.replace(//g, '
') + .replace(/<\/table>/g, '
'); } catch { /* fall through */ } } // Fallback: escape HTML and preserve whitespace @@ -1020,11 +1025,12 @@ class CodemanApp { body.innerHTML = ''; for (const msg of messages) { const div = document.createElement('div'); - div.className = 'rv-message'; + const isUser = msg.role === 'user'; + div.className = 'rv-message ' + (isUser ? 'rv-msg-user' : 'rv-msg-assistant'); const role = document.createElement('div'); - role.className = 'rv-role ' + (msg.role === 'user' ? 'rv-role-user' : 'rv-role-assistant'); - role.textContent = msg.role === 'user' ? 'You' : 'Claude'; + role.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant'); + role.textContent = isUser ? 'You' : 'Claude'; div.appendChild(role); const text = document.createElement('div'); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 8914eb14..18df14ea 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -7885,33 +7885,54 @@ kbd { line-height: 1; } -/* Conversation thread messages */ +/* Conversation thread messages — card-style layout for clear separation */ .rv-message { - margin-bottom: 16px; - padding-bottom: 16px; - border-bottom: 1px solid #2a2a3a; + margin: 0 0 18px; + padding: 14px 16px 16px; + border-radius: 10px; + border: 1px solid #252538; + border-left-width: 3px; + background: #181826; + position: relative; } .rv-message:last-child { - border-bottom: none; - margin-bottom: 0; - padding-bottom: 0; + margin-bottom: 6px; +} + +/* Distinct accent per role so threads are scannable at a glance */ +.rv-message:has(.rv-role-user), +.rv-message.rv-msg-user { + border-left-color: #7aa2ff; + background: #16182a; +} + +.rv-message:has(.rv-role-assistant), +.rv-message.rv-msg-assistant { + border-left-color: #6ddb7f; + background: #161f1a; } .rv-role { - font-size: 11px; - font-weight: 600; + display: inline-block; + font-size: 10.5px; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 6px; + letter-spacing: 1px; + margin-bottom: 10px; + padding: 2px 8px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.04); } .rv-role-user { - color: #5c7cfa; + color: #7aa2ff; + background: rgba(122, 162, 255, 0.12); } .rv-role-assistant { - color: #51cf66; + color: #6ddb7f; + background: rgba(109, 219, 127, 0.12); } /* Markdown rendered content inside response viewer */ @@ -7986,25 +8007,80 @@ kbd { text-decoration: none; } -.rv-text table { +.rv-text a:hover, +.response-viewer-body a:hover { + border-bottom-color: #7aa2ff; +} + +/* Tables — scroll wrapper keeps table proper while allowing horizontal overflow */ +.rv-table-wrap { + margin: 1em 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + border: 1px solid #2a2a3d; + border-radius: 8px; + background: #12121d; +} + +.rv-text table, +.response-viewer-body > table, +.rv-table-wrap > table { border-collapse: collapse; - margin: 0.6em 0; + margin: 0; width: 100%; - font-size: 0.9em; + font-size: 0.92em; + line-height: 1.55; } -.rv-text th, .rv-text td { - border: 1px solid #333; - padding: 4px 8px; +.rv-text th, .rv-text td, +.response-viewer-body > table th, +.response-viewer-body > table td, +.rv-table-wrap th, .rv-table-wrap td { + border-bottom: 1px solid #252538; + border-right: 1px solid #252538; + padding: 8px 12px; text-align: left; + vertical-align: top; + white-space: normal; } -.rv-text th { - background: #2a2a3e; - color: #e0e0e0; +.rv-text th:last-child, .rv-text td:last-child, +.response-viewer-body > table th:last-child, +.response-viewer-body > table td:last-child, +.rv-table-wrap th:last-child, .rv-table-wrap td:last-child { + border-right: none; +} + +.rv-text tr:last-child td, +.response-viewer-body > table tr:last-child td, +.rv-table-wrap tr:last-child td { + border-bottom: none; +} + +.rv-text th, +.response-viewer-body > table th, +.rv-table-wrap th { + background: #20202e; + color: #f0f0f5; + font-weight: 600; + border-bottom: 2px solid #2f2f45; + white-space: nowrap; +} + +.rv-text tbody tr:nth-child(even) td, +.response-viewer-body > table tbody tr:nth-child(even) td, +.rv-table-wrap tbody tr:nth-child(even) td { + background: rgba(255, 255, 255, 0.022); +} + +.rv-text tbody tr:hover td, +.response-viewer-body > table tbody tr:hover td, +.rv-table-wrap tbody tr:hover td { + background: rgba(122, 162, 255, 0.06); } -.rv-text hr { +.rv-text hr, +.response-viewer-body > hr { border: none; border-top: 1px solid #333; margin: 1em 0; From 30b802b375c6811b65e5bf308af22822272682fa Mon Sep 17 00:00:00 2001 From: Teigen Date: Thu, 23 Apr 2026 13:50:27 +0800 Subject: [PATCH 2/8] fix: capture Claude CLI's real session ID + robust ANSI/CLI-chrome stripping in response viewer fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session constructor seeded _claudeSessionId with Codeman's session.id as a placeholder, and the message-driven update was gated on !_claudeSessionId — meaning Claude CLI's actual session UUID was never adopted. This broke /api/sessions/:id/last-response JSONL lookups, silently falling through to the terminal-buffer path whose ANSI regex missed \x1b[>c / \x1b[>q queries. - session.ts: update _claudeSessionId whenever a message's session_id differs from current (covers placeholder and stale-resume cases) - app.js: extract _cleanTerminalBuffer with proper CSI regex (param bytes 0x30-0x3F now covers > ? < =) plus a chrome filter for status bar, progress bar, spinner, shell prompt, and hint lines --- src/session.ts | 9 ++- src/web/public/app.js | 125 +++++++++++++++++++++++++++++------------- 2 files changed, 92 insertions(+), 42 deletions(-) diff --git a/src/session.ts b/src/session.ts index 6a498fcb..b39d648f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1577,11 +1577,14 @@ export class Session extends EventEmitter { this._messages = this._messages.slice(-Math.floor(MAX_MESSAGES * 0.8)); } - // Extract Claude session ID from messages (can be in any message type) - // Support both sessionId (camelCase) and session_id (snake_case) + // Extract Claude session ID from messages (can be in any message type). + // Support both sessionId (camelCase) and session_id (snake_case). + // The constructor seeds _claudeSessionId with this.id as a placeholder; + // once Claude CLI emits its real session ID, adopt it so JSONL lookups + // (e.g. /api/sessions/:id/last-response) can find the transcript file. const msgSessionId = ((msg as unknown as Record).sessionId as string | undefined) ?? msg.session_id; - if (msgSessionId && !this._claudeSessionId) { + if (msgSessionId && msgSessionId !== this._claudeSessionId) { this._claudeSessionId = msgSessionId; } diff --git a/src/web/public/app.js b/src/web/public/app.js index ace61732..cee658ef 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -903,33 +903,90 @@ class CodemanApp { // Response Viewer — native-scroll panel for reading full Claude responses // ═══════════════════════════════════════════════════════════════ - /** Strip dangerous elements and attributes from HTML (XSS prevention) */ - _sanitizeHtml(html) { - const tpl = document.createElement('template'); - tpl.innerHTML = html; - const frag = tpl.content; - // Remove dangerous elements - for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) { - el.remove(); - } - // Strip dangerous attributes from all elements - for (const el of frag.querySelectorAll('*')) { - for (const attr of [...el.attributes]) { - const name = attr.name.toLowerCase(); - if (name.startsWith('on')) { - el.removeAttribute(attr.name); - } else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) { - const val = attr.value.replace(/\s/g, '').toLowerCase(); - if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) { - el.removeAttribute(attr.name); - } - } - } - } - // Serialize back via a container - const div = document.createElement('div'); - div.appendChild(frag); - return div.innerHTML; + /** + * Strip ANSI escape sequences and Claude CLI chrome (status bar, hints, + * spinner, progress bar) from a terminal buffer so the response viewer can + * show just the conversational text when the JSONL transcript is missing. + */ + _cleanTerminalBuffer(buf) { + const stripped = buf + // CSI sequences — params (0x30-0x3F includes digits, ?, ;, <, =, >), + // intermediates (0x20-0x2F), final byte (0x40-0x7E). Catches \x1b[>c, + // \x1b[>q, \x1b[?25l etc. that the previous regex missed. + .replace(/\x1b\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g, '') + // OSC sequences (window titles etc.) terminated by BEL or ST + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') + // DCS / APC / PM / SOS sequences + .replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '') + // SS2/SS3 + charset selects + single-char escapes + .replace(/\x1b[NO()][A-Z0-9]?/g, '') + .replace(/\x1b[>=<78cDEHM]/g, '') + // Stray control chars (except \t \n) + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '') + .replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + + // Drop Claude CLI chrome lines that aren't part of the response. + const CHROME_PATTERNS = [ + /^\s*❯\s*/, // shell prompt + /^\s*[⏵⏺⏸⏹]+\s*/, // status glyphs + /^\s*✻\s*(Crunching|Crunched|Thinking)/i, // spinner lines + /bypass permissions/i, + /\bshift\+tab to cycle\b/i, + /^\s*focus\s*$/, + /^\s*new task\?/i, + /\/clear to save/i, + /^\s*─{5,}\s*$/, // horizontal dividers + /\[(Opus|Sonnet|Haiku|GPT|Claude)[\s\S]*(tokens?|\$|¥|%|↑|↓)/i, // status bar + /^\s*\[\d+[km]?\/\d+[km]?\]/i, // token counter + /[█░▓▒]{3,}/, // progress bar + /^\s*\(.*\s*(tokens?|context).*\)\s*$/i, + ]; + + const lines = stripped.split('\n'); + const kept = lines.filter((line) => { + const trimmed = line.trim(); + if (!trimmed) return true; // keep blanks so paragraphs survive + return !CHROME_PATTERNS.some((re) => re.test(line)); + }); + + return kept + .join('\n') + .replace(/[ \t]+$/gm, '') + .replace(/\n{4,}/g, '\n\n\n') + .trim(); + } + + /** + * Wrap ASCII/box diagrams in fenced code blocks so marked.js preserves whitespace. + * Claude often emits box-drawing diagrams without triple-backticks; without this + * step, HTML collapses the whitespace and the diagram becomes unreadable prose. + */ + _preprocessAsciiArt(text) { + // Box-drawing + arrows + block elements + geometric shapes + const BOX_PATTERN = /[←-⇿─-╿▀-▟■-◿]/; + + // Preserve existing fenced code blocks as-is (hide them behind placeholders) + const fenceRe = /```[\s\S]*?```/g; + const placeholders = []; + const masked = text.replace(fenceRe, (m) => { + placeholders.push(m); + return `FENCE${placeholders.length - 1}`; + }); + + // Split on blank-line paragraph boundaries; wrap any paragraph containing + // box-drawing/arrow chars in its own fenced block. + const processed = masked + .split(/(\n{2,})/) + .map((chunk) => { + if (/^\n{2,}$/.test(chunk)) return chunk; // keep separators + if (!chunk.trim()) return chunk; + if (chunk.includes('FENCE')) return chunk; + if (BOX_PATTERN.test(chunk)) return '\n```\n' + chunk + '\n```\n'; + return chunk; + }) + .join(''); + + return processed.replace(/FENCE(\d+)/g, (_m, i) => placeholders[Number(i)]); } /** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */ @@ -968,22 +1025,12 @@ class CodemanApp { const data = await res.json(); let lastResponse = data.text || ''; - // Source 2: Terminal buffer fallback (strip ANSI codes) + // Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome if (!lastResponse) { const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`); const termData = await termRes.json(); if (termData.terminalBuffer) { - lastResponse = termData.terminalBuffer - .replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, '') - .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') - .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') - .replace(/\x1b[()][A-Z0-9]/g, '') - .replace(/\x1b[>=<]/g, '') - .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '') - .replace(/\r\n/g, '\n').replace(/\r/g, '\n') - .replace(/[ \t]+$/gm, '') - .replace(/\n{4,}/g, '\n\n\n') - .trim(); + lastResponse = this._cleanTerminalBuffer(termData.terminalBuffer); } } From 0bb4b85b20a22addb157d447a0b011059421a184 Mon Sep 17 00:00:00 2001 From: Teigen Date: Thu, 23 Apr 2026 13:56:45 +0800 Subject: [PATCH 3/8] fix: wrap regular code blocks on mobile, keep ASCII diagrams rigid with scroll hint --- src/web/public/app.js | 16 +++++++++++++-- src/web/public/styles.css | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index cee658ef..bbb6772c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -994,11 +994,23 @@ class CodemanApp { if (typeof marked !== 'undefined' && marked.parse) { try { const prepared = this._preprocessAsciiArt(src); - const html = marked.parse(prepared, { breaks: true, gfm: true }); + let html = marked.parse(prepared, { breaks: true, gfm: true }); // Wrap tables in a horizontal-scroll container so they overflow gracefully // on mobile without collapsing into block-level cells. - return html.replace(//g, '
') + html = html.replace(/
/g, '
') .replace(/<\/table>/g, '
'); + // Tag code blocks containing box-drawing/arrow glyphs as diagrams so CSS + // preserves their whitespace (horizontal scroll); other code blocks wrap + // for mobile readability. + const DIAGRAM_CHAR = /[←-⇿─-╿▀-▟■-◿]/; + const tmpl = document.createElement('template'); + tmpl.innerHTML = html; + tmpl.content.querySelectorAll('pre > code').forEach((code) => { + if (DIAGRAM_CHAR.test(code.textContent || '')) { + code.parentElement.classList.add('rv-diagram'); + } + }); + return tmpl.innerHTML; } catch { /* fall through */ } } // Fallback: escape HTML and preserve whitespace diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 18df14ea..1b5a5121 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -7972,6 +7972,49 @@ kbd { border-radius: 6px; padding: 10px 12px; overflow-x: auto; + margin: 1em 0; + -webkit-overflow-scrolling: touch; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02); + position: relative; +} + +/* Default: wrap long lines so mobile code reads naturally without horizontal scroll. + Preserve indentation (pre-wrap) but allow breaks inside long tokens + (URLs, paths, identifiers) so they don't overflow. */ +.rv-text pre code, +.response-viewer-body > pre code { + background: none; + color: #e6e6f0; + padding: 0; + font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, monospace; + font-size: 12.5px; + line-height: 1.55; + white-space: pre-wrap; + word-break: normal; + overflow-wrap: anywhere; + tab-size: 4; +} + +/* ASCII diagrams (box-drawing, arrows) stay rigid — wrapping destroys structure. + Horizontal scroll + gradient hint on the right edge signals "more to the right". */ +.rv-text pre.rv-diagram, +.response-viewer-body > pre.rv-diagram { + padding-right: 24px; + background: + linear-gradient(to left, #0f0f1a 0, rgba(15, 15, 26, 0) 28px) right / 28px 100% no-repeat, + linear-gradient(to left, rgba(122, 162, 255, 0.18) 0, rgba(15, 15, 26, 0) 28px) right / 28px 100% no-repeat, + #0f0f1a; +} + +.rv-text pre.rv-diagram code, +.response-viewer-body > pre.rv-diagram code { + white-space: pre; + overflow-wrap: normal; + word-break: normal; +} + +.rv-text ul, .rv-text ol, +.response-viewer-body > ul, .response-viewer-body > ol { margin: 0.6em 0; } From 440b5c0e8bb6b68a9cccb482ba1891aecb6c8c7f Mon Sep 17 00:00:00 2001 From: Teigen Date: Thu, 23 Apr 2026 14:02:21 +0800 Subject: [PATCH 4/8] feat: add per-block wrap toggle on ASCII-diagram code blocks --- src/web/public/app.js | 32 +++++++++++++++++++-- src/web/public/styles.css | 58 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index bbb6772c..3a011c3a 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1001,13 +1001,21 @@ class CodemanApp { .replace(/<\/table>/g, ''); // Tag code blocks containing box-drawing/arrow glyphs as diagrams so CSS // preserves their whitespace (horizontal scroll); other code blocks wrap - // for mobile readability. + // for mobile readability. Inject a toggle button so users can override + // when they'd rather see the content wrapped than scroll horizontally. const DIAGRAM_CHAR = /[←-⇿─-╿▀-▟■-◿]/; const tmpl = document.createElement('template'); tmpl.innerHTML = html; tmpl.content.querySelectorAll('pre > code').forEach((code) => { if (DIAGRAM_CHAR.test(code.textContent || '')) { - code.parentElement.classList.add('rv-diagram'); + const pre = code.parentElement; + pre.classList.add('rv-diagram'); + const btn = document.createElement('button'); + btn.className = 'rv-wrap-toggle'; + btn.type = 'button'; + btn.setAttribute('aria-label', 'Toggle line wrapping'); + btn.setAttribute('title', 'Toggle line wrapping'); + pre.insertBefore(btn, pre.firstChild); } }); return tmpl.innerHTML; @@ -1018,6 +1026,24 @@ class CodemanApp { return `
${escaped}
`; } + /** + * Bind click handlers inside the response viewer body. Uses event delegation + * so a single listener serves every diagram-toggle button, including those + * added when the conversation is reloaded. Idempotent via a dataset flag. + */ + _bindResponseViewerInteractions(body) { + if (!body || body.dataset.rvBound === '1') return; + body.dataset.rvBound = '1'; + body.addEventListener('click', (ev) => { + const btn = ev.target.closest('.rv-wrap-toggle'); + if (!btn) return; + ev.preventDefault(); + ev.stopPropagation(); + const pre = btn.closest('pre.rv-diagram'); + if (pre) pre.classList.toggle('rv-wrap-on'); + }); + } + async toggleResponseViewer() { const viewer = document.getElementById('responseViewer'); const backdrop = document.getElementById('responseViewerBackdrop'); @@ -1048,6 +1074,7 @@ class CodemanApp { const body = document.getElementById('responseViewerBody'); body.innerHTML = this._renderMarkdown(lastResponse); + this._bindResponseViewerInteractions(body); // Reset state for fresh open const title = document.getElementById('responseViewerTitle'); @@ -1099,6 +1126,7 @@ class CodemanApp { body.appendChild(div); } + this._bindResponseViewerInteractions(body); if (title) title.textContent = `Conversation (${messages.length} messages)`; if (moreBtn) moreBtn.style.display = 'none'; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 1b5a5121..8c2eef60 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -7995,11 +7995,13 @@ kbd { tab-size: 4; } -/* ASCII diagrams (box-drawing, arrows) stay rigid — wrapping destroys structure. - Horizontal scroll + gradient hint on the right edge signals "more to the right". */ +/* ASCII diagrams (box-drawing, arrows) stay rigid by default — wrapping destroys + structure. Horizontal scroll + gradient hint on the right edge signals + "more to the right". A toggle button lets users override when wrapping + is more readable than structure preservation. */ .rv-text pre.rv-diagram, .response-viewer-body > pre.rv-diagram { - padding-right: 24px; + padding-right: 44px; background: linear-gradient(to left, #0f0f1a 0, rgba(15, 15, 26, 0) 28px) right / 28px 100% no-repeat, linear-gradient(to left, rgba(122, 162, 255, 0.18) 0, rgba(15, 15, 26, 0) 28px) right / 28px 100% no-repeat, @@ -8013,6 +8015,56 @@ kbd { word-break: normal; } +/* Toggle button (absolute-positioned in top-right of diagram pre) */ +.rv-wrap-toggle { + position: absolute; + top: 6px; + right: 6px; + width: 28px; + height: 24px; + padding: 0; + border: 1px solid #2f2f45; + border-radius: 5px; + background: rgba(20, 20, 32, 0.92); + color: #8b8b97; + font-size: 11px; + line-height: 1; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: color 0.15s, border-color 0.15s; + z-index: 1; +} + +.rv-wrap-toggle:hover, +.rv-wrap-toggle:active { + color: #e0e0ec; + border-color: #4a4a65; +} + +.rv-wrap-toggle::before { + content: '⤢'; /* "expand" glyph — horizontal scroll mode (default) */ + font-size: 13px; +} + +/* Wrap-enabled diagram: switch whitespace handling + swap button icon */ +.rv-text pre.rv-diagram.rv-wrap-on code, +.response-viewer-body > pre.rv-diagram.rv-wrap-on code { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.rv-text pre.rv-diagram.rv-wrap-on, +.response-viewer-body > pre.rv-diagram.rv-wrap-on { + /* No need for right-edge scroll hint when wrapping is on */ + background: #0f0f1a; +} + +.rv-diagram.rv-wrap-on .rv-wrap-toggle::before { + content: '↵'; /* "return" glyph — wrap mode is active */ +} + .rv-text ul, .rv-text ol, .response-viewer-body > ul, .response-viewer-body > ol { margin: 0.6em 0; From dfe1dd9c35cbe9f481e56b2dea1d9c0d39923c7f Mon Sep 17 00:00:00 2001 From: Teigen Date: Thu, 23 Apr 2026 14:05:47 +0800 Subject: [PATCH 5/8] fix: wrap by default, pin toggle button outside scroll container --- src/web/public/app.js | 42 +++++++++++++--------- src/web/public/styles.css | 75 +++++++++++++++++++++++---------------- 2 files changed, 71 insertions(+), 46 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 3a011c3a..72cbf87c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -999,24 +999,31 @@ class CodemanApp { // on mobile without collapsing into block-level cells. html = html.replace(//g, '
') .replace(/<\/table>/g, '
'); - // Tag code blocks containing box-drawing/arrow glyphs as diagrams so CSS - // preserves their whitespace (horizontal scroll); other code blocks wrap - // for mobile readability. Inject a toggle button so users can override - // when they'd rather see the content wrapped than scroll horizontally. + // Tag code blocks containing box-drawing/arrow glyphs as diagrams. + // Default is wrap (readable on mobile); a toggle button lets the user + // switch to horizontal-scroll mode when the original structure matters. + // The button must live OUTSIDE the
 scroll container so it stays
+        // pinned to the visual right edge when the user scrolls horizontally.
         const DIAGRAM_CHAR = /[←-⇿─-╿▀-▟■-◿]/;
         const tmpl = document.createElement('template');
         tmpl.innerHTML = html;
         tmpl.content.querySelectorAll('pre > code').forEach((code) => {
-          if (DIAGRAM_CHAR.test(code.textContent || '')) {
-            const pre = code.parentElement;
-            pre.classList.add('rv-diagram');
-            const btn = document.createElement('button');
-            btn.className = 'rv-wrap-toggle';
-            btn.type = 'button';
-            btn.setAttribute('aria-label', 'Toggle line wrapping');
-            btn.setAttribute('title', 'Toggle line wrapping');
-            pre.insertBefore(btn, pre.firstChild);
-          }
+          if (!DIAGRAM_CHAR.test(code.textContent || '')) return;
+          const pre = code.parentElement;
+          pre.classList.add('rv-diagram');
+
+          const wrap = document.createElement('div');
+          wrap.className = 'rv-diagram-wrap';
+
+          const btn = document.createElement('button');
+          btn.className = 'rv-wrap-toggle';
+          btn.type = 'button';
+          btn.setAttribute('aria-label', 'Toggle line wrapping');
+          btn.setAttribute('title', 'Toggle line wrapping');
+
+          pre.parentNode.insertBefore(wrap, pre);
+          wrap.appendChild(btn);
+          wrap.appendChild(pre);
         });
         return tmpl.innerHTML;
       } catch { /* fall through */ }
@@ -1039,8 +1046,11 @@ class CodemanApp {
       if (!btn) return;
       ev.preventDefault();
       ev.stopPropagation();
-      const pre = btn.closest('pre.rv-diagram');
-      if (pre) pre.classList.toggle('rv-wrap-on');
+      const wrap = btn.closest('.rv-diagram-wrap');
+      const pre = wrap?.querySelector('pre.rv-diagram');
+      if (!pre || !wrap) return;
+      const nowrap = pre.classList.toggle('rv-nowrap');
+      wrap.classList.toggle('rv-wrap-nowrap', nowrap);
     });
   }
 
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 8c2eef60..71e9e9c1 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -7995,27 +7995,53 @@ kbd {
   tab-size: 4;
 }
 
-/* ASCII diagrams (box-drawing, arrows) stay rigid by default — wrapping destroys
-   structure. Horizontal scroll + gradient hint on the right edge signals
-   "more to the right". A toggle button lets users override when wrapping
-   is more readable than structure preservation. */
-.rv-text pre.rv-diagram,
-.response-viewer-body > pre.rv-diagram {
-  padding-right: 44px;
-  background:
-    linear-gradient(to left, #0f0f1a 0, rgba(15, 15, 26, 0) 28px) right / 28px 100% no-repeat,
-    linear-gradient(to left, rgba(122, 162, 255, 0.18) 0, rgba(15, 15, 26, 0) 28px) right / 28px 100% no-repeat,
-    #0f0f1a;
+/* ASCII diagrams (box-drawing, arrows): default is wrap for mobile readability.
+   A toggle button lets users switch to horizontal-scroll mode when preserving
+   the original grid structure matters more than fitting the viewport. The
+   button lives in a wrapper div outside the 
 so it stays pinned to the
+   visual right edge when the user scrolls horizontally. */
+.rv-text .rv-diagram-wrap,
+.response-viewer-body .rv-diagram-wrap {
+  position: relative;
+  margin: 1em 0;
+  max-width: var(--rv-content-max, 720px);
+  margin-left: auto;
+  margin-right: auto;
 }
 
+.rv-text .rv-diagram-wrap > pre.rv-diagram,
+.response-viewer-body .rv-diagram-wrap > pre.rv-diagram {
+  /* Pre lives inside wrap — move outer spacing to wrap */
+  margin: 0;
+  padding-right: 44px;    /* reserve space for the pinned button */
+}
+
+/* Default state: wrap long lines — same behavior as regular code blocks */
 .rv-text pre.rv-diagram code,
-.response-viewer-body > pre.rv-diagram code {
+.response-viewer-body pre.rv-diagram code {
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+  word-break: normal;
+}
+
+/* Scroll-mode (toggled): preserve structure, horizontal scroll with gradient hint */
+.rv-text pre.rv-diagram.rv-nowrap code,
+.response-viewer-body pre.rv-diagram.rv-nowrap code {
   white-space: pre;
   overflow-wrap: normal;
   word-break: normal;
 }
 
-/* Toggle button (absolute-positioned in top-right of diagram pre) */
+.rv-text pre.rv-diagram.rv-nowrap,
+.response-viewer-body pre.rv-diagram.rv-nowrap {
+  background:
+    linear-gradient(to left, #0f0f1a 0, rgba(15, 15, 26, 0) 28px) right / 28px 100% no-repeat,
+    linear-gradient(to left, rgba(122, 162, 255, 0.18) 0, rgba(15, 15, 26, 0) 28px) right / 28px 100% no-repeat,
+    #0f0f1a;
+}
+
+/* Toggle button — pinned to the wrapper's top-right, NOT affected by 
's
+   horizontal scroll since it lives outside that scrolling container. */
 .rv-wrap-toggle {
   position: absolute;
   top: 6px;
@@ -8034,7 +8060,7 @@ kbd {
   align-items: center;
   justify-content: center;
   transition: color 0.15s, border-color 0.15s;
-  z-index: 1;
+  z-index: 2;
 }
 
 .rv-wrap-toggle:hover,
@@ -8043,26 +8069,15 @@ kbd {
   border-color: #4a4a65;
 }
 
+/* Default icon = "return" (wrap is active). Clicking switches to expand/scroll. */
 .rv-wrap-toggle::before {
-  content: '⤢';         /* "expand" glyph — horizontal scroll mode (default) */
+  content: '↵';
   font-size: 13px;
 }
 
-/* Wrap-enabled diagram: switch whitespace handling + swap button icon */
-.rv-text pre.rv-diagram.rv-wrap-on code,
-.response-viewer-body > pre.rv-diagram.rv-wrap-on code {
-  white-space: pre-wrap;
-  overflow-wrap: anywhere;
-}
-
-.rv-text pre.rv-diagram.rv-wrap-on,
-.response-viewer-body > pre.rv-diagram.rv-wrap-on {
-  /* No need for right-edge scroll hint when wrapping is on */
-  background: #0f0f1a;
-}
-
-.rv-diagram.rv-wrap-on .rv-wrap-toggle::before {
-  content: '↵';          /* "return" glyph — wrap mode is active */
+.rv-diagram-wrap:has(> pre.rv-nowrap) .rv-wrap-toggle::before,
+.rv-diagram-wrap.rv-wrap-nowrap .rv-wrap-toggle::before {
+  content: '⤢';
 }
 
 .rv-text ul, .rv-text ol,

From e777ed9275c4c49dff6270076a6a7f87f81e080f Mon Sep 17 00:00:00 2001
From: Teigen 
Date: Thu, 23 Apr 2026 14:12:36 +0800
Subject: [PATCH 6/8] fix: narrow diagram detection to box-drawing + block
 elements only

---
 src/web/public/app.js | 17 +++++++++++++----
 1 file changed, 13 insertions(+), 4 deletions(-)

diff --git a/src/web/public/app.js b/src/web/public/app.js
index 72cbf87c..bb1ca071 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -962,8 +962,15 @@ class CodemanApp {
    * step, HTML collapses the whitespace and the diagram becomes unreadable prose.
    */
   _preprocessAsciiArt(text) {
-    // Box-drawing + arrows + block elements + geometric shapes
-    const BOX_PATTERN = /[←-⇿─-╿▀-▟■-◿]/;
+    // Only trigger on characters that rarely appear in prose:
+    //   U+2500-U+257F  Box Drawing      (─│┌┐└┘├┤┬┴┼╔╗╚╝═║)
+    //   U+2580-U+259F  Block Elements   (▀▄█▌▐░▒▓, progress bars)
+    // Deliberately excluded:
+    //   U+2190-U+21FF  Arrows           (→←↑↓⇒ — common rhetorical prose)
+    //   U+25A0-U+25FF  Geometric Shapes (●○■□◆◇ — common bullets)
+    // Triggering on those would wrap numbered lists / prose that merely uses
+    // arrows in code blocks and break their markdown rendering.
+    const BOX_PATTERN = /[─-╿▀-▟]/;
 
     // Preserve existing fenced code blocks as-is (hide them behind placeholders)
     const fenceRe = /```[\s\S]*?```/g;
@@ -999,12 +1006,14 @@ class CodemanApp {
         // on mobile without collapsing into block-level cells.
         html = html.replace(//g, '
') .replace(/<\/table>/g, '
'); - // Tag code blocks containing box-drawing/arrow glyphs as diagrams. + // Tag code blocks containing box-drawing glyphs as diagrams (same + // narrow trigger as _preprocessAsciiArt — arrows/geometric shapes + // don't count because they appear frequently in prose). // Default is wrap (readable on mobile); a toggle button lets the user // switch to horizontal-scroll mode when the original structure matters. // The button must live OUTSIDE the
 scroll container so it stays
         // pinned to the visual right edge when the user scrolls horizontally.
-        const DIAGRAM_CHAR = /[←-⇿─-╿▀-▟■-◿]/;
+        const DIAGRAM_CHAR = /[─-╿▀-▟]/;
         const tmpl = document.createElement('template');
         tmpl.innerHTML = html;
         tmpl.content.querySelectorAll('pre > code').forEach((code) => {

From 49139d31dae2b68f110f3bf5b824dff3afeeb142 Mon Sep 17 00:00:00 2001
From: Teigen 
Date: Thu, 23 Apr 2026 22:54:16 +0800
Subject: [PATCH 7/8] feat: show last-response viewer eye icon on desktop too
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The response viewer button was mobile-only via a display:none default with a
mobile.css override. Flip the default to inline-flex and drop the override so
the eye icon appears in the header on every form factor — desktop users get
the same quick "Last Response" pane as mobile.
---
 src/web/public/mobile.css | 5 -----
 src/web/public/styles.css | 3 +--
 2 files changed, 1 insertion(+), 7 deletions(-)

diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css
index 96d47a23..e3b2752a 100644
--- a/src/web/public/mobile.css
+++ b/src/web/public/mobile.css
@@ -1195,11 +1195,6 @@ html.mobile-init .file-browser-panel {
     touch-action: none;
   }
 
-  /* Response viewer — show eye icon in header on mobile */
-  .btn-response-viewer-header {
-    display: inline-flex !important;
-  }
-
   .response-viewer {
     padding-bottom: var(--safe-area-bottom, 0px);
   }
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 71e9e9c1..dca6ea44 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -7817,9 +7817,8 @@ kbd {
    Response Viewer — native-scroll overlay for reading Claude responses
    ═══════════════════════════════════════════════════════════════ */
 
-/* Hidden on desktop — only shown on mobile via mobile.css override */
 .btn-response-viewer-header {
-  display: none !important;
+  display: inline-flex !important;
 }
 
 .response-viewer {

From bdcd808f372c9fac3d281cb02eec29e51cb4a8bc Mon Sep 17 00:00:00 2001
From: arkon 
Date: Tue, 28 Apr 2026 02:57:20 +0200
Subject: [PATCH 8/8] fix(response-viewer): restore HTML sanitizer + fix
 undefined `src` in _renderMarkdown
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- `_renderMarkdown` referenced an undefined `src` (should be `text`),
  causing a ReferenceError on every markdown render. The try/catch
  swallowed it, so the new table-wrap and ASCII-diagram features
  never actually ran — output silently fell through to plain-text.
  app.js is excluded from ESLint, so this wasn't caught at lint time.
- `_sanitizeHtml` was removed when refactoring the response viewer,
  leaving `marked.parse()` output going straight into `innerHTML`
  without sanitization (XSS regression vs. master). Restored the
  helper and re-applied it before any post-processing.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 src/web/public/app.js | 30 ++++++++++++++++++++++++++++--
 1 file changed, 28 insertions(+), 2 deletions(-)

diff --git a/src/web/public/app.js b/src/web/public/app.js
index bb1ca071..fa47404f 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -903,6 +903,32 @@ class CodemanApp {
   // Response Viewer — native-scroll panel for reading full Claude responses
   // ═══════════════════════════════════════════════════════════════
 
+  /** Strip dangerous elements and attributes from HTML (XSS prevention) */
+  _sanitizeHtml(html) {
+    const tpl = document.createElement('template');
+    tpl.innerHTML = html;
+    const frag = tpl.content;
+    for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
+      el.remove();
+    }
+    for (const el of frag.querySelectorAll('*')) {
+      for (const attr of [...el.attributes]) {
+        const name = attr.name.toLowerCase();
+        if (name.startsWith('on')) {
+          el.removeAttribute(attr.name);
+        } else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) {
+          const val = attr.value.replace(/\s/g, '').toLowerCase();
+          if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) {
+            el.removeAttribute(attr.name);
+          }
+        }
+      }
+    }
+    const div = document.createElement('div');
+    div.appendChild(frag);
+    return div.innerHTML;
+  }
+
   /**
    * Strip ANSI escape sequences and Claude CLI chrome (status bar, hints,
    * spinner, progress bar) from a terminal buffer so the response viewer can
@@ -1000,8 +1026,8 @@ class CodemanApp {
   _renderMarkdown(text) {
     if (typeof marked !== 'undefined' && marked.parse) {
       try {
-        const prepared = this._preprocessAsciiArt(src);
-        let html = marked.parse(prepared, { breaks: true, gfm: true });
+        const prepared = this._preprocessAsciiArt(text);
+        let html = this._sanitizeHtml(marked.parse(prepared, { breaks: true, gfm: true }));
         // Wrap tables in a horizontal-scroll container so they overflow gracefully
         // on mobile without collapsing into block-level cells.
         html = html.replace(//g, '
')