diff --git a/public/css/claude.css b/public/css/claude.css index 837f3a4..5adb2c9 100644 --- a/public/css/claude.css +++ b/public/css/claude.css @@ -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 { diff --git a/public/css/keyboard.css b/public/css/keyboard.css index ced729d..07372c5 100644 --- a/public/css/keyboard.css +++ b/public/css/keyboard.css @@ -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; } diff --git a/public/css/main.css b/public/css/main.css index 706956c..ae76280 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -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 { diff --git a/public/css/terminal.css b/public/css/terminal.css index 12ce27a..7889722 100644 --- a/public/css/terminal.css +++ b/public/css/terminal.css @@ -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; diff --git a/public/index.html b/public/index.html index 60e2084..bb5b5c4 100644 --- a/public/index.html +++ b/public/index.html @@ -11,11 +11,11 @@ - - - + + + - + @@ -119,17 +119,17 @@ - - + + - + - - + + - + diff --git a/public/js/app.js b/public/js/app.js index 69d0f3d..3e01f13 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -185,16 +185,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 || !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) { @@ -387,10 +414,13 @@ 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; @@ -400,26 +430,11 @@ if (scrollbackLoading) scrollbackLoading.hidden = 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; 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); - } - }); - } } }); @@ -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 window.addEventListener('hashchange', () => { const saved = getHashSurface(); diff --git a/public/js/claude-parser.js b/public/js/claude-parser.js index 995ae77..792cb96 100644 --- a/public/js/claude-parser.js +++ b/public/js/claude-parser.js @@ -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 }; } @@ -207,10 +199,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 +308,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) { diff --git a/public/js/claude-renderer.js b/public/js/claude-renderer.js index d30415e..eb5fc38 100644 --- a/public/js/claude-renderer.js +++ b/public/js/claude-renderer.js @@ -142,7 +142,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); diff --git a/public/js/gestures.js b/public/js/gestures.js index 18acf13..504cce3 100644 --- a/public/js/gestures.js +++ b/public/js/gestures.js @@ -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) { diff --git a/public/js/terminal-view.js b/public/js/terminal-view.js index 5dead05..12c2c74 100644 --- a/public/js/terminal-view.js +++ b/public/js/terminal-view.js @@ -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) { @@ -169,7 +167,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 +180,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 +231,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) { diff --git a/public/js/virtual-keyboard.js b/public/js/virtual-keyboard.js index aa94d38..f148db1 100644 --- a/public/js/virtual-keyboard.js +++ b/public/js/virtual-keyboard.js @@ -38,6 +38,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')); diff --git a/src/bridge/cmux-client.ts b/src/bridge/cmux-client.ts index 4f7ab20..7d21b64 100644 --- a/src/bridge/cmux-client.ts +++ b/src/bridge/cmux-client.ts @@ -21,6 +21,7 @@ export class CmuxClient { private running = 0; private queue: Array<() => void> = []; private cache = new Map>(); + private lastGood = new Map(); 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 {