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>
main
I Luk Kim 2 months ago
parent 577b4d749b
commit 1f50014296

@ -40,12 +40,7 @@ body.codex-mode .cc-prompt {
flex-wrap: wrap; 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.claude-mode .cc-prompt-marker,
body.codex-mode .cc-prompt-marker { body.codex-mode .cc-prompt-marker {

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

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

@ -164,7 +164,6 @@
word-break: break-all; word-break: break-all;
color: var(--text-primary); color: var(--text-primary);
contain: style; contain: style;
min-height: 100%;
-webkit-user-select: text; -webkit-user-select: text;
user-select: text; user-select: text;
font-feature-settings: "liga" 0, "calt" 0; 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-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <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/themes.css?v=2">
<link rel="stylesheet" href="/css/main.css?v=9"> <link rel="stylesheet" href="/css/main.css?v=13">
<link rel="stylesheet" href="/css/terminal.css?v=5"> <link rel="stylesheet" href="/css/terminal.css?v=6">
<link rel="stylesheet" href="/css/keyboard.css?v=14"> <link rel="stylesheet" href="/css/keyboard.css?v=15">
<link rel="stylesheet" href="/css/sidebar.css?v=6"> <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> </head>
<body> <body>
<!-- Sidebar --> <!-- Sidebar -->
@ -119,17 +119,17 @@
</aside> </aside>
<script src="/js/websocket-client.js?v=4"></script> <script src="/js/websocket-client.js?v=4"></script>
<script src="/js/terminal-view.js?v=9"></script> <script src="/js/terminal-view.js?v=11"></script>
<script src="/js/virtual-keyboard.js?v=9"></script> <script src="/js/virtual-keyboard.js?v=10"></script>
<script src="/js/sidebar.js?v=5"></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/theme.js?v=4"></script>
<script src="/js/claude-parser.js?v=23"></script> <script src="/js/claude-parser.js?v=29"></script>
<script src="/js/claude-renderer.js?v=21"></script> <script src="/js/claude-renderer.js?v=22"></script>
<script src="/js/claude-keyboard.js?v=5"></script> <script src="/js/claude-keyboard.js?v=5"></script>
<script src="/js/codex-parser.js?v=1"></script> <script src="/js/codex-parser.js?v=1"></script>
<script src="/js/codex-renderer.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=20"></script>
<script src="/js/debug-safe-area.js"></script> <script src="/js/debug-safe-area.js"></script>
<script src="/js/sw-unregister.js"></script> <script src="/js/sw-unregister.js"></script>
</body> </body>

@ -185,15 +185,42 @@
} }
} }
// 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', () => { terminalContainer.addEventListener('scroll', () => {
if (scrollbackPending || !terminal.lines.length) return; const c = terminalContainer;
if (terminalContainer.scrollTop < 50 && currentWorkspace && currentSurface) { 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 || !terminal.lines.length ||
!currentWorkspace || !currentSurface) return;
inScrollMode = true; inScrollMode = true;
terminal.autoScroll = false; // pin position; render-fix won't snap to bottom
scrollbackLines += PAGE_LINES; scrollbackLines += PAGE_LINES;
requestScrollback(scrollbackLines); 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( const keyboard = new VirtualKeyboard(
(text) => { (text) => {
@ -387,10 +414,13 @@
ws.on('screen', (msg) => { ws.on('screen', (msg) => {
if (msg.surface !== currentSurface) return; if (msg.surface !== currentSurface) return;
// While viewing loaded history, pause live screen replacement.
if (inScrollMode && !msg.scrollback) return; if (inScrollMode && !msg.scrollback) return;
if (msg.scrollback) { 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 prevScrollHeight = terminalContainer.scrollHeight;
const prevScrollTop = terminalContainer.scrollTop; const prevScrollTop = terminalContainer.scrollTop;
@ -400,26 +430,11 @@
if (scrollbackLoading) scrollbackLoading.hidden = true; if (scrollbackLoading) scrollbackLoading.hidden = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
const newScrollHeight = terminalContainer.scrollHeight; const added = terminalContainer.scrollHeight - prevScrollHeight;
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); terminalContainer.scrollTop = prevScrollTop + Math.max(added, 0);
}); });
} else { } else {
terminal.setContent(msg.lines); 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);
}
});
}
} }
}); });
@ -526,6 +541,13 @@
} }
} }
// Periodically re-check Claude mode in case initial detection failed transiently.
setInterval(() => {
if (currentSurface && !document.body.classList.contains('claude-mode')) {
updateClaudeMode(currentSurface);
}
}, 8000);
// Browser back/forward navigation // Browser back/forward navigation
window.addEventListener('hashchange', () => { window.addEventListener('hashchange', () => {
const saved = getHashSurface(); const saved = getHashSurface();

@ -27,14 +27,6 @@ class ClaudeParser {
const blocks = this._parseBlocks(contentLines); 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 }; return { blocks, statusBar };
} }
@ -207,10 +199,11 @@ class ClaudeParser {
if (selectionLineSet.has(i)) break; if (selectionLineSet.has(i)) break;
bl.push(lines[i++]); bl.push(lines[i++]);
} }
// Skip empty prompt blocks — these are the input cursor, not submitted messages // Skip empty prompt blocks (input cursor). Strip ANSI first so color codes
// Only check the first line's text (not continuation lines) // before don't prevent the match. Also skip when content is only separator
const firstLineText = bl[0].replace(/^\s*\u276F\s*/, '').trim(); // chars — Claude Code renders ────── on the active input line.
if (!firstLineText) continue; 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 }); blocks.push({ type: 'prompt', lines: bl, toolName: null, options: null });
continue; continue;
} }
@ -315,9 +308,12 @@ class ClaudeParser {
} }
_stripAnsi(line) { _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 // 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) { _isBlockStart(line) {

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

@ -6,7 +6,7 @@ class GestureHandler {
this.tracking = false; this.tracking = false;
document.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: true }); 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 }); document.addEventListener('touchend', (e) => this.onTouchEnd(e), { passive: true });
} }
@ -19,22 +19,6 @@ class GestureHandler {
onTouchMove(e) { onTouchMove(e) {
if (!this.tracking) return; 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) { onTouchEnd(e) {

@ -105,13 +105,11 @@ class TerminalView {
this.codexParser = null; this.codexParser = null;
this.codexRenderer = null; this.codexRenderer = null;
// Track scroll position to determine auto-scroll // autoScroll is an explicit gate (default on). It is NOT recomputed from
const container = this.el.parentElement; // scroll position on every scroll event — that produced a stale-read race
container.addEventListener('scroll', () => { // where a render firing before the scroll event dispatched would yank the
const atBottom = // view back to the bottom. Instead render() measures the real position at
container.scrollHeight - container.scrollTop - container.clientHeight < 30; // render time. app.js sets this false only to pin position in PageUp mode.
this.autoScroll = atBottom;
});
} }
setContent(lines) { setContent(lines) {
@ -169,7 +167,7 @@ class TerminalView {
if (this.renderPending) return; if (this.renderPending) return;
this.renderPending = true; this.renderPending = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.render(); try { this.render(); } catch (e) { console.error('[render]', e); }
this.renderPending = false; this.renderPending = false;
}); });
} }
@ -182,23 +180,26 @@ class TerminalView {
} }
render() { 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) { if (this.codexMode && this.codexParser && this.codexRenderer) {
const doc = this.codexParser.parse(this.lines); const doc = this.codexParser.parse(this.lines);
this.codexRenderer.render(doc); this.codexRenderer.render(doc);
if (this.autoScroll) { if (follow) container.scrollTop = container.scrollHeight;
const container = this.el.parentElement;
container.scrollTop = container.scrollHeight;
}
return; return;
} }
if (this.claudeMode && this.claudeParser && this.claudeRenderer) { if (this.claudeMode && this.claudeParser && this.claudeRenderer) {
const doc = this.claudeParser.parse(this.lines); const doc = this.claudeParser.parse(this.lines);
this.claudeRenderer.render(doc); this.claudeRenderer.render(doc);
if (this.autoScroll) { if (follow) container.scrollTop = container.scrollHeight;
const container = this.el.parentElement;
container.scrollTop = container.scrollHeight;
}
return; return;
} }
@ -230,10 +231,7 @@ class TerminalView {
const html = output.join('\n'); const html = output.join('\n');
this.el.innerHTML = html; this.el.innerHTML = html;
if (this.autoScroll) { if (follow) container.scrollTop = container.scrollHeight;
const container = this.el.parentElement;
container.scrollTop = container.scrollHeight;
}
} }
highlightLine(line) { highlightLine(line) {

@ -38,6 +38,16 @@ class VirtualKeyboard {
this.sendBtn.addEventListener('mousedown', (e) => e.preventDefault()); this.sendBtn.addEventListener('mousedown', (e) => e.preventDefault());
this.sendBtn.addEventListener('click', () => this.submitText()); 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 // Ctrl modifier
this.ctrlKey.addEventListener('click', () => this.toggleModifier('ctrl')); this.ctrlKey.addEventListener('click', () => this.toggleModifier('ctrl'));
this.ctrlKey.addEventListener('dblclick', () => this.lockModifier('ctrl')); this.ctrlKey.addEventListener('dblclick', () => this.lockModifier('ctrl'));

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

Loading…
Cancel
Save