From 68594ac395c1797f9eac23b5372719d9211895df Mon Sep 17 00:00:00 2001 From: Tenggan Zhang <38639187+TeigenZhang@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:43:52 +0800 Subject: [PATCH] feat(web): response-viewer transcript fallback + code-block rendering (#102) * 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. * 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) --------- Co-authored-by: Teigen Co-authored-by: arkon Co-authored-by: Claude Opus 4.8 (1M context) --- src/web/public/app.js | 5 +- src/web/public/styles.css | 181 +++++++++++++++++++++++++------------- 2 files changed, 125 insertions(+), 61 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index b95fa4a3..1c535ddb 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1110,9 +1110,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 +1169,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 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 li {
-  margin-bottom: 0.2em;
+.rv-text strong,
+.response-viewer-body > p strong,
+.response-viewer-body > li strong {
+  color: #ffffff;
+  font-weight: 700;
 }
 
-.rv-text blockquote {
-  border-left: 3px solid #444;
-  margin: 0.6em 0;
-  padding: 0.3em 0 0.3em 12px;
-  color: #999;
+.rv-text em,
+.response-viewer-body em {
+  color: #e0e0ec;
 }
 
-.rv-text strong {
-  color: #f0f0f0;
-}
-
-.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 {