Compare commits

...

3 Commits

Author SHA1 Message Date
I Luk Kim d34359c9ab fix: Claude view parsing/scroll, ngrok warning bypass, reliable send
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 <noreply@anthropic.com>
2 months ago
I Luk Kim 5e473b9ff6 fix: stop 8s terminal blank-flash on non-Claude surfaces
The periodic Claude-mode re-check (setInterval 8s) called
setClaudeMode(false) on plain/codex surfaces, which ran
claudeRenderer.clear() unconditionally. clear() empties the shared
output element (this.el), blanking live terminal/codex content until
the next repaint — perceived as text fading out then back every 8s.

- app.js: remove the 8s claude re-check interval
- terminal-view.js: only clear the renderer on an actual mode
  transition (changed), so redundant setClaudeMode/setCodexMode(false)
  no longer wipes the shared output
- index.html: bump terminal-view.js v12, app.js v21

Verified deterministically in Playwright: pre-fix the output wiped
48->0 ~350ms after each 8s /api/claude-surfaces fetch; post-fix 18s
window shows 0 fetches and content intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 months ago
I Luk Kim 1f50014296 fix: scroll reliability, Claude-mode detection, desktop send
Scrolling:
- Fix snap-back race: render() read a stale autoScroll cache (updated only
  on async scroll events), yanking scrolled-up users back to the bottom
  during live output. Now measure the real bottom position from the DOM at
  render time and gate auto-follow on an explicit autoScroll flag.
- Restore scroll-to-top history loading; add a wheel-up handler so desktop
  (no touch, screen fits viewport → no scroll room → no scroll event) can
  still load scrollback. Resume live updates on scroll-to-bottom.
- Remove the auto-load-when-fits path that permanently froze live updates.
- terminal-container: touch-action pan-y, overflow-y scroll, overscroll
  contain; drop terminal-output min-height:100%; desktop app-container
  explicit 100dvh height.
- gestures.js touchmove → passive (let Safari composite child scroll).

Claude-mode detection:
- Match node-wrapped `claude` processes (check all ps args tokens).
- Enumerate ALL workspaces via `cmux tree --all`, not just the active
  window that the workspace.list socket returns.
- Don't cache empty results; keep last-known-good to survive transient
  ps/socket failures. Periodic client-side re-check.

Misc:
- Desktop: Enter sends, Shift/Cmd+Enter newline (mobile unchanged).
- Keyboard area touch-action:none (no vertical drift), key row pan-x.
- claude-parser _stripAnsi also strips C0/C1 control chars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 months ago

@ -40,12 +40,7 @@ body.codex-mode .cc-prompt {
flex-wrap: wrap;
}
body.claude-mode .cc-prompt.cc-prompt-active,
body.codex-mode .cc-prompt.cc-prompt-active {
background: var(--bg-tertiary);
border-left-color: var(--green);
border-top-color: var(--border-subtle);
}
body.claude-mode .cc-prompt-marker,
body.codex-mode .cc-prompt-marker {

@ -5,6 +5,7 @@
-webkit-backdrop-filter: blur(var(--blur-amount));
border-top: 1px solid var(--border-color);
padding: 6px 10px 2px;
touch-action: none;
}
.key-row {
@ -204,6 +205,7 @@
flex-wrap: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
touch-action: pan-x;
}
.key-row.all-keys::-webkit-scrollbar { display: none; }
.key-row.all-keys .key.shortcut { min-width: 32px; padding: 0 5px; }

@ -195,9 +195,11 @@ body {
.terminal-container {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-y: scroll;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
touch-action: pan-y;
background: var(--terminal-bg);
position: relative;
}
@ -227,6 +229,8 @@ body {
.app-container {
flex: 1;
min-width: 0;
height: 100vh;
height: 100dvh;
}
.top-bar {

@ -164,7 +164,6 @@
word-break: break-all;
color: var(--text-primary);
contain: style;
min-height: 100%;
-webkit-user-select: text;
user-select: text;
font-feature-settings: "liga" 0, "calt" 0;

@ -11,11 +11,11 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/css/themes.css?v=2">
<link rel="stylesheet" href="/css/main.css?v=9">
<link rel="stylesheet" href="/css/terminal.css?v=5">
<link rel="stylesheet" href="/css/keyboard.css?v=14">
<link rel="stylesheet" href="/css/main.css?v=13">
<link rel="stylesheet" href="/css/terminal.css?v=6">
<link rel="stylesheet" href="/css/keyboard.css?v=15">
<link rel="stylesheet" href="/css/sidebar.css?v=6">
<link rel="stylesheet" href="/css/claude.css?v=17">
<link rel="stylesheet" href="/css/claude.css?v=18">
</head>
<body>
<!-- Sidebar -->
@ -118,19 +118,19 @@
</div>
</aside>
<script src="/js/websocket-client.js?v=4"></script>
<script src="/js/terminal-view.js?v=9"></script>
<script src="/js/virtual-keyboard.js?v=9"></script>
<script src="/js/websocket-client.js?v=6"></script>
<script src="/js/terminal-view.js?v=12"></script>
<script src="/js/virtual-keyboard.js?v=12"></script>
<script src="/js/sidebar.js?v=5"></script>
<script src="/js/gestures.js?v=2"></script>
<script src="/js/gestures.js?v=3"></script>
<script src="/js/theme.js?v=4"></script>
<script src="/js/claude-parser.js?v=23"></script>
<script src="/js/claude-renderer.js?v=21"></script>
<script src="/js/claude-parser.js?v=30"></script>
<script src="/js/claude-renderer.js?v=24"></script>
<script src="/js/claude-keyboard.js?v=5"></script>
<script src="/js/codex-parser.js?v=1"></script>
<script src="/js/codex-renderer.js?v=1"></script>
<script src="/js/app.js?v=14"></script>
<script src="/js/app.js?v=26"></script>
<script src="/js/debug-safe-area.js"></script>
<script src="/js/sw-unregister.js"></script>
<script src="/js/sw-register.js?v=1"></script>
</body>
</html>

@ -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
@ -185,16 +192,43 @@
}
}
// Auto-load scrollback when user scrolls to the top
// Scroll-driven history: reaching the top (with scroll room) loads older
// history; reaching the bottom resumes live updates. Entry is explicit (only
// on an actual scroll-to-top), never automatic — that avoids the freeze the
// old auto-load-when-fits caused.
terminalContainer.addEventListener('scroll', () => {
if (scrollbackPending || !terminal.lines.length) return;
if (terminalContainer.scrollTop < 50 && currentWorkspace && currentSurface) {
inScrollMode = true;
scrollbackLines += PAGE_LINES;
requestScrollback(scrollbackLines);
const c = terminalContainer;
const atBottom = c.scrollHeight - c.scrollTop - c.clientHeight < 30;
if (atBottom) {
if (inScrollMode) exitScrollMode();
return;
}
if (c.scrollTop < 40 && !scrollbackPending && terminal.lines.length &&
currentWorkspace && currentSurface) {
loadOlderHistory();
}
});
function loadOlderHistory() {
if (scrollbackPending || noMoreHistory || !terminal.lines.length ||
!currentWorkspace || !currentSurface) return;
inScrollMode = true;
terminal.autoScroll = false; // pin position; render-fix won't snap to bottom
scrollbackLines += PAGE_LINES;
requestScrollback(scrollbackLines);
}
// Desktop has no touch scroll. When the live screen fits the viewport exactly
// there is no scroll room, so the 'scroll' event never fires and history can't
// be reached. Catch the wheel-up intent directly to load older history.
terminalContainer.addEventListener('wheel', (e) => {
if (e.deltaY < 0 && terminalContainer.scrollTop < 40) {
loadOlderHistory();
}
}, { passive: true });
const keyboard = new VirtualKeyboard(
(text) => {
if (currentWorkspace && currentSurface) {
@ -204,6 +238,7 @@
},
(key) => {
if (key === 'PageUp') {
if (noMoreHistory) return;
inScrollMode = true;
scrollbackLines += PAGE_LINES;
requestScrollback(scrollbackLines);
@ -224,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);
}
}
);
@ -325,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');
});
@ -336,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 }) => {
@ -387,39 +441,38 @@
ws.on('screen', (msg) => {
if (msg.surface !== currentSurface) return;
// While viewing loaded history, pause live screen replacement.
if (inScrollMode && !msg.scrollback) return;
if (msg.scrollback) {
// Preserve scroll position relative to bottom so content doesn't jump
// User-triggered history load (scroll-to-top or PageUp): show it, pause
// live updates, and preserve the user's reading position. Older lines are
// 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 newScrollHeight = terminalContainer.scrollHeight;
const added = newScrollHeight - prevScrollHeight;
// Keep the same content visible — shift scroll by the amount of new content added above
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 {
terminal.setContent(msg.lines);
// If content fits in viewport and no scrollback loaded yet, auto-load
// so the user always has something to scroll through
if (scrollbackLines === 0 && !scrollbackPending && !inScrollMode) {
requestAnimationFrame(() => {
if (scrollbackLines === 0 && !scrollbackPending && !inScrollMode &&
terminal.lines.length > 0 && currentWorkspace && currentSurface &&
terminalContainer.scrollHeight <= terminalContainer.clientHeight) {
inScrollMode = true;
scrollbackLines = PAGE_LINES;
requestScrollback(scrollbackLines);
}
});
}
}
});
@ -486,6 +539,7 @@
inScrollMode = false;
scrollbackLines = 0;
scrollbackPending = false;
noMoreHistory = false;
if (scrollbackLoading) scrollbackLoading.hidden = true;
currentWorkspace = wsRef;

@ -27,14 +27,6 @@ class ClaudeParser {
const blocks = this._parseBlocks(contentLines);
// Mark the last prompt as active (current input area)
for (let i = blocks.length - 1; i >= 0; i--) {
if (blocks[i].type === 'prompt') {
blocks[i].isActive = true;
break;
}
}
return { blocks, statusBar };
}
@ -82,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 %)
@ -112,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
@ -188,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;
}
@ -207,10 +221,11 @@ class ClaudeParser {
if (selectionLineSet.has(i)) break;
bl.push(lines[i++]);
}
// Skip empty prompt blocks — these are the input cursor, not submitted messages
// Only check the first line's text (not continuation lines)
const firstLineText = bl[0].replace(/^\s*\u276F\s*/, '').trim();
if (!firstLineText) continue;
// Skip empty prompt blocks (input cursor). Strip ANSI first so color codes
// before don't prevent the match. Also skip when content is only separator
// chars — Claude Code renders ────── on the active input line.
const firstLineText = this._stripAnsi(bl[0]).replace(/^\s*\s*/, '').trim();
if (!firstLineText || this._isSeparatorLine(firstLineText)) continue;
blocks.push({ type: 'prompt', lines: bl, toolName: null, options: null });
continue;
}
@ -315,9 +330,12 @@ class ClaudeParser {
}
_stripAnsi(line) {
// Strip CSI (\x1b[...X), OSC (\x1b]...BEL/ST), and other single-char ESC sequences
// Strip ANSI escape sequences, then C0/C1 control chars (Shift-In \x0f etc.)
// that tmux can inject before prompt characters, breaking block detection.
// eslint-disable-next-line no-control-regex
return line.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g, '');
return line
.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g, '')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '');
}
_isBlockStart(line) {
@ -349,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;
@ -361,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) {

@ -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) {
@ -142,7 +164,7 @@ class ClaudeRenderer {
_renderPrompt(block) {
const div = document.createElement('div');
div.className = block.isActive ? 'cc-prompt cc-prompt-active' : 'cc-prompt';
div.className = 'cc-prompt';
const firstLine = block.lines[0];
const stripped = this._stripAnsi(firstLine);
@ -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;

@ -6,7 +6,7 @@ class GestureHandler {
this.tracking = false;
document.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: true });
document.addEventListener('touchmove', (e) => this.onTouchMove(e), { passive: false });
document.addEventListener('touchmove', (e) => this.onTouchMove(e), { passive: true });
document.addEventListener('touchend', (e) => this.onTouchEnd(e), { passive: true });
}
@ -19,22 +19,6 @@ class GestureHandler {
onTouchMove(e) {
if (!this.tracking) return;
const touch = e.touches[0];
const dx = touch.clientX - this.startX;
const dy = touch.clientY - this.startY;
// Only track horizontal swipes from edge
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 10) {
// Swipe right from left edge to open sidebar
if (dx > 0 && this.startX < 50 && !this.sidebar.isOpen()) {
e.preventDefault();
}
// Swipe left to close sidebar
if (dx < 0 && this.sidebar.isOpen()) {
e.preventDefault();
}
}
}
onTouchEnd(e) {

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

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

@ -105,13 +105,11 @@ class TerminalView {
this.codexParser = null;
this.codexRenderer = null;
// Track scroll position to determine auto-scroll
const container = this.el.parentElement;
container.addEventListener('scroll', () => {
const atBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < 30;
this.autoScroll = atBottom;
});
// autoScroll is an explicit gate (default on). It is NOT recomputed from
// scroll position on every scroll event — that produced a stale-read race
// where a render firing before the scroll event dispatched would yank the
// view back to the bottom. Instead render() measures the real position at
// render time. app.js sets this false only to pin position in PageUp mode.
}
setContent(lines) {
@ -140,7 +138,8 @@ class TerminalView {
if (!this.codexRenderer) this.codexRenderer = new CodexRenderer(this.el);
if (changed && this.lines.length > 0) this.scheduleRender();
} else {
if (this.codexRenderer) this.codexRenderer.clear();
// Same as setClaudeMode: only clear on a real transition out of codex mode.
if (changed && this.codexRenderer) this.codexRenderer.clear();
}
}
@ -161,7 +160,11 @@ class TerminalView {
// 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();
// Only clear on an actual transition OUT of claude mode. A redundant
// setClaudeMode(false) on an already-plain surface must NOT wipe the
// output — claudeRenderer.clear() empties the shared `this.el`, which
// would blank the live terminal/codex content until the next repaint.
if (changed && this.claudeRenderer) this.claudeRenderer.clear();
}
}
@ -169,7 +172,7 @@ class TerminalView {
if (this.renderPending) return;
this.renderPending = true;
requestAnimationFrame(() => {
this.render();
try { this.render(); } catch (e) { console.error('[render]', e); }
this.renderPending = false;
});
}
@ -182,23 +185,26 @@ class TerminalView {
}
render() {
const container = this.el.parentElement;
// Measure the real scroll position BEFORE mutating the DOM. Only auto-follow
// if the explicit gate is on AND the view is actually at the bottom right now.
// Reading the live DOM here (instead of a scroll-event-derived cache) avoids
// the stale-read race that yanked scrolled-up users back to the bottom.
const wasAtBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < 40;
const follow = this.autoScroll && wasAtBottom;
if (this.codexMode && this.codexParser && this.codexRenderer) {
const doc = this.codexParser.parse(this.lines);
this.codexRenderer.render(doc);
if (this.autoScroll) {
const container = this.el.parentElement;
container.scrollTop = container.scrollHeight;
}
if (follow) container.scrollTop = container.scrollHeight;
return;
}
if (this.claudeMode && this.claudeParser && this.claudeRenderer) {
const doc = this.claudeParser.parse(this.lines);
this.claudeRenderer.render(doc);
if (this.autoScroll) {
const container = this.el.parentElement;
container.scrollTop = container.scrollHeight;
}
if (follow) container.scrollTop = container.scrollHeight;
return;
}
@ -230,10 +236,7 @@ class TerminalView {
const html = output.join('\n');
this.el.innerHTML = html;
if (this.autoScroll) {
const container = this.el.parentElement;
container.scrollTop = container.scrollHeight;
}
if (follow) container.scrollTop = container.scrollHeight;
}
highlightLine(line) {

@ -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;
@ -38,6 +41,16 @@ class VirtualKeyboard {
this.sendBtn.addEventListener('mousedown', (e) => e.preventDefault());
this.sendBtn.addEventListener('click', () => this.submitText());
// Desktop: Enter = send, Shift+Enter / Cmd+Enter = newline.
// Mobile keeps the default (Enter = newline, send button submits).
this.textInput.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
if (window.innerWidth < 1024) return;
if (e.shiftKey || e.metaKey) return;
e.preventDefault();
this.submitText();
});
// Ctrl modifier
this.ctrlKey.addEventListener('click', () => this.toggleModifier('ctrl'));
this.ctrlKey.addEventListener('dblclick', () => this.lockModifier('ctrl'));
@ -56,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);
});
});
@ -101,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');

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

@ -272,5 +272,6 @@
</form>
</div>
<script src="/js/auth.js"></script>
<script src="/js/sw-register.js?v=1"></script>
</body>
</html>

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

@ -21,6 +21,7 @@ export class CmuxClient {
private running = 0;
private queue: Array<() => void> = [];
private cache = new Map<string, CacheEntry<unknown>>();
private lastGood = new Map<string, string[]>();
private shuttingDown = false;
constructor(cmuxPath = 'cmux') {
@ -235,10 +236,13 @@ export class CmuxClient {
const pids: string[] = [];
for (const line of psListOut.split('\n')) {
const parts = line.trim().split(/\s+/);
const cmd = parts[1] || '';
if (cmd.endsWith(`/${processName}`) || cmd === processName) {
pids.push(parts[0]);
}
if (parts.length < 2) continue;
// Check all args tokens: handles both native binaries (/usr/bin/claude)
// and Node.js wrappers (node /usr/local/bin/claude ...).
const matched = parts.slice(1).some(
p => p === processName || p.endsWith(`/${processName}`)
);
if (matched) pids.push(parts[0]);
}
for (const pid of pids) {
try {
@ -250,26 +254,29 @@ export class CmuxClient {
} catch { /* no matching processes */ }
if (uuids.size === 0) {
this.setCache(cacheKey, []);
return [];
// Don't cache empty — transient ps failure; return last known good result.
return this.lastGood.get(cacheKey) ?? [];
}
// Step 2: get workspace list to iterate
const wsResult = await this.socketQuery<{ workspaces: Array<{ id: string }> }>('workspace.list', {});
// Step 2: get ALL workspaces via cmux tree --all (covers every window, not just
// the active one that workspace.list socket returns).
const allWorkspaces = await this.listWorkspaces();
// Step 3: for each workspace, get surfaces and match UUID → ref
const refs: string[] = [];
for (const ws of wsResult.workspaces) {
for (const ws of allWorkspaces) {
try {
const surfResult = await this.socketQuery<{ surfaces: Array<{ id: string; ref: string }> }>('surface.list', { workspace_id: ws.id });
const surfResult = await this.socketQuery<{ surfaces: Array<{ id: string; ref: string }> }>('surface.list', { workspace_id: ws.ref });
for (const s of surfResult.surfaces) {
if (uuids.has(s.id)) refs.push(s.ref);
}
} catch { /* skip workspace */ }
}
this.setCache(cacheKey, refs);
return refs;
if (refs.length > 0) this.lastGood.set(cacheKey, refs);
// Only cache non-empty results; empty may mean transient socket failure.
if (refs.length > 0) this.setCache(cacheKey, refs);
return refs.length > 0 ? refs : (this.lastGood.get(cacheKey) ?? []);
}
async getClaudeCodeSurfaceRefs(): Promise<string[]> {

@ -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 {

@ -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':

Loading…
Cancel
Save