feat(response-viewer): ASCII diagram wrap toggle, mobile code blocks, chrome-stripping fallback (#75)

* fix: restore clear message separation + proper table layout in response viewer

* fix: capture Claude CLI's real session ID + robust ANSI/CLI-chrome stripping in response viewer fallback

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

* fix: wrap regular code blocks on mobile, keep ASCII diagrams rigid with scroll hint

* feat: add per-block wrap toggle on ASCII-diagram code blocks

* fix: wrap by default, pin toggle button outside scroll container

* fix: narrow diagram detection to box-drawing + block elements only

* feat: show last-response viewer eye icon on desktop too

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.

* fix(response-viewer): restore HTML sanitizer + fix undefined `src` in _renderMarkdown

- `_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) <noreply@anthropic.com>

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Co-authored-by: arkon <arkon.85@hotmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tenggan Zhang
2026-04-28 02:58:42 +02:00
committed by GitHub
co-authored by Claude Opus 4.7 Teigen arkon
parent d07b59db4e
commit e549e15cb8
4 changed files with 376 additions and 55 deletions
+6 -3
View File
@@ -1614,11 +1614,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<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
if (msgSessionId && !this._claudeSessionId) {
if (msgSessionId && msgSessionId !== this._claudeSessionId) {
this._claudeSessionId = msgSessionId;
}
+157 -19
View File
@@ -908,11 +908,9 @@ class CodemanApp {
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();
@@ -926,17 +924,143 @@ class CodemanApp {
}
}
}
// 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) {
// 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;
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 */
_renderMarkdown(text) {
if (typeof marked !== 'undefined' && marked.parse) {
try {
return this._sanitizeHtml(marked.parse(text, { 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(/<table>/g, '<div class="rv-table-wrap"><table>')
.replace(/<\/table>/g, '</table></div>');
// 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 <pre> 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 || '')) 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 */ }
}
// Fallback: escape HTML and preserve whitespace
@@ -944,6 +1068,27 @@ class CodemanApp {
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
}
/**
* 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 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);
});
}
async toggleResponseViewer() {
const viewer = document.getElementById('responseViewer');
const backdrop = document.getElementById('responseViewerBackdrop');
@@ -963,27 +1108,18 @@ 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);
}
}
const body = document.getElementById('responseViewerBody');
body.innerHTML = this._renderMarkdown(lastResponse);
this._bindResponseViewerInteractions(body);
// Reset state for fresh open
const title = document.getElementById('responseViewerTitle');
@@ -1020,11 +1156,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');
@@ -1034,6 +1171,7 @@ class CodemanApp {
body.appendChild(div);
}
this._bindResponseViewerInteractions(body);
if (title) title.textContent = `Conversation (${messages.length} messages)`;
if (moreBtn) moreBtn.style.display = 'none';
-5
View File
@@ -1196,11 +1196,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);
}
+213 -28
View File
@@ -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 {
@@ -7885,33 +7884,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;
}
.rv-role {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
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 {
display: inline-block;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
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 */
@@ -7951,6 +7971,116 @@ 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): 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 <pre> 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 {
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;
}
.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 <pre>'s
horizontal scroll since it lives outside that scrolling container. */
.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: 2;
}
.rv-wrap-toggle:hover,
.rv-wrap-toggle:active {
color: #e0e0ec;
border-color: #4a4a65;
}
/* Default icon = "return" (wrap is active). Clicking switches to expand/scroll. */
.rv-wrap-toggle::before {
content: '↵';
font-size: 13px;
}
.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,
.response-viewer-body > ul, .response-viewer-body > ol {
margin: 0.6em 0;
}
@@ -7986,25 +8116,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 hr {
.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,
.response-viewer-body > hr {
border: none;
border-top: 1px solid #333;
margin: 1em 0;