Skip to content

Commit e549e15

Browse files
TeigenZhangTeigenArk0Nclaude
authored
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>
1 parent d07b59d commit e549e15

4 files changed

Lines changed: 373 additions & 52 deletions

File tree

src/session.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,11 +1614,14 @@ export class Session extends EventEmitter {
16141614
this._messages = this._messages.slice(-Math.floor(MAX_MESSAGES * 0.8));
16151615
}
16161616

1617-
// Extract Claude session ID from messages (can be in any message type)
1618-
// Support both sessionId (camelCase) and session_id (snake_case)
1617+
// Extract Claude session ID from messages (can be in any message type).
1618+
// Support both sessionId (camelCase) and session_id (snake_case).
1619+
// The constructor seeds _claudeSessionId with this.id as a placeholder;
1620+
// once Claude CLI emits its real session ID, adopt it so JSONL lookups
1621+
// (e.g. /api/sessions/:id/last-response) can find the transcript file.
16191622
const msgSessionId =
16201623
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
1621-
if (msgSessionId && !this._claudeSessionId) {
1624+
if (msgSessionId && msgSessionId !== this._claudeSessionId) {
16221625
this._claudeSessionId = msgSessionId;
16231626
}
16241627

src/web/public/app.js

Lines changed: 157 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -908,11 +908,9 @@ class CodemanApp {
908908
const tpl = document.createElement('template');
909909
tpl.innerHTML = html;
910910
const frag = tpl.content;
911-
// Remove dangerous elements
912911
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
913912
el.remove();
914913
}
915-
// Strip dangerous attributes from all elements
916914
for (const el of frag.querySelectorAll('*')) {
917915
for (const attr of [...el.attributes]) {
918916
const name = attr.name.toLowerCase();
@@ -926,24 +924,171 @@ class CodemanApp {
926924
}
927925
}
928926
}
929-
// Serialize back via a container
930927
const div = document.createElement('div');
931928
div.appendChild(frag);
932929
return div.innerHTML;
933930
}
934931

932+
/**
933+
* Strip ANSI escape sequences and Claude CLI chrome (status bar, hints,
934+
* spinner, progress bar) from a terminal buffer so the response viewer can
935+
* show just the conversational text when the JSONL transcript is missing.
936+
*/
937+
_cleanTerminalBuffer(buf) {
938+
const stripped = buf
939+
// CSI sequences — params (0x30-0x3F includes digits, ?, ;, <, =, >),
940+
// intermediates (0x20-0x2F), final byte (0x40-0x7E). Catches \x1b[>c,
941+
// \x1b[>q, \x1b[?25l etc. that the previous regex missed.
942+
.replace(/\x1b\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g, '')
943+
// OSC sequences (window titles etc.) terminated by BEL or ST
944+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
945+
// DCS / APC / PM / SOS sequences
946+
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
947+
// SS2/SS3 + charset selects + single-char escapes
948+
.replace(/\x1b[NO()][A-Z0-9]?/g, '')
949+
.replace(/\x1b[>=<78cDEHM]/g, '')
950+
// Stray control chars (except \t \n)
951+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
952+
.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
953+
954+
// Drop Claude CLI chrome lines that aren't part of the response.
955+
const CHROME_PATTERNS = [
956+
/^\s*\s*/, // shell prompt
957+
/^\s*[]+\s*/, // status glyphs
958+
/^\s*\s*(Crunching|Crunched|Thinking)/i, // spinner lines
959+
/bypass permissions/i,
960+
/\bshift\+tab to cycle\b/i,
961+
/^\s*focus\s*$/,
962+
/^\s*new task\?/i,
963+
/\/clear to save/i,
964+
/^\s*{5,}\s*$/, // horizontal dividers
965+
/\[(Opus|Sonnet|Haiku|GPT|Claude)[\s\S]*(tokens?|\$|¥|%||)/i, // status bar
966+
/^\s*\[\d+[km]?\/\d+[km]?\]/i, // token counter
967+
/[]{3,}/, // progress bar
968+
/^\s*\(.*\s*(tokens?|context).*\)\s*$/i,
969+
];
970+
971+
const lines = stripped.split('\n');
972+
const kept = lines.filter((line) => {
973+
const trimmed = line.trim();
974+
if (!trimmed) return true; // keep blanks so paragraphs survive
975+
return !CHROME_PATTERNS.some((re) => re.test(line));
976+
});
977+
978+
return kept
979+
.join('\n')
980+
.replace(/[ \t]+$/gm, '')
981+
.replace(/\n{4,}/g, '\n\n\n')
982+
.trim();
983+
}
984+
985+
/**
986+
* Wrap ASCII/box diagrams in fenced code blocks so marked.js preserves whitespace.
987+
* Claude often emits box-drawing diagrams without triple-backticks; without this
988+
* step, HTML collapses the whitespace and the diagram becomes unreadable prose.
989+
*/
990+
_preprocessAsciiArt(text) {
991+
// Only trigger on characters that rarely appear in prose:
992+
// U+2500-U+257F Box Drawing (─│┌┐└┘├┤┬┴┼╔╗╚╝═║)
993+
// U+2580-U+259F Block Elements (▀▄█▌▐░▒▓, progress bars)
994+
// Deliberately excluded:
995+
// U+2190-U+21FF Arrows (→←↑↓⇒ — common rhetorical prose)
996+
// U+25A0-U+25FF Geometric Shapes (●○■□◆◇ — common bullets)
997+
// Triggering on those would wrap numbered lists / prose that merely uses
998+
// arrows in code blocks and break their markdown rendering.
999+
const BOX_PATTERN = /[--]/;
1000+
1001+
// Preserve existing fenced code blocks as-is (hide them behind placeholders)
1002+
const fenceRe = /```[\s\S]*?```/g;
1003+
const placeholders = [];
1004+
const masked = text.replace(fenceRe, (m) => {
1005+
placeholders.push(m);
1006+
return `FENCE${placeholders.length - 1}`;
1007+
});
1008+
1009+
// Split on blank-line paragraph boundaries; wrap any paragraph containing
1010+
// box-drawing/arrow chars in its own fenced block.
1011+
const processed = masked
1012+
.split(/(\n{2,})/)
1013+
.map((chunk) => {
1014+
if (/^\n{2,}$/.test(chunk)) return chunk; // keep separators
1015+
if (!chunk.trim()) return chunk;
1016+
if (chunk.includes('FENCE')) return chunk;
1017+
if (BOX_PATTERN.test(chunk)) return '\n```\n' + chunk + '\n```\n';
1018+
return chunk;
1019+
})
1020+
.join('');
1021+
1022+
return processed.replace(/FENCE(\d+)/g, (_m, i) => placeholders[Number(i)]);
1023+
}
1024+
9351025
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
9361026
_renderMarkdown(text) {
9371027
if (typeof marked !== 'undefined' && marked.parse) {
9381028
try {
939-
return this._sanitizeHtml(marked.parse(text, { breaks: true, gfm: true }));
1029+
const prepared = this._preprocessAsciiArt(text);
1030+
let html = this._sanitizeHtml(marked.parse(prepared, { breaks: true, gfm: true }));
1031+
// Wrap tables in a horizontal-scroll container so they overflow gracefully
1032+
// on mobile without collapsing into block-level cells.
1033+
html = html.replace(/<table>/g, '<div class="rv-table-wrap"><table>')
1034+
.replace(/<\/table>/g, '</table></div>');
1035+
// Tag code blocks containing box-drawing glyphs as diagrams (same
1036+
// narrow trigger as _preprocessAsciiArt — arrows/geometric shapes
1037+
// don't count because they appear frequently in prose).
1038+
// Default is wrap (readable on mobile); a toggle button lets the user
1039+
// switch to horizontal-scroll mode when the original structure matters.
1040+
// The button must live OUTSIDE the <pre> scroll container so it stays
1041+
// pinned to the visual right edge when the user scrolls horizontally.
1042+
const DIAGRAM_CHAR = /[-╿▀-]/;
1043+
const tmpl = document.createElement('template');
1044+
tmpl.innerHTML = html;
1045+
tmpl.content.querySelectorAll('pre > code').forEach((code) => {
1046+
if (!DIAGRAM_CHAR.test(code.textContent || '')) return;
1047+
const pre = code.parentElement;
1048+
pre.classList.add('rv-diagram');
1049+
1050+
const wrap = document.createElement('div');
1051+
wrap.className = 'rv-diagram-wrap';
1052+
1053+
const btn = document.createElement('button');
1054+
btn.className = 'rv-wrap-toggle';
1055+
btn.type = 'button';
1056+
btn.setAttribute('aria-label', 'Toggle line wrapping');
1057+
btn.setAttribute('title', 'Toggle line wrapping');
1058+
1059+
pre.parentNode.insertBefore(wrap, pre);
1060+
wrap.appendChild(btn);
1061+
wrap.appendChild(pre);
1062+
});
1063+
return tmpl.innerHTML;
9401064
} catch { /* fall through */ }
9411065
}
9421066
// Fallback: escape HTML and preserve whitespace
9431067
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
9441068
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
9451069
}
9461070

1071+
/**
1072+
* Bind click handlers inside the response viewer body. Uses event delegation
1073+
* so a single listener serves every diagram-toggle button, including those
1074+
* added when the conversation is reloaded. Idempotent via a dataset flag.
1075+
*/
1076+
_bindResponseViewerInteractions(body) {
1077+
if (!body || body.dataset.rvBound === '1') return;
1078+
body.dataset.rvBound = '1';
1079+
body.addEventListener('click', (ev) => {
1080+
const btn = ev.target.closest('.rv-wrap-toggle');
1081+
if (!btn) return;
1082+
ev.preventDefault();
1083+
ev.stopPropagation();
1084+
const wrap = btn.closest('.rv-diagram-wrap');
1085+
const pre = wrap?.querySelector('pre.rv-diagram');
1086+
if (!pre || !wrap) return;
1087+
const nowrap = pre.classList.toggle('rv-nowrap');
1088+
wrap.classList.toggle('rv-wrap-nowrap', nowrap);
1089+
});
1090+
}
1091+
9471092
async toggleResponseViewer() {
9481093
const viewer = document.getElementById('responseViewer');
9491094
const backdrop = document.getElementById('responseViewerBackdrop');
@@ -963,27 +1108,18 @@ class CodemanApp {
9631108
const data = await res.json();
9641109
let lastResponse = data.text || '';
9651110

966-
// Source 2: Terminal buffer fallback (strip ANSI codes)
1111+
// Source 2: Terminal buffer fallback strip ANSI, drop Claude CLI chrome
9671112
if (!lastResponse) {
9681113
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
9691114
const termData = await termRes.json();
9701115
if (termData.terminalBuffer) {
971-
lastResponse = termData.terminalBuffer
972-
.replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, '')
973-
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
974-
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
975-
.replace(/\x1b[()][A-Z0-9]/g, '')
976-
.replace(/\x1b[>=<]/g, '')
977-
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
978-
.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
979-
.replace(/[ \t]+$/gm, '')
980-
.replace(/\n{4,}/g, '\n\n\n')
981-
.trim();
1116+
lastResponse = this._cleanTerminalBuffer(termData.terminalBuffer);
9821117
}
9831118
}
9841119

9851120
const body = document.getElementById('responseViewerBody');
9861121
body.innerHTML = this._renderMarkdown(lastResponse);
1122+
this._bindResponseViewerInteractions(body);
9871123

9881124
// Reset state for fresh open
9891125
const title = document.getElementById('responseViewerTitle');
@@ -1020,11 +1156,12 @@ class CodemanApp {
10201156
body.innerHTML = '';
10211157
for (const msg of messages) {
10221158
const div = document.createElement('div');
1023-
div.className = 'rv-message';
1159+
const isUser = msg.role === 'user';
1160+
div.className = 'rv-message ' + (isUser ? 'rv-msg-user' : 'rv-msg-assistant');
10241161

10251162
const role = document.createElement('div');
1026-
role.className = 'rv-role ' + (msg.role === 'user' ? 'rv-role-user' : 'rv-role-assistant');
1027-
role.textContent = msg.role === 'user' ? 'You' : 'Claude';
1163+
role.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
1164+
role.textContent = isUser ? 'You' : 'Claude';
10281165
div.appendChild(role);
10291166

10301167
const text = document.createElement('div');
@@ -1034,6 +1171,7 @@ class CodemanApp {
10341171

10351172
body.appendChild(div);
10361173
}
1174+
this._bindResponseViewerInteractions(body);
10371175

10381176
if (title) title.textContent = `Conversation (${messages.length} messages)`;
10391177
if (moreBtn) moreBtn.style.display = 'none';

src/web/public/mobile.css

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,11 +1196,6 @@ html.mobile-init .file-browser-panel {
11961196
touch-action: none;
11971197
}
11981198

1199-
/* Response viewer — show eye icon in header on mobile */
1200-
.btn-response-viewer-header {
1201-
display: inline-flex !important;
1202-
}
1203-
12041199
.response-viewer {
12051200
padding-bottom: var(--safe-area-bottom, 0px);
12061201
}

0 commit comments

Comments
 (0)