/** * @fileoverview Terminal setup (xterm.js config, input, resize, link provider), rendering pipeline * (batch writes, flicker filter, chunked writes, local echo), terminal controls (clear, font, resize), * and directory input. * * @mixin Extends CodemanApp.prototype via Object.assign * @dependency app.js (CodemanApp class, this.terminal, this.fitAddon, this.sessions) * @dependency constants.js (DEC_SYNC_STRIP_RE, TIMING constants) * @dependency mobile-handlers.js (MobileDetection) * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js * @dependency vendor/xterm-zerolag-input.js (LocalEchoOverlay) * @loadorder 7 of 15 — loaded after app.js, before respawn-ui.js */ Object.assign(CodemanApp.prototype, { // ═══════════════════════════════════════════════════════════════ // Terminal Setup — xterm.js config and input handling // ═══════════════════════════════════════════════════════════════ initTerminal() { // Load scrollback setting from localStorage (default 5000) const scrollback = parseInt(localStorage.getItem('codeman-scrollback')) || DEFAULT_SCROLLBACK; this.terminal = new Terminal({ theme: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255, 255, 255, 0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff', }, fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace', // Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line) fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14, lineHeight: 1.2, cursorBlink: false, cursorStyle: 'block', scrollback: scrollback, allowTransparency: true, allowProposedApi: true, }); this.fitAddon = new FitAddon.FitAddon(); this.terminal.loadAddon(this.fitAddon); if (typeof Unicode11Addon !== 'undefined') { try { const unicode11Addon = new Unicode11Addon.Unicode11Addon(); this.terminal.loadAddon(unicode11Addon); this.terminal.unicode.activeVersion = '11'; } catch (_e) { /* Unicode11 addon failed — default Unicode handling used */ } } const container = document.getElementById('terminalContainer'); this.terminal.open(container); // Suppress xterm key handling during CJK IME composition. // Without this, xterm processes raw keyDown events (e.g., "Process" key) // during composition, causing duplicate or garbled input. this.terminal.attachCustomKeyEventHandler((ev) => { if (ev.isComposing || ev.keyCode === 229) return false; return true; }); // WebGL renderer for GPU-accelerated terminal rendering. // Previously caused "page unresponsive" crashes from synchronous GPU stalls, // but the 48KB/frame flush cap in flushPendingWrites() now prevents // oversized terminal.write() calls that triggered the stalls. // Disable with ?nowebgl URL param if GPU issues return. // Lazy-loaded: script downloaded only on desktop (saves 244KB on mobile). this._webglAddon = null; const skipWebGL = MobileDetection.getDeviceType() !== 'desktop'; if (!skipWebGL && !new URLSearchParams(location.search).has('nowebgl')) { if (typeof WebglAddon !== 'undefined') { this._initWebGL(); } else { // Lazy-load WebGL addon — not bundled in
to avoid blocking mobile const wglScript = document.createElement('script'); wglScript.src = 'vendor/xterm-addon-webgl.min.js'; wglScript.onload = () => this._initWebGL(); wglScript.onerror = () => console.warn('[CRASH-DIAG] Failed to load WebGL addon — using canvas renderer'); document.head.appendChild(wglScript); } } this._localEchoOverlay = new LocalEchoOverlay(this.terminal); // CJK IME input — textarea in index.html, just wire up send this._cjkInput = null; if (typeof CjkInput !== 'undefined') { this._cjkInput = CjkInput.init({ send: (text) => { if (this.activeSessionId) { this._sendInputAsync(this.activeSessionId, text); } }, }); } // On mobile Safari, delay initial fit() to allow layout to settle // This prevents 0-column terminals caused by fit() running before container is sized const isMobileSafari = MobileDetection.getDeviceType() === 'mobile' && document.body.classList.contains('safari-browser'); if (isMobileSafari) { // Wait for layout, then fit multiple times to ensure proper sizing requestAnimationFrame(() => { this.fitAddon.fit(); // Double-check after another frame requestAnimationFrame(() => this.fitAddon.fit()); }); } else { this.fitAddon.fit(); } // Register link provider for clickable file paths in Bash tool output this.registerFilePathLinkProvider(); // Always use mouse wheel for terminal scrollback, never forward to application. // Prevents Claude's Ink UI (plan mode selector) from capturing scroll as option navigation. container.addEventListener( 'wheel', (ev) => { ev.preventDefault(); const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1); this.terminal.scrollLines(lines); }, { passive: false } ); // Touch scrolling — use terminal.scrollLines() for all devices. // xterm.js DOM renderer doesn't populate xterm-viewport's scroll area, // so native CSS scrolling (overflow-y: scroll + touch-action: pan-y) // has nothing to scroll. Instead, convert touch deltas into scrollLines() // calls, matching the wheel handler above. { const cellHeight = () => this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13; let touchLastY = 0; let velocity = 0; let lastTime = 0; let scrollFrame = null; let isTouching = false; const scrollLoop = (timestamp) => { const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1; lastTime = timestamp; if (!isTouching && Math.abs(velocity) > 0.3) { // Momentum phase — convert pixel velocity to lines const lines = Math.round(velocity / cellHeight()); if (lines !== 0) this.terminal.scrollLines(lines); velocity *= 0.92; scrollFrame = requestAnimationFrame(scrollLoop); } else if (!isTouching) { scrollFrame = null; velocity = 0; } else { scrollFrame = requestAnimationFrame(scrollLoop); } }; // Accumulate sub-line pixel deltas so slow swipes still scroll let pixelAccum = 0; let didScroll = false; // track whether touchmove fired (tap vs scroll) container.addEventListener( 'touchstart', (ev) => { if (ev.touches.length === 1) { touchLastY = ev.touches[0].clientY; velocity = 0; pixelAccum = 0; isTouching = true; didScroll = false; lastTime = 0; if (scrollFrame) { cancelAnimationFrame(scrollFrame); scrollFrame = null; } } }, { passive: true } ); container.addEventListener( 'touchmove', (ev) => { if (ev.touches.length === 1 && isTouching) { didScroll = true; const touchY = ev.touches[0].clientY; const delta = touchLastY - touchY; // positive = scroll down pixelAccum += delta; velocity = delta * 1.2; touchLastY = touchY; // Convert accumulated pixels to whole lines const ch = cellHeight(); const lines = Math.trunc(pixelAccum / ch); if (lines !== 0) { this.terminal.scrollLines(lines); pixelAccum -= lines * ch; } } }, { passive: true } ); container.addEventListener( 'touchend', () => { isTouching = false; if (!scrollFrame && Math.abs(velocity) > 0.3) { scrollFrame = requestAnimationFrame(scrollLoop); } // Tap (no scroll): refocus xterm's hidden textarea so keyboard input // routes back to the terminal. Without this, a tap on the terminal area // consumes the touch event but xterm's textarea never regains focus. if (!didScroll && this.terminal) { this.terminal.focus(); } }, { passive: true } ); container.addEventListener( 'touchcancel', () => { isTouching = false; velocity = 0; pixelAccum = 0; }, { passive: true } ); } // Welcome message this.showWelcome(); // Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch this._chunkedWriteGen = 0; // Handle resize with throttling for performance this._resizeTimeout = null; this._lastResizeDims = null; // Minimum terminal dimensions to prevent vertical text wrapping const MIN_COLS = 40; const MIN_ROWS = 10; const throttledResize = () => { // Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens // once after the user stops resizing. During active resize, the terminal // stays at its old dimensions for up to 300ms. // // Why not fit() immediately? Each fitAddon.fit() reflows content at the // new width — lines that were 7 rows become 10, and the overflow gets // pushed into scrollback. With continuous resize events, this creates // dozens of intermediate reflow states in scrollback, appearing as // duplicate/garbled content when the user scrolls up. // // By deferring fit() to the trailing edge, there's exactly ONE reflow // at the final dimensions, ONE viewport clear, and ONE Ink redraw. if (this._resizeTimeout) { clearTimeout(this._resizeTimeout); } this._resizeTimeout = setTimeout(() => { this._resizeTimeout = null; // Fit xterm.js to final container dimensions if (this.fitAddon) { this.fitAddon.fit(); } // Flush any stale flicker buffer before clearing viewport if (this.flickerFilterBuffer) { if (this.flickerFilterTimeout) { clearTimeout(this.flickerFilterTimeout); this.flickerFilterTimeout = null; } this.flushFlickerBuffer(); } // Skip server resize while mobile keyboard is visible — sending SIGWINCH // causes Ink to re-render at the new row count, garbling terminal output. // Local fit() still runs so xterm knows the viewport size for scrolling. const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; if (this.activeSessionId && !keyboardUp) { const dims = this.fitAddon.proposeDimensions(); // Enforce minimum dimensions to prevent layout issues const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS; const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS; // Only send resize if dimensions actually changed if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) { // Clear viewport + scrollback ONLY when dimensions actually change. // fitAddon.fit() reflows content: lines at old width may wrap to more rows, // pushing overflow into scrollback. Ink's cursor-up count is based on the // pre-reflow line count, so ghost renders accumulate in scrollback. // Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris, // then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw. // IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed). // Clearing without a subsequent Ink redraw leaves the terminal blank. const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; if ( activeResizeSession && activeResizeSession.mode !== 'shell' && !activeResizeSession._ended && this.terminal && this.isTerminalAtBottom() ) { this.terminal.write('\x1b[3J\x1b[H\x1b[2J'); } this._lastResizeDims = { cols, rows }; fetch(`/api/sessions/${this.activeSessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols, rows }), }).catch(() => {}); } } // Update subagent connection lines and local echo at new dimensions this.updateConnectionLines(); if (this._localEchoOverlay?.hasPending) { this._localEchoOverlay.rerender(); } }, 300); // Trailing-edge: only fire after 300ms of no resize events }; window.addEventListener('resize', throttledResize); // Store resize observer for cleanup (prevents memory leak on terminal re-init) if (this.terminalResizeObserver) { this.terminalResizeObserver.disconnect(); } this.terminalResizeObserver = new ResizeObserver(throttledResize); this.terminalResizeObserver.observe(container); // Handle keyboard input — send to PTY immediately, no local echo. // PTY/Ink handles all character echoing to avoid desync ("typing visible below" bug). this._pendingInput = ''; this._inputFlushTimeout = null; this._lastKeystrokeTime = 0; const flushInput = () => { this._inputFlushTimeout = null; if (this._pendingInput && this.activeSessionId) { const input = this._pendingInput; const sessionId = this.activeSessionId; this._pendingInput = ''; this._sendInputAsync(sessionId, input); } }; // Local echo mode: buffer keystrokes locally (shown in overlay) and only // send to PTY on Enter. Avoids out-of-order delivery on high-latency // mobile connections. The overlay + localStorage persistence ensure input // survives tab switches and reconnects. this.terminal.onData((data) => { // CJK input has focus — block xterm from sending to PTY if (window.cjkActive || document.activeElement?.id === 'cjkInput') return; if (this.activeSessionId) { // Filter out terminal query responses that xterm.js generates automatically. // These are responses to DA (Device Attributes), DSR (Device Status Report), etc. // sent by tmux when attaching. Without this filter, they appear as typed text. // Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR) if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return; // ── Local Echo Mode ── // When enabled, keystrokes are buffered locally in the overlay for // instant visual feedback. Nothing is sent to the PTY until Enter // (or a control char) is pressed — avoids out-of-order char delivery. if (this._localEchoEnabled) { if (data === '\x7f') { const source = this._localEchoOverlay?.removeChar(); if (source === 'flushed') { // Sync app-level flushed Maps (per-session state for tab switching) const { count, text } = this._localEchoOverlay.getFlushed(); if (this._flushedOffsets?.has(this.activeSessionId)) { if (count === 0) { this._flushedOffsets.delete(this.activeSessionId); this._flushedTexts?.delete(this.activeSessionId); } else { this._flushedOffsets.set(this.activeSessionId, count); this._flushedTexts?.set(this.activeSessionId, text); } } this._pendingInput += data; flushInput(); } // 'pending' = removed unsent text (no PTY backspace needed) // false = nothing to remove (swallow the backspace) return; } if (/^[\r\n]+$/.test(data)) { // Enter: send full buffered text + \r to PTY in one shot const text = this._localEchoOverlay?.pendingText || ''; this._localEchoOverlay?.clear(); // Suppress detection so PTY-echoed text isn't re-detected as user input this._localEchoOverlay?.suppressBufferDetection(); // Clear flushed offset and text — Enter commits all text this._flushedOffsets?.delete(this.activeSessionId); this._flushedTexts?.delete(this.activeSessionId); if (this._inputFlushTimeout) { clearTimeout(this._inputFlushTimeout); this._inputFlushTimeout = null; } if (text) { this._pendingInput += text; flushInput(); } // Send \r after a short delay so text arrives first setTimeout(() => { this._pendingInput += '\r'; flushInput(); }, 80); return; } if (data.length > 1 && data.charCodeAt(0) >= 32) { // Paste: append to overlay only (sent on Enter) this._localEchoOverlay?.appendText(data); return; } if (data.charCodeAt(0) < 32) { // Skip xterm-generated terminal responses. // These arrive via triggerDataEvent when the terminal processes // buffer data (DA responses, OSC color queries, mode reports, etc.). // They are NOT user input and must not clear flushed text state. // Covers: CSI (\x1b[), OSC (\x1b]), DCS (\x1bP), APC (\x1b_), // PM (\x1b^), SOS (\x1bX), and any other multi-byte ESC sequence. // Single-byte ESC (user pressing Escape) still falls through to // the control char handler below. if (data.length > 1 && data.charCodeAt(0) === 27) { // Multi-byte escape sequence — forward to PTY without clearing // overlay/flushed state (terminal response, not user input) this._pendingInput += data; flushInput(); return; } // During buffer load (tab switch), stray control chars from // terminal response processing must not wipe the flushed state // that selectSession() is actively restoring. if (this._restoringFlushedState) { this._pendingInput += data; flushInput(); return; } // Tab key: send pending text + Tab to PTY for tab completion. // Set a flag so flushPendingWrites() re-detects buffer text when // the PTY response arrives (event-driven, no fixed timer). if (data === '\t') { const text = this._localEchoOverlay?.pendingText || ''; this._localEchoOverlay?.clear(); this._flushedOffsets?.delete(this.activeSessionId); this._flushedTexts?.delete(this.activeSessionId); if (text) { this._pendingInput += text; } this._pendingInput += data; if (this._inputFlushTimeout) { clearTimeout(this._inputFlushTimeout); this._inputFlushTimeout = null; } // Snapshot prompt line text BEFORE flushing — used to distinguish // real Tab completions from pre-existing Claude UI text. let baseText = ''; try { const p = this._localEchoOverlay?.findPrompt?.(); if (p) { const buf = this.terminal.buffer.active; const line = buf.getLine(buf.viewportY + p.row); if (line) baseText = line .translateToString(true) .slice(p.col + 2) .trimEnd(); } } catch {} this._tabCompletionBaseText = baseText; flushInput(); this._tabCompletionSessionId = this.activeSessionId; this._tabCompletionRetries = 0; // Fallback: if flushPendingWrites() detection misses the completion // (e.g., flicker filter delays data, or xterm hasn't processed writes // by the time the callback fires), retry detection after a delay. // This ensures the overlay renders even without further terminal data. if (this._tabCompletionFallback) clearTimeout(this._tabCompletionFallback); const selfTab = this; this._tabCompletionFallback = setTimeout(() => { selfTab._tabCompletionFallback = null; if (!selfTab._tabCompletionSessionId || selfTab._tabCompletionSessionId !== selfTab.activeSessionId) return; const ov = selfTab._localEchoOverlay; if (!ov || ov.pendingText) return; selfTab.terminal.write('', () => { if (!selfTab._tabCompletionSessionId) return; ov.resetBufferDetection(); const detected = ov.detectBufferText(); if (detected && detected !== selfTab._tabCompletionBaseText) { selfTab._tabCompletionSessionId = null; selfTab._tabCompletionRetries = 0; selfTab._tabCompletionBaseText = null; ov.rerender(); } }); }, 300); return; } // Control chars (Ctrl+C, single ESC): send buffered text + control char immediately const text = this._localEchoOverlay?.pendingText || ''; this._localEchoOverlay?.clear(); // Suppress detection so PTY-echoed text isn't re-detected as user input this._localEchoOverlay?.suppressBufferDetection(); // Clear flushed offset and text — control chars (Ctrl+C, Escape) change // cursor position or abort readline, making flushed text tracking invalid. this._flushedOffsets?.delete(this.activeSessionId); this._flushedTexts?.delete(this.activeSessionId); if (text) { this._pendingInput += text; } this._pendingInput += data; if (this._inputFlushTimeout) { clearTimeout(this._inputFlushTimeout); this._inputFlushTimeout = null; } flushInput(); return; } if (data.length === 1 && data.charCodeAt(0) >= 32) { // Printable char: add to overlay only (sent on Enter) this._localEchoOverlay?.addChar(data); return; } } // ── Normal Mode (echo disabled) ── this._pendingInput += data; // Control chars (Enter, Ctrl+C, escape sequences) — flush immediately if (data.charCodeAt(0) < 32 || data.length > 1) { if (this._inputFlushTimeout) { clearTimeout(this._inputFlushTimeout); this._inputFlushTimeout = null; } flushInput(); return; } // Regular chars — flush immediately if typed after a gap (>50ms), // otherwise batch via microtask to coalesce rapid keystrokes (paste). const now = performance.now(); if (now - this._lastKeystrokeTime > 50) { // Single char after a gap — send immediately, no setTimeout latency if (this._inputFlushTimeout) { clearTimeout(this._inputFlushTimeout); this._inputFlushTimeout = null; } this._lastKeystrokeTime = now; flushInput(); } else { // Rapid sequence (paste or fast typing) — coalesce via microtask this._lastKeystrokeTime = now; if (!this._inputFlushTimeout) { this._inputFlushTimeout = setTimeout(flushInput, 0); } } } }); }, /** * Register a custom link provider for xterm.js that detects file paths * in terminal output and makes them clickable. * When clicked, opens a floating log viewer window with live streaming. */ registerFilePathLinkProvider() { const self = this; // Debug: Track if provider is being invoked let lastInvokedLine = -1; this.terminal.registerLinkProvider({ provideLinks(bufferLineNumber, callback) { // Debug logging - only log if line changed to avoid spam if (bufferLineNumber !== lastInvokedLine) { lastInvokedLine = bufferLineNumber; console.debug('[LinkProvider] Checking line:', bufferLineNumber); } const buffer = self.terminal.buffer.active; // provideLinks passes 1-based line number, getLine expects 0-based const line = buffer.getLine(bufferLineNumber - 1); if (!line) { callback(undefined); return; } // Get line text - translateToString handles wrapped lines const lineText = line.translateToString(true); if (!lineText || !lineText.includes('/')) { callback(undefined); return; } const links = []; // Pattern 0: URLs (https://, http://) — matched first so they take priority const urlPattern = /https?:\/\/[^\s"'<>|;&)\]\x00-\x1f]+/g; const addUrlLink = (url, matchIndex) => { // Strip trailing punctuation that's likely not part of the URL const cleaned = url.replace(/[.,;:!?)]+$/, ''); const startCol = lineText.indexOf(cleaned, matchIndex); if (startCol === -1) return; if (links.some((l) => l.range.start.x === startCol + 1)) return; links.push({ text: cleaned, range: { start: { x: startCol + 1, y: bufferLineNumber }, end: { x: startCol + cleaned.length + 1, y: bufferLineNumber }, }, decorations: { pointerCursor: true, underline: true }, activate(_event, text) { window.open(text, '_blank', 'noopener,noreferrer'); }, }); }; // Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.) // Handles: tail -f /path, grep pattern /path, cat -n /path const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g; // Pattern 2: Paths with common extensions const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g; // Pattern 3: Bash() tool output const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g; const addLink = (filePath, matchIndex) => { const startCol = lineText.indexOf(filePath, matchIndex); if (startCol === -1) return; // Skip if already have link at this position if (links.some((l) => l.range.start.x === startCol + 1)) return; links.push({ text: filePath, range: { start: { x: startCol + 1, y: bufferLineNumber }, // 1-based end: { x: startCol + filePath.length + 1, y: bufferLineNumber }, }, decorations: { pointerCursor: true, underline: true, }, activate(event, text) { self.openLogViewerWindow(text, self.activeSessionId); }, }); }; // Match all patterns — URLs first so they take priority let match; urlPattern.lastIndex = 0; while ((match = urlPattern.exec(lineText)) !== null) { addUrlLink(match[0], match.index); } cmdPattern.lastIndex = 0; while ((match = cmdPattern.exec(lineText)) !== null) { addLink(match[2], match.index); } extPattern.lastIndex = 0; while ((match = extPattern.exec(lineText)) !== null) { addLink(match[1], match.index); } bashPattern.lastIndex = 0; while ((match = bashPattern.exec(lineText)) !== null) { addLink(match[1], match.index); } if (links.length > 0) { console.debug( '[LinkProvider] Found links:', links.map((l) => l.text) ); } callback(links.length > 0 ? links : undefined); }, }); console.log('[LinkProvider] File path link provider registered'); }, showWelcome() { const overlay = document.getElementById('welcomeOverlay'); if (overlay) { overlay.classList.add('visible'); this.loadTunnelStatus(); this.loadHistorySessions(); } }, hideWelcome() { const overlay = document.getElementById('welcomeOverlay'); if (overlay) { overlay.classList.remove('visible'); } // Collapse expanded QR when leaving welcome screen const qrWrap = document.getElementById('welcomeQr'); if (qrWrap) { clearTimeout(this._welcomeQrShrinkTimer); qrWrap.classList.remove('expanded'); } }, /** * Fetch and deduplicate history sessions (up to 2 per dir, max `limit` total). * @returns {Promise