From 025e989089c6ba0163f7bd8525fb82352b4a76d5 Mon Sep 17 00:00:00 2001 From: Teigen Date: Sun, 31 May 2026 23:25:45 +0800 Subject: [PATCH 1/2] feat(web): response-viewer transcript fallback + code-block rendering - Add _cleanTerminalBuffer(): strip ANSI escapes and Claude CLI chrome (status bar, spinner, progress bar, prompt glyphs) from the terminal buffer so the response viewer renders clean text when the JSONL transcript is missing. - Add _preprocessAsciiArt(): wrap box-drawing/block-element diagrams in fenced code blocks (narrow trigger that excludes arrows/geometric shapes common in prose) so marked.js preserves their whitespace. - Extend .rv-text rules to .response-viewer-body so fallback-rendered content gets the same typography, code-block, and table styling. --- src/web/public/app.js | 98 ++++++++++++++++++++- src/web/public/styles.css | 179 ++++++++++++++++++++++++++------------ 2 files changed, 217 insertions(+), 60 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index b95fa4a3..b76c2b8b 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -989,6 +989,99 @@ class CodemanApp { // Response Viewer — native-scroll panel for reading full Claude responses // ═══════════════════════════════════════════════════════════════ + /** + * 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)]); + } + /** Strip dangerous elements and attributes from HTML (XSS prevention) */ _sanitizeHtml(html) { const tpl = document.createElement('template'); @@ -1110,9 +1203,10 @@ class CodemanApp { /** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */ _renderMarkdown(text) { + const src = text || ''; if (typeof marked !== 'undefined' && marked.parse) { try { - const prepared = this._preprocessAsciiArt(text); + const prepared = this._preprocessAsciiArt(src); 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. @@ -1168,7 +1262,7 @@ class CodemanApp { } catch { /* fall through */ } } // Fallback: escape HTML and preserve whitespace - const escaped = text.replace(/&/g, '&').replace(//g, '>'); + const escaped = src.replace(/&/g, '&').replace(//g, '>'); return `
${escaped}
`; } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index b96277ef..0272c7ae 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -7971,14 +7971,15 @@ kbd { bottom: 0; left: 0; right: 0; - max-height: 85vh; - background: #1a1a2e; - border-top: 1px solid #333; - border-radius: 12px 12px 0 0; + max-height: 88vh; + background: #14141f; + border-top: 1px solid #2a2a3a; + border-radius: 14px 14px 0 0; + box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.45); z-index: 5000; flex-direction: column; transform: translateY(100%); - transition: transform 0.25s ease-out; + transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1); } .response-viewer.visible { @@ -7990,12 +7991,13 @@ kbd { display: flex; align-items: center; justify-content: space-between; - padding: 12px 16px; - border-bottom: 1px solid #333; + padding: 14px 20px; + border-bottom: 1px solid #2a2a3a; flex-shrink: 0; font-size: 14px; font-weight: 600; - color: #e0e0e0; + color: #e8e8ec; + letter-spacing: 0.2px; } .response-viewer-actions { @@ -8078,42 +8080,80 @@ kbd { background: rgba(109, 219, 127, 0.12); } -/* Markdown rendered content inside response viewer */ -.rv-text { - word-break: break-word; - line-height: 1.6; +/* Markdown rendered content inside response viewer. + Prose uses a proportional font for readability; code keeps monospace. */ +.rv-text, +.response-viewer-body > :not(.rv-message) { + word-break: normal; + overflow-wrap: anywhere; + line-height: 1.7; } -.rv-text p { - margin: 0 0 0.6em; +.rv-text p, +.response-viewer-body > p { + margin: 0 0 0.85em; } -.rv-text p:last-child { +.rv-text p:last-child, +.response-viewer-body > p:last-child { margin-bottom: 0; } -.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4 { - color: #e0e0e0; - margin: 1em 0 0.4em; +.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4, +.response-viewer-body > h1, .response-viewer-body > h2, +.response-viewer-body > h3, .response-viewer-body > h4 { + color: #f2f2f6; + margin: 1.4em 0 0.5em; line-height: 1.3; + font-weight: 700; + letter-spacing: -0.01em; } -.rv-text h1 { font-size: 1.3em; } -.rv-text h2 { font-size: 1.15em; } -.rv-text h3 { font-size: 1.05em; } +.rv-text h1:first-child, .rv-text h2:first-child, +.response-viewer-body > h1:first-child, .response-viewer-body > h2:first-child { + margin-top: 0; +} -.rv-text code { - background: #2a2a3e; - padding: 1px 5px; - border-radius: 3px; +.rv-text h1, .response-viewer-body > h1 { + font-size: 1.55em; + padding-bottom: 0.3em; + border-bottom: 1px solid #2d2d40; +} +.rv-text h2, .response-viewer-body > h2 { + font-size: 1.3em; + color: #ffd27a; +} +.rv-text h3, .response-viewer-body > h3 { + font-size: 1.13em; + color: #bfc8ff; +} +.rv-text h4, .response-viewer-body > h4 { + font-size: 1em; + color: #c9c9d5; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.rv-text code, +.response-viewer-body > :not(pre) code { + background: #262638; + color: #ffb4a2; + padding: 1px 6px; + border-radius: 4px; + font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, monospace; font-size: 0.9em; } -.rv-text pre { - background: #1e1e2e; - border: 1px solid #333; - border-radius: 6px; - padding: 10px 12px; +/* Descendant (not child) combinator: code blocks are wrapped in .rv-code-wrap, + so the latest-response view (markdown rendered straight into the body) nests +
 one level deeper than a direct child. The historical .rv-text path
+   already matched via descendant; keep both in lockstep. */
+.rv-text pre,
+.response-viewer-body pre {
+  background: #0f0f1a;
+  border: 1px solid #2a2a3d;
+  border-radius: 8px;
+  padding: 14px 16px;
   overflow-x: auto;
   margin: 1em 0;
   -webkit-overflow-scrolling: touch;
@@ -8125,7 +8165,7 @@ kbd {
    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 {
+.response-viewer-body pre code {
   background: none;
   color: #e6e6f0;
   padding: 0;
@@ -8294,38 +8334,43 @@ kbd {
 .rv-text ul, .rv-text ol,
 .response-viewer-body > ul, .response-viewer-body > ol {
   margin: 0.6em 0;
+  padding-left: 1.5em;
 }
 
-.rv-text pre code {
-  background: none;
-  padding: 0;
-  font-size: 0.85em;
-  line-height: 1.5;
+.rv-text li,
+.response-viewer-body > ul > li, .response-viewer-body > ol > li {
+  margin-bottom: 0.3em;
 }
 
-.rv-text ul, .rv-text ol {
-  margin: 0.4em 0;
-  padding-left: 1.4em;
-}
+.rv-text li > p { margin: 0.2em 0; }
 
-.rv-text li {
-  margin-bottom: 0.2em;
+.rv-text blockquote,
+.response-viewer-body > blockquote {
+  border-left: 3px solid #5c7cfa;
+  background: rgba(92, 124, 250, 0.06);
+  margin: 0.8em 0;
+  padding: 0.5em 14px;
+  color: #b8b8c8;
+  border-radius: 0 6px 6px 0;
 }
 
-.rv-text blockquote {
-  border-left: 3px solid #444;
-  margin: 0.6em 0;
-  padding: 0.3em 0 0.3em 12px;
-  color: #999;
+.rv-text strong,
+.response-viewer-body > p strong,
+.response-viewer-body > li strong {
+  color: #ffffff;
+  font-weight: 700;
 }
 
-.rv-text strong {
-  color: #f0f0f0;
+.rv-text em,
+.response-viewer-body em {
+  color: #e0e0ec;
 }
 
-.rv-text a {
-  color: #5c7cfa;
+.rv-text a,
+.response-viewer-body a {
+  color: #7aa2ff;
   text-decoration: none;
+  border-bottom: 1px solid rgba(122, 162, 255, 0.35);
 }
 
 .rv-text a:hover,
@@ -8403,19 +8448,37 @@ kbd {
 .rv-text hr,
 .response-viewer-body > hr {
   border: none;
-  border-top: 1px solid #333;
-  margin: 1em 0;
+  border-top: 1px solid #2d2d40;
+  margin: 1.5em 0;
 }
 
 .response-viewer-body {
   flex: 1;
   overflow-y: auto;
   -webkit-overflow-scrolling: touch;
-  padding: 16px;
-  font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, monospace;
-  font-size: 13px;
-  line-height: 1.5;
-  color: #d4d4d4;
+  overscroll-behavior: contain;
+  padding: 20px 22px 28px;
+  /* Proportional font for prose — monospace only for code/pre */
+  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC',
+    'Hiragino Sans GB', 'Segoe UI', 'Helvetica Neue', Helvetica, Arial,
+    'Noto Sans CJK SC', sans-serif;
+  font-size: 15px;
+  line-height: 1.7;
+  color: #d8d8e0;
+  /* Comfortable reading width on wider viewports */
+  --rv-content-max: 720px;
+}
+
+/* Constrain content width for readability; code blocks can still scroll horizontally */
+.response-viewer-body > * {
+  max-width: var(--rv-content-max);
+  margin-left: auto;
+  margin-right: auto;
+}
+.response-viewer-body > pre,
+.response-viewer-body > table,
+.response-viewer-body > .rv-message {
+  max-width: var(--rv-content-max);
 }
 
 .response-viewer-body:empty::after {

From bdeab49bd6b93dfc870946ffe7718b13eb6e977c Mon Sep 17 00:00:00 2001
From: arkon 
Date: Mon, 1 Jun 2026 19:43:23 +0200
Subject: [PATCH 2/2] refactor(web): drop duplicate
 _cleanTerminalBuffer/_preprocessAsciiArt

These two methods already exist on master (added in #75). This branch
re-added byte-identical copies above _sanitizeHtml; in a JS class body the
later definition wins, so the duplicates were inert dead code. Remove them,
keeping only the genuinely new work: the _renderMarkdown null-safety fix and
the response-viewer CSS overhaul.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 src/web/public/app.js | 93 -------------------------------------------
 1 file changed, 93 deletions(-)

diff --git a/src/web/public/app.js b/src/web/public/app.js
index b76c2b8b..1c535ddb 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -989,99 +989,6 @@ class CodemanApp {
   // Response Viewer — native-scroll panel for reading full Claude responses
   // ═══════════════════════════════════════════════════════════════
 
-  /**
-   * 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)]);
-  }
-
   /** Strip dangerous elements and attributes from HTML (XSS prevention) */
   _sanitizeHtml(html) {
     const tpl = document.createElement('template');