From d34359c9ab53cb04afba2d3cc3844d2b1b8e830d Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Mon, 15 Jun 2026 18:33:26 -0700 Subject: [PATCH] fix: Claude view parsing/scroll, ngrok warning bypass, reliable send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude parsed view: - Tables now require a real column-border row (┌┬┐/├┼┤); welcome/notice boxes (rounded ╭╰, single │ column) no longer render as garbled tables - Tighten code detection so log lines / prose with parentheses aren't boxed as code - Status bar no longer eats prose/question lines containing a "·"; pull ctx%/model from combined statuslines - Renderer hashes full block content, so streaming updates re-render instead of freezing ("parsed, then not") - Left-normalize TUI lines shoved to the right on wide panes (text/response/tool output only; code/tables/diffs untouched) Scroll history: - Stop the loader flash-loop once scrollback is exhausted (noMoreHistory guard) - Keep the reading position on load; older content is revealed by scrolling up further (preserve-position) ngrok: - No-cache pass-through service worker adds the ngrok-skip-browser-warning header to navigations so the free-tier interstitial is skipped on revisits (replaces the old caching SW; sw-unregister removed) Send: - Server awaits send_text before pressing Enter so the submit can't race the bracketed paste (fixes "had to press Enter twice") Co-Authored-By: Claude Opus 4.8 --- public/index.html | 12 +++--- public/js/app.js | 43 ++++++++++++++++++++- public/js/claude-parser.js | 59 +++++++++++++++++++++++----- public/js/claude-renderer.js | 30 ++++++++++++-- public/js/sw-register.js | 11 ++++++ public/js/sw-unregister.js | 6 --- public/js/virtual-keyboard.js | 36 +++++++++++------ public/js/websocket-client.js | 11 +++++- public/login.html | 1 + public/sw.js | 73 ++++++++++++++--------------------- src/protocol/messages.ts | 3 ++ src/server/websocket.ts | 13 +++++++ 12 files changed, 214 insertions(+), 84 deletions(-) create mode 100644 public/js/sw-register.js delete mode 100644 public/js/sw-unregister.js diff --git a/public/index.html b/public/index.html index 94de6c5..d3b821d 100644 --- a/public/index.html +++ b/public/index.html @@ -118,19 +118,19 @@ - + - + - - + + - + - + diff --git a/public/js/app.js b/public/js/app.js index 3f3327e..a2b2403 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -164,6 +164,12 @@ let scrollbackLines = 0; // 0 = live view let inScrollMode = false; let scrollbackPending = false; + // Set once capture-pane stops returning new lines (history exhausted). Without + // this, a surface with little/no scrollback flashes the loader forever: each + // wheel-up at the top re-requests, the response adds nothing, the view stays put + // at scrollTop 0, so the next wheel-up fires again. Reset on surface switch / + // when live updates resume (more history may exist later). + let noMoreHistory = false; const scrollbackLoading = document.getElementById('scrollback-loading'); function requestScrollback(lines) { @@ -177,6 +183,7 @@ inScrollMode = false; scrollbackLines = 0; scrollbackPending = false; + noMoreHistory = false; if (scrollbackLoading) scrollbackLoading.hidden = true; terminal.autoScroll = true; // Re-subscribe to get fresh live content @@ -205,7 +212,7 @@ }); function loadOlderHistory() { - if (scrollbackPending || !terminal.lines.length || + if (scrollbackPending || noMoreHistory || !terminal.lines.length || !currentWorkspace || !currentSurface) return; inScrollMode = true; terminal.autoScroll = false; // pin position; render-fix won't snap to bottom @@ -231,6 +238,7 @@ }, (key) => { if (key === 'PageUp') { + if (noMoreHistory) return; inScrollMode = true; scrollbackLines += PAGE_LINES; requestScrollback(scrollbackLines); @@ -251,6 +259,13 @@ if (currentWorkspace && currentSurface) { ws.sendKey(currentWorkspace, currentSurface, key); } + }, + (text) => { + // onSubmitText: send text + server-side Enter (awaited ordering) + if (currentWorkspace && currentSurface) { + if (inScrollMode) exitScrollMode(); + ws.sendTextSubmit(currentWorkspace, currentSurface, text); + } } ); @@ -352,10 +367,17 @@ // Connection events + // The `.stale` veil dims the WHOLE terminal (a 40%-opacity overlay). Showing + // it on every brief disconnect makes the screen visibly "fade out then back" + // whenever the socket flaps/reconnects (common on remote/tunnel connections). + // Debounce it: only dim once a disconnect has lasted long enough that the view + // is genuinely stale; a fast reconnect clears the pending dim with no flash. + let staleTimer = null; ws.on('connected', () => { statusDot.classList.add('connected'); statusDot.classList.remove('reconnecting'); statusDot.title = 'Connected'; + if (staleTimer) { clearTimeout(staleTimer); staleTimer = null; } terminalContainer.classList.remove('stale'); }); @@ -363,7 +385,12 @@ statusDot.classList.remove('connected'); statusDot.classList.add('reconnecting'); statusDot.title = 'Reconnecting...'; - terminalContainer.classList.add('stale'); + if (!staleTimer && !terminalContainer.classList.contains('stale')) { + staleTimer = setTimeout(() => { + terminalContainer.classList.add('stale'); + staleTimer = null; + }, 2500); + } }); ws.on('reconnecting', ({ attempt }) => { @@ -423,14 +450,25 @@ // prepended, so shift scrollTop by the height added above. const prevScrollHeight = terminalContainer.scrollHeight; const prevScrollTop = terminalContainer.scrollTop; + const prevLineCount = terminal.lines.length; terminal.setContent(msg.lines); terminal.autoScroll = false; scrollbackPending = false; if (scrollbackLoading) scrollbackLoading.hidden = true; + // capture-pane returned no additional lines → history is exhausted. Stop + // further requests so the loader doesn't flash on every wheel-up at the top. + if (msg.lines.length <= prevLineCount) { + noMoreHistory = true; + } + requestAnimationFrame(() => { const added = terminalContainer.scrollHeight - prevScrollHeight; + // Preserve the reading position: keep whatever is on screen exactly where it + // is and let the freshly-loaded older lines sit ABOVE it, so the view doesn't + // jump — scrolling up further then reveals the history smoothly. (Older lines + // are prepended, so shift scrollTop down by the height added above.) terminalContainer.scrollTop = prevScrollTop + Math.max(added, 0); }); } else { @@ -501,6 +539,7 @@ inScrollMode = false; scrollbackLines = 0; scrollbackPending = false; + noMoreHistory = false; if (scrollbackLoading) scrollbackLoading.hidden = true; currentWorkspace = wsRef; diff --git a/public/js/claude-parser.js b/public/js/claude-parser.js index 792cb96..5effc19 100644 --- a/public/js/claude-parser.js +++ b/public/js/claude-parser.js @@ -74,9 +74,21 @@ class ClaudeParser { if (/\u23F8/.test(stripped) || /plan\s+mode/i.test(stripped)) mode = 'plan'; else if (/bypass/i.test(stripped)) mode = 'bypass'; else if (/accept\s+edits/i.test(stripped)) mode = 'accept'; - const pctMatch = stripped.match(/(\d+)%\s+until\s+auto-compact/i); - const stats = pctMatch ? `${pctMatch[1]}% context` : null; - return { mode, branch: null, model: null, stats, ctxPct: null, rawLine: line }; + // A custom statusline often shares this row ("… Opus 4.8 ctx:96% ⏵⏵ bypass + // … shift+tab …"), so pull ctx%/compact% and model from the same line instead + // of dropping them (the dedicated ctx:/compact branches below never run when + // the mode glyph short-circuits here). + const ctxMatch = stripped.match(/ctx:(\d+)%/i); + const compactMatch = stripped.match(/(\d+)%\s+until\s+auto-compact/i); + const modelMatch = stripped.match(/(Opus|Sonnet|Haiku)(?:\s+(\d+(?:\.\d+)?))?/i); + const ctxPct = ctxMatch ? parseInt(ctxMatch[1], 10) + : (compactMatch ? parseInt(compactMatch[1], 10) : null); + const stats = ctxMatch ? `${ctxMatch[1]}%` + : (compactMatch ? `${compactMatch[1]}% context` : null); + const model = modelMatch + ? (modelMatch[2] ? `${modelMatch[1]} ${modelMatch[2]}` : modelMatch[1]) + : null; + return { mode, branch: null, model, stats, ctxPct, rawLine: line }; } // Combined line: "user@host ... 11% until auto-compact" (tmux status + Claude compact %) @@ -104,15 +116,17 @@ class ClaudeParser { const parts = stripped.split('·').map(p => p.trim()); if (parts.length < 2) return null; - let mode = null; + // Require a leading mode glyph (\u23F8/\u23F5) \u2014 otherwise any prose or question line + // containing a "\u00B7" middot (e.g. an inline options prompt) gets mis-detected as a + // status bar and both eaten from the content and leaked into the status pill. const firstPart = parts[0]; - + let mode = null; if (/\u23F8/.test(firstPart)) { mode = 'plan'; } else if (/\u23F5/.test(firstPart)) { mode = 'bypass'; } else { - mode = 'ask'; + return null; } // Branch: first part with no spaces that looks like a branch name @@ -180,7 +194,15 @@ class ClaudeParser { break; } } - blocks.push({ type: 'table', lines: bl, toolName: null, options: null }); + // A real markdown table has a border row with column tees (┌─┬─┐ / ├─┼─┤). + // Decorative boxes (welcome banner, notices, the input frame) use only │ + // side borders / rounded corners and would be mangled into garbage cells by + // the table renderer — render those as plain monospace text instead. + if (this._hasTableStructure(bl)) { + blocks.push({ type: 'table', lines: bl, toolName: null, options: null }); + } else { + blocks.push({ type: 'text', lines: bl, toolName: null, options: null }); + } continue; } @@ -345,6 +367,19 @@ class ClaudeParser { return /^[┌┐└┘├┤┬┴┼╔╗╚╝╠╣╦╩╬┏┓┗┛┣┫┳┻╋]/.test(s); } + _hasTableStructure(blockLines) { + // True only when some line is a horizontal border that splits into columns — + // i.e. a box-drawing border char AND a column tee (┬ ┼ ┴ + heavy/double + // variants). Distinguishes a data table from a single-column decorative box. + for (const l of blockLines) { + const s = this._stripAnsi(l).trimStart(); + if (/^[┌┐└┘├┤┬┴┼┏┓┗┛┣┫┳┻╋╔╗╚╝╠╣╦╩╬]/.test(s) && /[┬┼┴╦╬╩┳╋┻]/.test(s)) { + return true; + } + } + return false; + } + _isSeparatorLine(line) { const stripped = this._stripAnsi(line).trim(); if (stripped.length < 3) return false; @@ -357,9 +392,13 @@ class ClaudeParser { } _isCodeLike(line) { - return /[{}\[\]();=]/.test(line) || - /\b(const|let|var|function|class|import|export|if|else|for|while|return|def|async)\b/.test(line) || - /^\s+\w[\w.]*\s*[({]/.test(line); + // Strong code signals only. The old rule matched any ()/[]/= which turned log + // output and ordinary indented prose-with-parentheses into code blocks. Require + // braces/semicolons, an operator, a code keyword, or an indented call instead. + return /[{};]/.test(line) || + /=>|::|==|!=|<=|>=|&&|\|\|/.test(line) || + /\b(const|let|var|function|class|import|export|return|def|async|await)\b/.test(line) || + /^\s+\w[\w.]*\s*\(/.test(line); } _detectSelectionRanges(lines) { diff --git a/public/js/claude-renderer.js b/public/js/claude-renderer.js index eb5fc38..533744d 100644 --- a/public/js/claude-renderer.js +++ b/public/js/claude-renderer.js @@ -18,6 +18,16 @@ class ClaudeRenderer { return s.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g, ''); } + _dedentRunaway(line) { + // Claude Code's TUI shoves some lines far to the right (often right-aligned to + // the pane edge) when the cmux pane is very wide (e.g. ~185 cols); read-screen + // captures the literal leading spaces, so the parsed reading view renders the + // text pushed off to the right. Prose/output is never legitimately indented this + // far (lists/quotes stay ≤ ~12), so collapse runaway leading whitespace to the + // left. Code / tables / diffs are rendered by other paths and never come here. + return /^[ \t]{16,}\S/.test(line) ? line.replace(/^[ \t]+/, '') : line; + } + _addCopyButton(container, text) { container.classList.add('cc-copy-wrap'); const btn = document.createElement('button'); @@ -56,7 +66,19 @@ class ClaudeRenderer { } _blockSig(block) { - return `${block.type}:${block.lines.length}:${block.lines[0] || ''}`; + // Hash the FULL block content, not just the first line. With a first-line-only + // signature, a block whose inner lines change while type/count/first-line stay + // the same (very common while streaming: tool output, multi-line text, a + // selection whose focused option moves) keeps its stale DOM — and because this + // feeds the top-level render() signature gate, a content-only change skips ALL + // rendering, freezing the view ("parsed, then not"). Hashing fixes both layers. + return `${block.type}:${block.lines.length}:${this._hash(block.lines.join(''))}`; + } + + _hash(s) { + let h = 5381; + for (let i = 0; i < s.length; i++) h = (((h << 5) + h) ^ s.charCodeAt(i)) | 0; + return h; } _signature(blocks) { @@ -217,7 +239,7 @@ class ClaudeRenderer { for (let i = 1; i < block.lines.length; i++) { const line = document.createElement('div'); line.className = 'cc-line'; - line.innerHTML = ansiToHtml(block.lines[i]); + line.innerHTML = ansiToHtml(this._dedentRunaway(block.lines[i])); body.appendChild(line); } div.appendChild(body); @@ -297,7 +319,7 @@ class ClaudeRenderer { lineEl.className = 'cc-line'; // Strip ⎿ prefix marker, preserve ANSI colors const content = line.replace(/^(\x1b\[[0-9;]*m)*\s*\u23BF\s?/, ''); - lineEl.innerHTML = ansiToHtml(content); + lineEl.innerHTML = ansiToHtml(this._dedentRunaway(content)); div.appendChild(lineEl); } return div; @@ -309,7 +331,7 @@ class ClaudeRenderer { for (const line of block.lines) { const lineEl = document.createElement('div'); lineEl.className = 'cc-line'; - lineEl.innerHTML = ansiToHtml(line); + lineEl.innerHTML = ansiToHtml(this._dedentRunaway(line)); div.appendChild(lineEl); } return div; diff --git a/public/js/sw-register.js b/public/js/sw-register.js new file mode 100644 index 0000000..142b2a4 --- /dev/null +++ b/public/js/sw-register.js @@ -0,0 +1,11 @@ +// Register the pass-through service worker (see /sw.js) so ngrok's free-tier browser +// interstitial is skipped on revisits / PWA launches. Registering /sw.js also UPDATES +// (and thereby replaces) any previously-installed caching service worker, whose +// activate handler then clears the old caches. +// +// Caveat: the very first visit on a new browser still shows the ngrok page once — a +// service worker cannot exist before that initial load. ngrok's own cookie suppresses +// it after one "Visit Site" click; the SW then keeps it gone across sessions. +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(() => {}); +} diff --git a/public/js/sw-unregister.js b/public/js/sw-unregister.js deleted file mode 100644 index d31c53d..0000000 --- a/public/js/sw-unregister.js +++ /dev/null @@ -1,6 +0,0 @@ -// Unregister any cached service workers so CSS/JS changes are always fresh -if ('serviceWorker' in navigator) { - navigator.serviceWorker.getRegistrations().then(regs => { - regs.forEach(r => r.unregister()); - }); -} diff --git a/public/js/virtual-keyboard.js b/public/js/virtual-keyboard.js index f148db1..0ce0415 100644 --- a/public/js/virtual-keyboard.js +++ b/public/js/virtual-keyboard.js @@ -1,7 +1,10 @@ class VirtualKeyboard { - constructor(onSendText, onSendKey) { + constructor(onSendText, onSendKey, onSubmitText) { this.onSendText = onSendText; this.onSendKey = onSendKey; + // onSubmitText(text): send text AND submit (server presses Enter after the + // paste completes). Falls back to the old text+timed-Enter if not provided. + this.onSubmitText = onSubmitText; this.ctrlActive = false; this.ctrlLocked = false; this.altActive = false; @@ -66,7 +69,9 @@ class VirtualKeyboard { btn.addEventListener('click', () => { this._haptic(); this.onSendText(btn.dataset.text); - setTimeout(() => this.onSendKey('Enter'), 50); + // Slash-command menus need a tick to populate before submit; give the + // paste enough time to render (same reason as submitText's larger delay). + setTimeout(() => this.onSendKey('Enter'), 200); }); }); @@ -111,17 +116,24 @@ class VirtualKeyboard { this._haptic(); const text = this.textInput.value; if (text) { - // Send the text WITHOUT a trailing newline, then a discrete Enter key - // event. cmux delivers send_text as a bracketed paste, so a trailing - // "\n" becomes a literal newline in the TUI input box (Claude Code / - // Codex) instead of submitting — that's why a single press only filled - // the box and a second (empty) press was needed to actually send. - // This replicates that two-press sequence in one press. The delay lets - // the paste land before Enter; larger for multi-line/big pastes that - // take longer to render than the short data-text shortcut keys. - this.onSendText(text); + // cmux delivers send_text as a bracketed paste, so a trailing "\n" is a + // literal newline in the TUI input box (Claude Code / Codex), not a submit. + // The submit therefore needs a discrete Enter AFTER the paste lands. this.textInput.value = ''; - setTimeout(() => this.onSendKey('Enter'), 100); + if (this.onSubmitText) { + // Preferred: the server presses Enter only AFTER send_text completes + // (awaited ordering), so there is no client-side race between the paste + // and the Enter — fixes the "had to press Enter twice" over slow/remote + // connections. + this.onSubmitText(text); + } else { + // Fallback for an older server that doesn't understand submit: two + // messages with a size-scaled client delay (timing-fragile). + this.onSendText(text); + const lineCount = (text.match(/\n/g) || []).length + 1; + const enterDelay = Math.min(900, 300 + (lineCount - 1) * 80); + setTimeout(() => this.onSendKey('Enter'), enterDelay); + } } else { // Empty submit = Enter key this.onSendKey('Enter'); diff --git a/public/js/websocket-client.js b/public/js/websocket-client.js index 2ca3502..834aac9 100644 --- a/public/js/websocket-client.js +++ b/public/js/websocket-client.js @@ -40,8 +40,11 @@ class WebSocketClient { this.ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); + // Any inbound message proves the connection is alive — not just pings. + // Otherwise a busy stream of screen-diffs with delayed pings could trip + // the 15s heartbeat and force a needless close/reconnect (screen flash). + this._lastServerPing = Date.now(); if (msg.type === 'ping') { - this._lastServerPing = Date.now(); this.send({ type: 'pong' }); return; } @@ -118,6 +121,12 @@ class WebSocketClient { this.send({ type: 'send-text', workspace, surface, text }); } + // Send text and have the SERVER press Enter once send_text completes (awaited + // ordering). Reliable submit without a client-side Enter timing race. + sendTextSubmit(workspace, surface, text) { + this.send({ type: 'send-text', workspace, surface, text, submit: true }); + } + sendKey(workspace, surface, key) { this.send({ type: 'send-key', workspace, surface, key }); } diff --git a/public/login.html b/public/login.html index 1dfca50..b98ea4e 100644 --- a/public/login.html +++ b/public/login.html @@ -272,5 +272,6 @@ + diff --git a/public/sw.js b/public/sw.js index 31cc178..9c76e33 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,53 +1,40 @@ -const CACHE_NAME = 'cmux-remote-v3'; -const STATIC_ASSETS = [ - '/', - '/index.html', - '/login.html', - '/css/main.css', - '/css/terminal.css', - '/css/keyboard.css', - '/css/sidebar.css', - '/css/themes.css', - '/js/app.js', - '/js/auth.js', - '/js/terminal-view.js', - '/js/websocket-client.js', - '/js/virtual-keyboard.js', - '/js/sidebar.js', - '/js/gestures.js', - '/js/theme.js', -]; - -self.addEventListener('install', (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)) - ); - self.skipWaiting(); -}); +// Pass-through service worker — does NOT cache responses (response caching is what +// made us disable the previous SW; assets must always come fresh from the network). +// +// Its only job: add the `ngrok-skip-browser-warning` header to top-level navigation +// requests, so ngrok's free-tier browser interstitial ("You are about to visit …") +// doesn't appear on repeat visits / PWA launches. ngrok skips the warning whenever +// that header is present (verified). The very first visit on a fresh browser still +// shows it once — the SW can't exist before that initial load — but ngrok's own +// cookie also suppresses it after a single "Visit Site" click. +self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('activate', (event) => { - event.waitUntil( - caches.keys().then((keys) => - Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) - ) - ); - self.clients.claim(); + event.waitUntil((async () => { + // Drop any caches left behind by the old caching SW so nothing serves stale. + const keys = await caches.keys(); + await Promise.all(keys.map((k) => caches.delete(k))); + await self.clients.claim(); + })()); }); self.addEventListener('fetch', (event) => { - // Skip API and WebSocket requests - const url = new URL(event.request.url); - if (url.pathname.startsWith('/api/') || event.request.headers.get('upgrade') === 'websocket') { - return; - } + const req = event.request; + // Only top-level navigations trigger ngrok's interstitial. Leave everything else + // (assets, /api, POST, WebSocket upgrades) completely untouched — no rewrite and + // no caching — so behaviour is otherwise identical to having no SW at all. + if (req.mode !== 'navigate') return; + const headers = new Headers(req.headers); + headers.set('ngrok-skip-browser-warning', 'true'); event.respondWith( - fetch(event.request) - .then((response) => { - const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); - return response; + fetch( + new Request(req.url, { + method: 'GET', + headers, + credentials: req.credentials, + redirect: 'manual', }) - .catch(() => caches.match(event.request)) + ).catch(() => fetch(req)) ); }); diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts index 74ac2f4..0c5d189 100644 --- a/src/protocol/messages.ts +++ b/src/protocol/messages.ts @@ -97,6 +97,9 @@ export interface SendTextMessage { workspace: string; surface: string; text: string; + // When true, the server presses Enter AFTER send_text completes (awaited + // ordering), so the paste reliably submits without a client-side timing race. + submit?: boolean; } export interface SendKeyMessage { diff --git a/src/server/websocket.ts b/src/server/websocket.ts index e9e838f..e6ff274 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -138,6 +138,19 @@ export class WsServer { if (this.verbose) console.log(`[ws] ${clientId} send-text to ${msg.surface}`); this.poller.resetToFast(msg.surface); await this.input.handleText(msg.workspace, msg.surface, msg.text); + if (msg.submit) { + // Await-ordered submit: send_text has now fully completed (bytes are + // in the PTY), so the Enter cannot race ahead of the bracketed paste + // the way a client-side timer could. The short local delay covers the + // TUI's async ingestion of the paste before the key event (send_key + // uses ghostty key-injection, which is not PTY-ordered against bytes). + await new Promise((resolve) => setTimeout(resolve, 200)); + await this.input + .handleKey(msg.workspace, msg.surface, 'Enter') + .catch((err) => { + if (this.verbose) console.error(`[ws] submit Enter error:`, err); + }); + } break; case 'send-key':