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 bb85ef1e..fa47404f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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(//g, '
') + .replace(/<\/table>/g, '
'); + // 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 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 `
${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 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'; 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 8914eb14..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 { @@ -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; + 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 */ @@ -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
 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 
'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 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;