feat: auto-load scrollback on scroll-to-top, CLI packaging, misc fixes

- Auto-request scrollback history when user scrolls to top of terminal
- Preserve scroll position when prepending scrollback content
- Add files/prepublishOnly to package.json for npm install -g support
- Suppress url.parse() deprecation warning from Express internals
- Fix reconnect: re-subscribe to surface after auth-ok on reconnect
- Improve iOS background recovery with visibility API + elapsed check
- Harden ANSI stripping to handle OSC and other escape sequences

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent 5b9780fafb
commit e411ab38db

@ -1,2 +1,3 @@
#!/usr/bin/env node
process.noDeprecation = true;
import '../dist/index.js';

@ -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",

@ -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);
}
}

@ -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 = /\*\*|(?<!\w)`(?!\s)/;
const HAS_MARKDOWN = /\*\*|`[^\s`]/;
const ranges = [];
let rStart = -1;

@ -126,6 +126,7 @@ class TerminalView {
}
setClaudeMode(active, onAction) {
const changed = this.claudeMode !== active;
this.claudeMode = active;
if (active) {
if (!this.claudeParser) this.claudeParser = new ClaudeParser();
@ -138,6 +139,8 @@ class TerminalView {
container.appendChild(this.claudeRenderer.statusBarEl);
}
this.claudeRenderer.setOnAction(onAction);
// Re-render existing content in claude mode (fixes race with async mode detection)
if (changed && this.lines.length > 0) this.scheduleRender();
} else {
if (this.claudeRenderer) this.claudeRenderer.clear();
}

@ -1,3 +1,5 @@
process.noDeprecation = true;
import { createServer } from 'node:http';
import { readFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';

Loading…
Cancel
Save