diff --git a/bin/cmux-remote.js b/bin/cmux-remote.js index 924fff1..9cbedaf 100755 --- a/bin/cmux-remote.js +++ b/bin/cmux-remote.js @@ -1,2 +1,3 @@ #!/usr/bin/env node +process.noDeprecation = true; import '../dist/index.js'; diff --git a/package.json b/package.json index ab43041..752355a 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,14 @@ "bin": { "cmux-remote": "./bin/cmux-remote.js" }, + "files": [ + "bin", + "dist", + "public" + ], "scripts": { "build": "tsc", + "prepublishOnly": "npm run build", "dev": "tsx src/index.ts", "dev:local": "tsx src/index.ts --no-tunnel", "start": "node dist/index.js", diff --git a/public/js/app.js b/public/js/app.js index 83b0c6b..a415483 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -163,15 +163,18 @@ const PAGE_LINES = 500; let scrollbackLines = 0; // 0 = live view let inScrollMode = false; + let scrollbackPending = false; function requestScrollback(lines) { if (!currentWorkspace || !currentSurface) return; + scrollbackPending = true; ws.requestScroll(currentWorkspace, currentSurface, lines); } function exitScrollMode() { inScrollMode = false; scrollbackLines = 0; + scrollbackPending = false; terminal.autoScroll = true; // Re-subscribe to get fresh live content if (currentWorkspace && currentSurface) { @@ -179,6 +182,16 @@ } } + // Auto-load scrollback when user scrolls to the top + terminalContainer.addEventListener('scroll', () => { + if (scrollbackPending) return; + if (terminalContainer.scrollTop < 50 && currentWorkspace && currentSurface) { + inScrollMode = true; + scrollbackLines += PAGE_LINES; + requestScrollback(scrollbackLines); + } + }); + const keyboard = new VirtualKeyboard( (text) => { if (currentWorkspace && currentSurface) { @@ -337,8 +350,11 @@ }); ws.on('auth-ok', () => { - // Request workspace list after auth ws.listWorkspaces(); + // Re-subscribe to current surface after reconnect + if (currentWorkspace && currentSurface) { + ws.subscribe(currentWorkspace, currentSurface); + } }); ws.on('workspaces', (msg) => { @@ -378,16 +394,24 @@ ws.on('screen', (msg) => { if (msg.surface !== currentSurface) return; if (inScrollMode && !msg.scrollback) return; - terminal.setContent(msg.lines); if (msg.scrollback) { + // Preserve scroll position relative to bottom so content doesn't jump + const prevScrollHeight = terminalContainer.scrollHeight; + const prevScrollTop = terminalContainer.scrollTop; + + terminal.setContent(msg.lines); terminal.autoScroll = false; - // After render, scroll to show content just before current view (most recently scrolled-off) + scrollbackPending = false; + requestAnimationFrame(() => { - requestAnimationFrame(() => { - terminalContainer.scrollTop = terminalContainer.scrollHeight - terminalContainer.clientHeight; - }); + const newScrollHeight = terminalContainer.scrollHeight; + const added = newScrollHeight - prevScrollHeight; + // Keep the same content visible — shift scroll by the amount of new content added above + terminalContainer.scrollTop = prevScrollTop + Math.max(added, 0); }); + } else { + terminal.setContent(msg.lines); } }); @@ -481,12 +505,22 @@ } }); - // Visibility API: pause polling when hidden + // Visibility API: pause polling when hidden, reconnect when visible + let hiddenSince = 0; document.addEventListener('visibilitychange', () => { if (document.hidden) { + hiddenSince = Date.now(); if (currentSurface) ws.unsubscribe(currentSurface); } else { - if (currentWorkspace && currentSurface) { + const elapsed = hiddenSince ? Date.now() - hiddenSince : 0; + hiddenSince = 0; + if (!currentWorkspace || !currentSurface) return; + + // iOS kills WebSocket connections in background. + // If hidden >5s or connection is dead, force fresh reconnect. + if (!ws.isConnected() || elapsed > 5000) { + ws.reconnectNow(); + } else { ws.subscribe(currentWorkspace, currentSurface); } } diff --git a/public/js/claude-parser.js b/public/js/claude-parser.js index 0101ce8..1d16e1d 100644 --- a/public/js/claude-parser.js +++ b/public/js/claude-parser.js @@ -49,7 +49,7 @@ class ClaudeParser { } _tryParseStatusBar(line) { - const stripped = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim(); + const stripped = this._stripAnsi(line).trim(); // Exclude prompt and spinner/thinking lines — not a status bar if (/^[\u276F\u2700-\u27BF]/.test(stripped)) return null; @@ -261,8 +261,9 @@ class ClaudeParser { } _stripAnsi(line) { + // Strip CSI (\x1b[...X), OSC (\x1b]...BEL/ST), and other single-char ESC sequences // eslint-disable-next-line no-control-regex - return line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); + return line.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g, ''); } _isBlockStart(line) { @@ -293,9 +294,7 @@ class ClaudeParser { } _isSeparatorLine(line) { - // Strip ANSI escape codes (e.g. \x1b[38;5;240m ... \x1b[0m) before analysis - // eslint-disable-next-line no-control-regex - const stripped = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim(); + const stripped = this._stripAnsi(line).trim(); if (stripped.length < 3) return false; // Must consist mostly of horizontal box-drawing characters const sepChars = (stripped.match(/[\u2500\u2501\u2550\u254C\u254D\u2574\u2576\u2578\u257A\u2015\u2014]/g) || []).length; @@ -322,7 +321,7 @@ class ClaudeParser { // ❯ N. text — cursor-prefixed option (the currently focused selection item) const CURSOR_OPTION_RE = /^\s*\u276F\s*(\d+|[a-z])[.)]\s+\S/; // Markdown formatting signals response text, not a real selection prompt - const HAS_MARKDOWN = /\*\*|(? 0) this.scheduleRender(); } else { if (this.claudeRenderer) this.claudeRenderer.clear(); } diff --git a/src/index.ts b/src/index.ts index 396a528..707668b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +process.noDeprecation = true; + import { createServer } from 'node:http'; import { readFileSync, existsSync } from 'node:fs'; import { homedir } from 'node:os';