/** * @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 */ (function (global) { const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/; const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/; // Pointer and focus reports xterm emits through onData on the terminal's OWN // initiative, with no key pressed: SGR mouse (DECSET 1006, also 1016), legacy // X10 mouse (DECSET 1000 — three raw bytes after CSI M) and focus in/out // (DECSET 1004). They are not query REPLIES, so the query-response filter // above does not match them, and they must keep reaching the PTY. What they // must NOT do is stand in for a keystroke: see isTerminalFocusOrMouseReport. const MOUSE_SGR_REPORT_PATTERN = /^\x1b\[<\d+;\d+;\d+[Mm]$/; const MOUSE_X10_REPORT_PATTERN = /^\x1b\[M[\s\S]{3}$/; const FOCUS_REPORT_PATTERN = /^\x1b\[[IO]$/; // Grace window after a manual scroll-up gesture during which sticky-scroll is // suppressed, so high-frequency Codex status redraws don't snap the viewport // back to the bottom while the user is inspecting earlier output. const USER_SCROLL_STICKY_SUPPRESS_MS = 1500; // Mobile browsers synthesize trusted mouse events after touchend. During this // short window, only the app's synthetic tap-to-position mouse event should // reach xterm. const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450; // Finger travel (px) still counted as a tap rather than a scroll. Shared by // the terminal's own touch handling (TAP_THRESHOLD, initTerminal) and the // keyboard-dismiss handler (_installMobileKeyboardDismiss), which MUST agree: // a gesture the terminal treats as a scroll but the dismiss handler treats as // a tap would close the keyboard mid-scroll and drop the composer. const MOBILE_KEYBOARD_DISMISS_TAP_SLOP = 8; // Hold this long, finger still, before a press becomes a text selection. // // ⚠️ It must fire well BEFORE the platform's own long-press threshold (~500ms on // Android), not just under it: the guards this gesture installs are armed when it // fires, and at 450ms they were still being armed as Chrome ran its own handling // — which focuses the nearest editable element, so the keyboard shot up over the // selection the moment it appeared. 350ms is still far above a tap (~100-150ms). const TOUCH_SELECT_LONG_PRESS_MS = 350; // How long after a selection gesture the terminal input stays un-focusable. Long // enough to cover the platform's long-press handling and the compatibility events // that trail a touchend; short and self-expiring, so a stuck flag can never leave // the keyboard unreachable. const TOUCH_SELECT_FOCUS_GUARD_MS = 800; // Regions where a tap must NOT dismiss the on-screen keyboard // (_installMobileKeyboardDismiss). Two groups: anything that is about to take // focus itself, and the accessory bar, which is built to be used while the // keyboard is open. const MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR = [ 'input', 'textarea', 'select', 'button', 'a[href]', '[contenteditable=""]', '[contenteditable="true"]', '[tabindex]:not([tabindex="-1"])', '.keyboard-accessory-bar', '.path-picker-overlay', ].join(','); // Escape sequences occupy no terminal cells, so they must come out before a // captured line's WIDTH can be measured (_estimateReplayRows). Covers OSC, // CSI, charset designators and the short escapes tmux emits; deliberately // approximate — this feeds a size comparison, not a renderer. // eslint-disable-next-line no-control-regex const REPLAY_ESCAPE_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b[()#][0-9A-Za-z]|\x1b[=>78M]/g; // PageUp / PageDown as xterm.js encodes them. Used as the LAST-RESORT scroll // gesture for a repaint-mode CLI whose local buffer holds no scrollback // (_maybePageCliTranscript). const KEY_PAGE_UP = '\x1b[5~'; const KEY_PAGE_DOWN = '\x1b[6~'; // Wheel/touch travel (in lines) that adds up to one PageUp/PageDown. Half a // screen rather than a full one: the page key always jumps a whole screen, so // a 1:1 mapping made the fallback feel unreachably slow with a discrete mouse // wheel (Firefox reports 3 lines a notch → 12 notches per page). Overshooting // the finger is the right trade against a gesture that otherwise does nothing. const PAGE_KEY_SCREEN_FRACTION = 0.5; // Bound on page keys emitted from one gesture batch, mirroring the SGR tick // cap: a fling must not build a backlog that keeps paging after it stops. const PAGE_KEY_MAX_PER_BATCH = 3; const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4; // Composer navigation keys as xterm.js encodes user keystrokes: plain and // modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3 // H/F, CSI 1~/4~), Insert/Delete/PgUp/PgDn (CSI 2~/3~/5~/6~, optional // modifier). Deliberately EXCLUDES terminal query responses that also // arrive via onData (DA `\x1b[?1;2c`, CPR `\x1b[12;34R`) and function // keys, so only genuine cursor/editing keys trigger the local-echo flush. // eslint-disable-next-line no-control-regex const COMPOSER_NAV_KEY_PATTERN = /^\x1b(?:\[(?:[ABCDHF]|1;[2-8][ABCDHF]|[1-8](?:;[2-8])?~)|O[ABCDHF])$/; // Prefix xterm.js puts on terminal.paste() payloads while the application // has bracketed-paste mode (DECSET 2004) enabled. Codex, Claude Code and // tmux all enable it, so browser pastes arrive as one onData chunk of // `\x1b[200~\x1b[201~`. const BRACKETED_PASTE_START = '\x1b[200~'; function isComposerNavKey(data) { return COMPOSER_NAV_KEY_PATTERN.test(data); } // Codex composer-row signature, measured against codex-cli 0.147.0 // (docs/predictive-echo-plan.md): the composer's cursor row starts with // "› " (U+203A + space) when empty (placeholder text), while typing, and // while the slash picker filters. Modal rows ("Press enter to continue") // and wrapped continuation rows (2-space indent) do NOT match — that is // the ghost eliminator: no prediction is ever painted there. const CODEX_COMPOSER_ROW_RE = /^› /; // Classify onData for the predictive echo hook. Terminal query responses // never reach this (suppressed earlier in onData); bracketed pastes, nav // keys and mouse reports all start with ESC => 'clear'. function classifyPredictInput(data) { const cps = Array.from(data); // astral-safe if (cps.length === 1) { const cp = cps[0].codePointAt(0); if (cp === 0x7f) return 'backspace'; if (cp >= 0x20) return 'char'; // incl. a single astral emoji return 'clear'; // \r \n \t \x03, bare ESC, ... } if (data.charCodeAt(0) === 0x1b) return 'clear'; // ESC seq: nav, paste, mouse SGR if (data.charCodeAt(0) >= 0x20) return 'text'; // multi-char printable (plain paste, return 'clear'; // ZWJ emoji cluster): wire only, no visual } // Predictive-echo gate: predict only while the cursor sits on the codex // composer row. cursorY is baseY-relative (xterm API), hence baseY + cursorY. function isCodexComposerRow(terminal) { try { const buf = terminal.buffer.active; const line = buf.getLine(buf.baseY + buf.cursorY); return !!line && CODEX_COMPOSER_ROW_RE.test(line.translateToString(true)); } catch { return false; } } function isTerminalQueryResponse(data) { return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data); } function shouldSuppressTerminalQueryResponse(data) { return isTerminalQueryResponse(data); } /** * Did the terminal generate this chunk itself, rather than a human pressing a * key? True for mouse and focus reports (issue #262). * * Consumers that treat one onData chunk as "the next keystroke" must skip * these. The one-shot Ctrl modifier is why this exists, and the MOUSE half is * the live one: a shell session keeps the narrow scrollback strip, so mouse * DECSETs reach the browser and anything the user runs that enables tracking * (vim, htop, less) turns a tap into `\x1b[<0;31;23M`. Measured in a real * shell session: with Ctrl armed, one tap on the terminal spent it silently. * * Focus reports are the same class and cost nothing to cover, but they cannot * reach xterm today: `FOCUS_ESCAPE_FILTER` in session.ts strips `\x1b[?1004h` * (and the reports themselves) from every PTY read, so `sendFocusMode` never * turns on. Were that filter to go, the Ctrl button would spend the modifier * on its OWN refocus — the bar refocuses the terminal after every key so the * keyboard stays open, and that refocus emits `\x1b[I`. */ function isTerminalFocusOrMouseReport(data) { return ( FOCUS_REPORT_PATTERN.test(data) || MOUSE_SGR_REPORT_PATTERN.test(data) || MOUSE_X10_REPORT_PATTERN.test(data) ); } // Per-skin xterm.js palettes. The 'daylight-blue' object equals the legacy hardcoded // theme, so default behavior is unchanged. Shared at module scope and exported on the // global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals, // a separate IIFE) can read the current skin's palette. // // ⚠️ The selection key is `selectionBackground`, xterm's name for it since v5 (#360). // An ITheme is a plain object handed straight to xterm, so an unknown key is not an // error, it is silently dropped: every palette here carried `selection`, so every skin // drew xterm's built-in default instead, rgba(255,255,255,0.3). On the four light skins // that is white at 30% over a near-white background, a delta of about 3/255 — the // highlight was effectively invisible, which is what a long-press selection that // "did nothing" actually looked like. A key only works here if xterm knows its name; // test/skin-themes.test.ts pins the name AND that the blend stays visible. const CODEMAN_XTERM_THEMES = { og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selectionBackground: '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' }, 'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selectionBackground: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' }, 'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selectionBackground: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' }, 'paper-gray': { background: '#f6f8fa', foreground: '#1f2328', cursor: '#0969da', cursorAccent: '#ffffff', selectionBackground: 'rgba(9,105,218,0.2)', black: '#24292f', red: '#cf222e', green: '#1a7f37', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#59636e', brightBlack: '#6e7781', brightRed: '#a40e26', brightGreen: '#116329', brightYellow: '#7d4e00', brightBlue: '#0550ae', brightMagenta: '#6639ba', brightCyan: '#116b75', brightWhite: '#1f2328' }, 'solarized-light': { background: '#fdf6e3', foreground: '#586e75', cursor: '#147ba3', cursorAccent: '#fdf6e3', selectionBackground: 'rgba(38,139,210,0.2)', black: '#eee8d5', red: '#dc322f', green: '#758600', yellow: '#9b7800', blue: '#147ba3', magenta: '#d33682', cyan: '#2a9189', white: '#073642', brightBlack: '#93a1a1', brightRed: '#cb4b16', brightGreen: '#657b83', brightYellow: '#586e75', brightBlue: '#268bd2', brightMagenta: '#6c71c4', brightCyan: '#2aa198', brightWhite: '#002b36' }, 'catppuccin-latte': { background: '#eff1f5', foreground: '#4c4f69', cursor: '#1e66f5', cursorAccent: '#ffffff', selectionBackground: 'rgba(30,102,245,0.18)', black: '#5c5f77', red: '#d20f39', green: '#3b8f2b', yellow: '#a86605', blue: '#1e66f5', magenta: '#8839ef', cyan: '#177f86', white: '#6c6f85', brightBlack: '#7c7f93', brightRed: '#b50930', brightGreen: '#2f7622', brightYellow: '#8b5604', brightBlue: '#174fbf', brightMagenta: '#6f2bc5', brightCyan: '#116b71', brightWhite: '#4c4f69' }, 'rose-pine-dawn': { background: '#faf4ed', foreground: '#575279', cursor: '#286983', cursorAccent: '#fffaf3', selectionBackground: 'rgba(40,105,131,0.2)', black: '#575279', red: '#b4637a', green: '#286983', yellow: '#96681f', blue: '#477f91', magenta: '#907aa9', cyan: '#3f7f8b', white: '#6e6a86', brightBlack: '#797593', brightRed: '#984d66', brightGreen: '#1f5266', brightYellow: '#7d5417', brightBlue: '#386b7c', brightMagenta: '#765f90', brightCyan: '#326b76', brightWhite: '#575279' }, }; const CODEMAN_LIGHT_SKINS = new Set(['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn']); function currentSkin() { return (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue'; } function currentXtermTheme() { const skin = currentSkin(); return CODEMAN_XTERM_THEMES[skin] || CODEMAN_XTERM_THEMES['daylight-blue']; } function currentSkinIsLight(skin = currentSkin()) { return CODEMAN_LIGHT_SKINS.has(skin); } global.CodemanTerminalInput = { isTerminalQueryResponse, shouldSuppressTerminalQueryResponse, isTerminalFocusOrMouseReport, isComposerNavKey, classifyPredictInput, isCodexComposerRow, CODEX_COMPOSER_ROW_RE, BRACKETED_PASTE_START, USER_SCROLL_STICKY_SUPPRESS_MS, TOUCH_COMPAT_MOUSE_SUPPRESS_MS, REPLAY_ESCAPE_RE, KEY_PAGE_UP, KEY_PAGE_DOWN, PAGE_KEY_SCREEN_FRACTION, PAGE_KEY_MAX_PER_BATCH, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, MOBILE_KEYBOARD_DISMISS_TAP_SLOP, TOUCH_SELECT_LONG_PRESS_MS, TOUCH_SELECT_FOCUS_GUARD_MS, }; global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES; global.codemanCurrentXtermTheme = currentXtermTheme; global.codemanCurrentSkinIsLight = currentSkinIsLight; })(window); Object.assign(CodemanApp.prototype, { // ═══════════════════════════════════════════════════════════════ // Terminal Setup — xterm.js config and input handling // ═══════════════════════════════════════════════════════════════ _destroyKeyCode229Recovery() { try { this._keyCode229Recovery?.destroy?.(); } catch { // Recovery is optional; terminal replacement must continue. } this._keyCode229Recovery = null; }, initTerminal() { // Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor // so users who picked up the previous (smaller) default get the new minimum on upgrade. const stored = parseInt(localStorage.getItem('codeman-scrollback')); const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK; this._destroyKeyCode229Recovery(); const fontSettings = this.loadAppSettingsFromStorage?.() || {}; this.terminal = new Terminal({ theme: { ...window.codemanCurrentXtermTheme() }, fontFamily: window.CodemanTerminalFont.resolve(fontSettings.terminalFontFamily), // Both weight slots, each falling back to xterm's own default for that // slot, so an untouched install renders exactly as it always has. ...window.CodemanTerminalFont.resolveWeights(fontSettings), // 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', minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1, scrollback: scrollback, allowTransparency: true, allowProposedApi: true, }); this.fitAddon = new FitAddon.FitAddon(); this.terminal.loadAddon(this.fitAddon); // SerializeAddon: lets us snapshot the xterm rendered state (viewport + // scrollback + colors/attrs) when switching away from a tab and restore // it on switch-back. Needed primarily for codex tabs — codex's TUI drops // earlier conversation from its current frame, so replaying the server // byte buffer on tab-switch shows only the latest (idle) frame. The // snapshot captures what the user was actually looking at. this._xtermSnapshots = new Map(); // Map if (typeof SerializeAddon !== 'undefined') { try { this._serializeAddon = new SerializeAddon.SerializeAddon(); this.terminal.loadAddon(this._serializeAddon); } catch (_e) { /* SerializeAddon failed — snapshot/restore disabled, fallback to buffer-fetch */ this._serializeAddon = null; } } 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); this._installMobileTapMouseGuard(); this._installShiftDragSelection(); this._installTouchSelectionFocusGuard(); // Let xterm's CompositionHelper own IME key events. In particular, a // non-composing keyCode 229 is how an active IME commits numbers and // punctuation; returning false here would stop xterm before it can diff // the helper textarea and emit the committed Unicode text. this.terminal.attachCustomKeyEventHandler((ev) => { try { // Deliberately runs for EVERY keydown, not just keyCode 229: the // controller snapshots a counter and reads nothing off the event, and // the devices this exists for report `key: 'Unidentified'` with no // reliable identity to gate on. Gating it would make recovery inert // exactly where it is needed. Cost is one assignment. this._keyCode229Recovery?.handleKeyEvent?.(ev); } catch { // The fallback must never interfere with xterm's canonical handler. } if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true; // Let the app's Alt/Option session-nav and Command Palette shortcuts reach the document keydown handler // (app.js switches tabs by PHYSICAL e.code) instead of xterm injecting ESC into // the PTY. Mirror app.js's gate exactly — same physical codes + modifier guard — so // macOS Option layouts (Option+1 -> "¡", Option+[ -> "“", Option+K -> "˚") are suppressed here too and // don't leak an escape sequence into the focused terminal on every tab switch. if ( ev.altKey && !ev.ctrlKey && !ev.shiftKey && /^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '') ) { return false; } // Command palette chord (COD-153): keep it out of the PTY. The document // CAPTURE handler has already opened the palette by the time xterm sees // this keydown, but its preventDefault() does NOT stop xterm — without // this gate Ctrl+K would ALSO write 0x0b (readline kill-line) into the // live session behind the palette, truncating whatever the user had // typed. Route through the registry-aware checker so a rebound or // disabled palette shortcut restores normal terminal Ctrl+K. if (ev.type === 'keydown' && this.shouldOpenCommandPaletteFromShortcut?.(ev)) { return false; } // Smart copy (#211): with a selection, Ctrl+C copies it instead of sending // ^C. With NO selection the branch must fall through (return true, and no // preventDefault) or the interrupt key is lost, which is the whole reason // the selection check runs before any registry dispatch. Ctrl+Shift+C is // the explicit copy chord and never falls through: an "explicit copy" that // interrupts a running agent because the selection happened to be empty is // a footgun with no upside. // NOTE: returning false does NOT cancel the event (xterm's _keyDown calls // this handler before its own cancel()), so preventDefault is explicit: // without it the browser runs its native copy on top of ours. if (this.shouldCopyTerminalSelectionFromShortcut?.(ev)) { const selection = this.terminal.hasSelection?.() ? this.terminal.getSelection() : ''; if (selection) { ev.preventDefault(); void this.copyTerminalSelection(selection); return false; } if (ev.shiftKey) { ev.preventDefault(); return false; } return true; } // Session-sidebar toggle chord (default Alt+B): same trap as above — // preventDefault() in the capture handler does not stop xterm, so without // this gate every toggle would ALSO send ESC b (readline backward-word) // into the live session and walk the cursor back through the user's // half-typed prompt. Registry-aware and only while the sidebar layout is // active, so a rebind/disable and the default header layout keep plain // Meta-b working in the terminal. if (ev.type === 'keydown' && this.shouldToggleSessionSidebarFromShortcut?.(ev)) { return false; } // Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY. // Route through our paste trap which handles both images and text. if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') { if (this.activeSessionId && this._handleImagePaste) { this._handleImagePaste(); } return false; } // Ctrl+Z (SIGTSTP/job-control suspend): in a plain shell session this is the // user's own job-control tool (suspend a foreground command, `fg` it back) — // leave it alone. In every other mode (claude/omp/pi/codex/... — Ink/TUI apps // that normally run in raw mode with ISIG off, so ^Z is usually inert there // already) suspending the CLI stops an unattended agent loop dead with no // visible output — the same failure shape as an XOFF freeze. Swallow it // before xterm can send \x1a into the PTY rather than relying on every CLI's // raw-mode state holding at every instant (startup, raw-mode toggles, etc). if ( ev.type === 'keydown' && ev.key.toLowerCase() === 'z' && ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey ) { const activeCtrlZSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; if (activeCtrlZSession && activeCtrlZSession.mode !== 'shell') { ev.preventDefault(); return false; } } // Shift+Enter / Ctrl+Enter: insert newline for multi-line input. // xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't // distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a) // which the inner application recognizes as "insert newline" vs carriage return. if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') { if (this.activeSessionId) { if (this._localEchoEnabled) { const text = this._localEchoOverlay?.pendingText || ''; this._localEchoOverlay?.clear(); this._localEchoOverlay?.suppressBufferDetection(); this._flushedOffsets?.delete(this.activeSessionId); this._flushedTexts?.delete(this.activeSessionId); if (text) { this._pendingInput += text; flushInput(); } setTimeout(() => { fetch(`/api/sessions/${this.activeSessionId}/send-key`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }), }); }, text ? 80 : 0); } else { fetch(`/api/sessions/${this.activeSessionId}/send-key`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }), }); } } return false; } return true; }); // WebGL renderer for GPU-accelerated terminal rendering. // Previously caused "page unresponsive" crashes from synchronous GPU stalls, // but the mode-aware 32/64KB frame cap in flushPendingWrites() now prevents // oversized terminal.write() calls that triggered the stalls. // Disable with ?nowebgl URL param if GPU issues return. // Auto-fallback: _initWebGL installs a long-task watchdog that disables // WebGL sticky in localStorage after repeated GPU stalls (see app.js). // Force re-enable after sticky disable with ?webgl=force. // Lazy-loaded: script downloaded only on desktop (saves 244KB on mobile). this._webglAddon = null; const _params = new URLSearchParams(location.search); const _stickyDisabled = (() => { try { const raw = localStorage.getItem('codeman-webgl-disabled'); if (!raw) return false; const { at } = JSON.parse(raw); // Auto-expire after WEBGL_FALLBACK.STICKY_EXPIRY_MS so we retry // (driver/Chrome may have been updated). if (Date.now() - at > WEBGL_FALLBACK.STICKY_EXPIRY_MS) { localStorage.removeItem('codeman-webgl-disabled'); return false; } return true; } catch { return false; } })(); // User's "WebGL Renderer" toggle (Settings > Appearance). undefined = untouched // (desktop default on); false = explicit opt-out; true = explicit opt-in. const _webglSettings = this.loadAppSettingsFromStorage(); const _webglDefaults = this.getDefaultSettings(); const _webglPref = _webglSettings.webglRendererEnabled ?? _webglDefaults.webglRendererEnabled; const { skip: skipWebGL, clearSticky: _clearWebglSticky } = shouldSkipWebGL({ deviceType: MobileDetection.getDeviceType(), noWebglParam: _params.has('nowebgl'), forceParam: _params.get('webgl') === 'force', stickyDisabled: _stickyDisabled, userPrefEnabled: _webglPref, }); // Only ?webgl=force retires the auto-fallback marker at init — a stored // toggle ON is incidental (checkbox defaults checked) and must not defeat // the sticky safety net. An OFF→ON flip clears it in saveAppSettings(). if (_clearWebglSticky) { try { localStorage.removeItem('codeman-webgl-disabled'); } catch {} } if (skipWebGL && _stickyDisabled) { console.log('[CRASH-DIAG] WebGL sticky-disabled from prior stalls — DOM renderer in use. Re-enable: ?webgl=force'); } if (!skipWebGL) { 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); // Predictive write-through echo (codex): separate opt-in bundle // (vendor/xterm-predictive-echo.js); when it is missing or failed to // load, codex falls back to plain PTY echo exactly like 1.12.2. this._predictiveEcho = typeof PredictiveEchoOverlay !== 'undefined' ? new PredictiveEchoOverlay(this.terminal) : null; this._predictiveEcho?.setPredictWhen((terminal) => window.CodemanTerminalInput.isCodexComposerRow(terminal)); if (MobileDetection.isTouchDevice()) { this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor()); this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor()); } // CJK IME input — textarea in index.html, just wire up send this._cjkInput = null; if (typeof CjkInput !== 'undefined') { this._cjkInput = CjkInput.init({ send: (text) => { this._handleCjkInput(text); }, }); } // ── Focus router ── // While the CJK field is visible, EVERY terminal.focus() call must land on // the CJK field instead. Focusing xterm's hidden textarea in CJK mode sends // the IME's output into a black hole: the keyboard composes normally, but // onData is gated by cjkActive, so nothing reaches the field OR the PTY. // Session select / SSE-reconnect restore paths call terminal.focus() and // were silently stealing focus after every app switch on mobile (the // intermittent "Chinese input goes nowhere" bug). One chokepoint here // covers all ~15 call sites plus any future ones. const _xtermFocus = this.terminal.focus.bind(this.terminal); this.terminal.focus = () => { const cjkEl = document.getElementById('cjkInput'); if (cjkEl?.classList.contains('cjk-input-visible')) { cjkEl.focus(); } else { _xtermFocus(); } }; // 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(); } // Whenever that first fit runs — on this line, or a frame or two later on // the mobile-Safari branch above — it measures whatever font the browser has // painted with so far, which is not necessarily the terminal font. Start the // wait now so the buffer load can hold for it. this._terminalFontReady = this._awaitTerminalFont(); // Register link provider for clickable file paths in Bash tool output this.registerFilePathLinkProvider(); // Bar visible ⟺ a selection exists. xterm drops the selection on any keypress, // on reset and on a tab switch, and a Copy button floating over nothing is a // trap — one that would copy the PREVIOUS session's text if it still worked. this.terminal.onSelectionChange?.(() => { if (!this.terminal?.hasSelection?.()) { this._touchSelecting = false; this._touchSelectionActive = false; this._touchSelectionAnchor = null; this._hideTouchSelectionBar(); // Nothing highlighted, nothing for Auto Copy to flush. xterm drops the // selection on any keypress, so without this a keystroke would leave a // stale flush armed for the next unrelated mouseup. this._autoCopyPending = false; return; } // Auto Copy arms here and fires at the END of the gesture (below). This // callback runs on EVERY cell the drag crosses, so copying here would be // one clipboard write per mouse move. this._autoCopyPending = true; }); // Auto Copy (copy-on-select, per-device `autoCopySelection`, default OFF). // // ⚠️ The flush is SYNCHRONOUS inside the mouseup handler, deliberately: both // clipboard paths need user activation (Firefox gates navigator.clipboard // .writeText on it, and Safari requires document.execCommand('copy'), the // plain-HTTP fallback install.sh's LAN option lands on, to run inside the // gesture's own task). Deferring to a timer or to onSelectionChange loses it. // // ⚠️ document-level, because a drag that ends outside the terminal (the user // sweeps up past the header) delivers its mouseup to the document, not to // the container. Unrelated mouseups elsewhere on the page are filtered by // decideAutoCopy, not by the listener's target. if (!this._autoCopyListenerInstalled) { this._autoCopyListenerInstalled = true; document.addEventListener('mouseup', () => this._flushAutoCopySelection()); } // Mouse wheel: forward to the TUI only for sessions verified to handle SGR // wheel reports (claude 2.1.187+ — see _shouldForwardWheelToApp), local // scrollback otherwise. Claude Code 2.1.187+ scrolls its own // transcript on SGR wheel reports — scrolled-away tool blocks re-render // live and stay clickable — and its select menus no longer capture wheel // as option navigation (verified against 2.1.202: /model menu highlight // ignores wheel reports); older versions DO capture wheel as option // navigation, so they keep the local wheel. // Shift+wheel always scrolls xterm's local scrollback (Codeman's restored // history lives there); the plain wheel stays on the CLI's transcript for // those modes regardless of scroll position, so the CLI's input box never // slides off the screen (see _shouldForwardWheelToApp). // // CAPTURE phase, deliberately, and Codeman owns the scroll. xterm's // viewport is a vscode-style ScrollableElement that consumes wheel events // itself (preventDefault + stopPropagation) whenever it believes a // scrollbar exists, does NOT consult attachCustomWheelEventHandler, and — // measured on the live instance — goes DEAF after terminal.reset(): a tab // switch or full-history replay leaves its scroll dimensions stale, after // which wheel events neither scroll nor propagate reliably. A bubble-phase // listener here therefore never fired once local scrollback existed // (measured: _shouldForwardWheelToApp call count stayed 0 while xterm // scrolled), and after a tab switch NOTHING scrolled at all — the "input // box scrolls up then it fights", "works at first, breaks after a tab // switch" reports on #205. // // So: capture runs ancestors-first; this handler sees every wheel first // and stops propagation, keeping xterm's scroller out of it entirely. // Local scrolling goes through terminal.scrollLines() — buffer-level, so // it keeps working after resets — with our own deltaMode normalization // (_wheelScrollLines) covering Firefox's line-unit wheels. Two cases still // belong to xterm and are passed through untouched: // - mouseTrackingMode active: xterm's own encoder forwards the wheel to // the PTY (htop/vim with mouse on in a shell pane); // - alternate buffer (direct-PTY fallback running vim/less): xterm's // alt-scroll handling converts the wheel to cursor keys, which is what // those apps expect. container.addEventListener( 'wheel', (ev) => { const trackingMode = this.terminal?.modes?.mouseTrackingMode; if (trackingMode && trackingMode !== 'none') return; if (this.terminal?.buffer?.active?.type === 'alternate') return; ev.preventDefault(); ev.stopPropagation(); if (this._shouldForwardWheelToApp(ev)) { this._logScrollRouting('forward-sgr'); this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLines(ev)); return; } // Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits // a stream of tiny pixel deltas, and rounding each one to a whole line // (the ±1 fallback) made slow drags scroll faster than the finger. const lines = this._wheelScrollLinesFloat(ev); // …unless there is no local scrollback to scroll, in which case page the // CLI's own transcript instead of doing nothing (_maybePageCliTranscript). if (this._maybePageCliTranscript(ev, lines)) return; this._logScrollRouting('local-scrollback'); this._noteTerminalUserScroll(lines); this._smoothScrollBy(lines); }, { passive: false, capture: true } ); // 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, including the forwarding // branch: for the sessions whose wheel goes to the CLI's own transcript // (_shouldForwardWheelToApp), a touch drag must go there too, or every // phone/tablet swipe scrolls the local buffer of stale repaint frames and // drags the CLI's pinned input box off the screen (issue #205's mobile // half). Same gate, so Shift has no touch analog but the local-scrollback // opt-out setting and the CLI-version gate apply to touch exactly as they // do to the wheel — including the PageUp/PageDown fallback the wheel uses // when that gate is false and there is no local scrollback to scroll // (_maybePageCliTranscript), which is what keeps a swipe from being a // complete no-op on a phone. { const cellHeight = () => this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13; let touchLastX = 0; 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) { if (this._shouldForwardWheelToApp({ shiftKey: false })) { // Flick momentum keeps feeding the CLI's transcript from the last // touch point; the 40ms coalescer batches the per-frame reports. this._forwardScrollToApp(touchLastX, touchLastY, lines); } else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) { this.terminal.scrollLines(lines); this._maybeLoadMoreHistoryOnScroll(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) let longPressTimer = null; // armed on touchstart, becomes a text selection let longPressStartX = 0; let longPressStartY = 0; let touchStartY = 0; let tapStartedWithTerminalFocus = false; let tapStartIntentCache = null; // px — ignore micro-drift to distinguish tap from scroll. Shared with the // keyboard-dismiss handler so both classify the same gesture the same way. const TAP_THRESHOLD = window.CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_TAP_SLOP; container.addEventListener( 'touchstart', (ev) => { // The selection bar is a child of this container: its buttons own their // own taps and must not arm a gesture on the terminal underneath. if (ev.target?.closest?.('.term-select-bar')) return; if (ev.touches.length !== 1) { clearTimeout(longPressTimer); longPressTimer = null; } if (ev.touches.length === 1) { touchLastX = ev.touches[0].clientX; touchLastY = ev.touches[0].clientY; touchStartY = touchLastY; velocity = 0; pixelAccum = 0; isTouching = true; didScroll = false; tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused(); // Classifying scans the whole viewport with translateToString, and // this runs at the start of EVERY gesture including scroll drags. // Cache the result for the touchend of this same gesture rather than // recomputing it; the cache is keyed on the exact start coordinates // so a finger that moved re-classifies at its real position. const touchStartIntent = this._classifyMobileTerminalTap(touchLastX, touchLastY); tapStartIntentCache = { x: touchLastX, y: touchLastY, intent: touchStartIntent }; if (touchStartIntent === 'content') { // Cancel xterm/browser focus before the compatibility click can // open the OS keyboard. Content taps are re-emitted as SGR on // touchend. // // 'history' is deliberately NOT included. A scrolled-up viewport // sends nothing, so there is no compatibility click worth // cancelling — and preventDefault() here, paired with touchend's // early return, closes both routes to focus at once. Since // selectSession() ends with scrollToLastNonEmptyLine(), that made // the keyboard unreachable after every tab switch. ev.preventDefault(); this._blurMobileTerminalInput(); } // Hold still and this press becomes a text selection. Cancelled by any // travel past the shared tap slop below, so a scroll can never become one. longPressStartX = touchLastX; longPressStartY = touchLastY; clearTimeout(longPressTimer); longPressTimer = setTimeout(() => { longPressTimer = null; this._beginTouchSelection(longPressStartX, longPressStartY); }, window.CodemanTerminalInput.TOUCH_SELECT_LONG_PRESS_MS); lastTime = 0; if (scrollFrame) { cancelAnimationFrame(scrollFrame); scrollFrame = null; } } }, { passive: false } ); container.addEventListener( 'touchmove', (ev) => { // A drag that follows the long press grows the selection instead of // scrolling; preventDefault keeps the page from taking the gesture back. if (this._touchSelecting) { ev.preventDefault(); const selTouch = ev.touches[0]; if (selTouch) this._extendTouchSelection(selTouch.clientX, selTouch.clientY); return; } if (longPressTimer && ev.touches.length === 1) { const t = ev.touches[0]; if ( Math.abs(t.clientX - longPressStartX) > TAP_THRESHOLD || Math.abs(t.clientY - longPressStartY) > TAP_THRESHOLD ) { clearTimeout(longPressTimer); longPressTimer = null; } } if (ev.touches.length === 1 && isTouching) { const touchY = ev.touches[0].clientY; if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) { didScroll = true; } // Below the tap threshold, treat the gesture as a potential tap: // don't preventDefault (iOS needs click synthesis to show the // keyboard) and don't accumulate scroll distance or velocity. Without // this guard, sub-threshold micro-drift still scrolls a line and // leaves a non-zero velocity that touchend turns into a momentum // fling, so a jittery tap would both position the cursor AND scroll. if (!didScroll) return; ev.preventDefault(); const delta = touchLastY - touchY; // positive = scroll down pixelAccum += delta; velocity = delta * 1.2; touchLastX = ev.touches[0].clientX; touchLastY = touchY; // Convert accumulated pixels to whole lines const ch = cellHeight(); const lines = Math.trunc(pixelAccum / ch); if (lines !== 0) { if (this._shouldForwardWheelToApp({ shiftKey: false })) { this._logScrollRouting('forward-sgr'); this._forwardScrollToApp(touchLastX, touchLastY, lines); } else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) { this._logScrollRouting('local-scrollback'); this._noteTerminalUserScroll(lines); this.terminal.scrollLines(lines); this._maybeLoadMoreHistoryOnScroll(lines); } pixelAccum -= lines * ch; } } }, { passive: false } ); container.addEventListener( 'touchend', (ev) => { if (ev.target?.closest?.('.term-select-bar')) return; clearTimeout(longPressTimer); longPressTimer = null; isTouching = false; if (this._touchSelecting) { // Lifting ends the DRAG, not the selection: the bar stays up so the // range can still be extended by tapping, or copied. preventDefault // cancels the compatibility mouse events this touchend would otherwise // synthesize — see _endTouchSelectionGesture. ev.preventDefault(); velocity = 0; this._endTouchSelectionGesture(); tapStartedWithTerminalFocus = false; return; } if (!scrollFrame && Math.abs(velocity) > 0.3) { scrollFrame = requestAnimationFrame(scrollLoop); } if (!didScroll && this.terminal) { const touch = ev.changedTouches && ev.changedTouches[0]; if (touch) { this._suppressTrustedTapMouseEvents(); const cached = tapStartIntentCache && tapStartIntentCache.x === touch.clientX && tapStartIntentCache.y === touch.clientY ? tapStartIntentCache.intent : null; this._handleMobileTerminalTap(touch, tapStartedWithTerminalFocus, cached); } } tapStartedWithTerminalFocus = false; }, // NOT passive: the selection branch above must be able to preventDefault // the compatibility mouse events. Every other path leaves the event alone. { passive: false } ); // Android Chrome fires `contextmenu` at its long-press threshold and then runs // its default long-press behaviour. Suppressed ONLY while a selection gesture // is in flight — a desktop right-click keeps its menu, since the timer is null // and no gesture is active there. container.addEventListener('contextmenu', (ev) => { if (longPressTimer !== null || this._touchSelecting || this._touchSelectionActive) { ev.preventDefault(); return; } // Right-click COPIES the selection, the mintty/PuTTY convention, because // the browser's own menu structurally cannot offer it here: xterm paints // glyphs into a canvas, so a terminal selection is not a DOM selection // and the native "Copy" item has nothing to act on (it is absent or // inert). This is the second half of the habit users bring from a native // terminal running a mouse-tracking TUI — Shift+drag to select (see // _installShiftDragSelection), right-click to copy — and without it that // gesture dead-ends after the selection is made. // // With NOTHING selected the native menu is left alone: it still carries // the browser-level items (reload, inspect) and suppressing it there // would take them away to offer nothing in return. if (!this.terminal?.hasSelection?.()) return; const selection = this.terminal.getSelection(); if (!selection) return; ev.preventDefault(); void this.copyTerminalSelection(selection); }); container.addEventListener( 'touchcancel', () => { clearTimeout(longPressTimer); longPressTimer = null; isTouching = false; this._touchSelecting = false; velocity = 0; pixelAccum = 0; tapStartedWithTerminalFocus = false; }, { passive: true } ); } // ── Desktop click-to-position cursor ────────────────────────────── // A real mouse click normally reaches the PTY through xterm's own mouse // encoder, but that encoder only runs while mouseTrackingMode is ON — and // the server strips the enabling DECSETs from claude/codex/gemini output // (isAltScreenStripMode, session.ts) so the wheel keeps scrolling // scrollback. Desktop clicks therefore stopped reporting entirely (the // same breakage the mobile touchend tap branch above works around). // Hand-encode the SGR report for plain left-clicks on those sessions. container.addEventListener('click', (ev) => this._handleDesktopTerminalClick(ev)); this._installMobileKeyboardDismiss(); // Welcome message this.showWelcome(); // Image paste and drag-and-drop support this.initImageInput(); // Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch this._chunkedWriteGen = 0; this._bufferLoadSeq = 0; this._bufferLoadOwner = null; this._lastUserScrollUpAt = null; // 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 = () => { if (this._tabRailResizeOwnsObserver) return; // 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; // Same yield as sendResize: never resize a PTY whose session is showing // in its own window. Dragging the dashboard's border must not reshape it. const detachedElsewhere = !this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId); if (this.activeSessionId && !keyboardUp && !detachedElsewhere) { 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' && this.terminal && this.isTerminalAtBottom() ) { this.terminal.write('\x1b[3J\x1b[H\x1b[2J'); } this._lastResizeDims = { cols, rows }; // Typed + WS-first like sendResize: the viewport type feeds resize // arbitration (a phone rotating must not bypass a desktop claim), // and a desktop window narrowing past the tablet breakpoint must // send a typed WS frame so its stale desktop claim is released. const viewportType = typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType ? MobileDetection.getDeviceType() : 'desktop'; let sentViaWs = false; if (this._wsReady && this._wsSessionId === this.activeSessionId) { try { this._ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: viewportType })); sentViaWs = true; } catch { // Fall through to HTTP POST } } if (!sentViaWs) { fetch(`/api/sessions/${this.activeSessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols, rows, viewportType }), }).catch(() => {}); } } } // Update subagent connection lines and local echo at new dimensions. // Lineage lines are desktop-only, so a resize across the 1024px boundary // has to re-resolve their gate before the redraw, not just move them. this.applyLineageLineSettings?.(); 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. const handleTerminalData = (data) => { // Mouse SGR reports (tap-to-position) are NOT IME input — they must reach // the PTY even while the CJK input field owns focus. Without this exception // tapping to move the cursor silently does nothing whenever Chinese input // is on, because cjkActive stays true the whole time the field is visible. const isMouseReport = /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data); // CJK input has focus — block xterm from sending keystrokes to PTY if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) { // Self-heal: if the CJK field is visible but focus drifted to xterm's // hidden textarea (e.g. something called terminal.focus()), everything // typed lands HERE and is swallowed — keyboard shows the IME composing // while both the CJK field and the terminal stay empty. Route focus // back so the very next keystroke lands in the CJK field again. // Only GENUINE typed input qualifies: onData also fires for xterm's // self-generated query replies (DA/DSR/CPR/OSC during Ink redraws), // which arrive no matter what has focus — so require focus to be on // xterm's own textarea and bail on query replies, or this would steal // focus from the rename/search/settings inputs while output streams. const cjkEl = document.getElementById('cjkInput'); if ( cjkEl?.classList.contains('cjk-input-visible') && document.activeElement === this.terminal.textarea && !window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(data) ) { _crashDiag.log('CJK regain-focus (onData swallowed input)'); cjkEl.focus(); } return; } if (this.activeSessionId) { // Filter terminal query replies generated by xterm.js itself. // Forwarding them through the WebSocket injects DA/DSR/CPR replies // into the foreground process as typed input (for example "0;276;0c"). if ( window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(data) ) { return; } // ── One-shot Ctrl (mobile shell bar, issue #262) ── // A virtual keyboard reports no usable key events, so a keydown hook // would never see the character the modifier applies to: it arrives // here as onData text. Sits AFTER the query-response filter so xterm's // own DA/CPR replies can never spend the modifier, and BEFORE every // send path so the control byte follows the normal control-char route // (immediate flush, local-echo state cleared). // // Mouse and focus reports are skipped rather than suppressed: they are // real bytes the PTY still needs, they just were not typed by anyone. // A shell session passes mouse DECSETs through, so with vim or htop // running, one tap on the terminal used to spend the modifier silently // (measured against a real shell). See isTerminalFocusOrMouseReport. if ( typeof KeyboardAccessoryBar !== 'undefined' && KeyboardAccessoryBar.isCtrlArmed?.() && !window.CodemanTerminalInput?.isTerminalFocusOrMouseReport(data) ) { data = KeyboardAccessoryBar.consumeCtrl(data); } this._lastTerminalData = { data, time: performance.now() }; // ── Local Echo Pass-through ── // After a composer nav key (arrow/Home/End/Delete) the real cursor may // sit mid-text, where the overlay's append-only buffering would corrupt // both the preview and the submitted text. Such sessions are handed // back to plain PTY echo until Enter or Ctrl+C submits/cancels the // composer line (see the nav-key branch below). const echoPassthrough = this._localEchoEnabled && this._echoPassthroughSessions?.has(this.activeSessionId); if (echoPassthrough && (data === '\r' || data === '\x03')) { this._echoPassthroughSessions.delete(this.activeSessionId); } // ── 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 && !echoPassthrough) { 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(); } else if (source === false) { // Nothing pending, nothing flushed, nothing detected. The // composer may still hold text the overlay cannot see (buffer // detection is suppressed after a control-char flush), so // forward the backspace instead of swallowing it (issue #218); // an empty composer ignores it. this._pendingInput += data; flushInput(); } // 'pending' = removed unsent text (no PTY backspace needed) 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) { // Bracketed paste (terminal.paste() while DECSET 2004 is on): // flush typed-but-unsent overlay text FIRST so the pasted block // lands after it in the composer, not before it (issue #219). // The paste sequence gets its own delayed write: Codex's // paste-burst handling drops keystrokes that arrive in the SAME // PTY read as a bracketed paste (verified against codex 0.147), // mirroring the delayed \r in the Enter branch above. if (data.startsWith(window.CodemanTerminalInput.BRACKETED_PASTE_START)) { const hadPending = !!this._localEchoOverlay?.pendingText; this._flushLocalEchoPending(); if (hadPending) { flushInput(); setTimeout(() => { this._pendingInput += data; flushInput(); }, 80); } else { this._pendingInput += data; flushInput(); } return; } // Composer nav keys (arrows, Home/End, Delete, PgUp/PgDn): // flush unsent text so the key edits the real composer state, // then hand the session to plain PTY echo until Enter/Ctrl+C. // The cursor may now sit mid-text, where append-only buffering // cannot track edits (issue #218). if (window.CodemanTerminalInput.isComposerNavKey(data)) { this._flushLocalEchoPending(); if (!this._echoPassthroughSessions) this._echoPassthroughSessions = new Set(); this._echoPassthroughSessions.add(this.activeSessionId); this._pendingInput += data; flushInput(); return; } // 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; } } // ── Predictive Echo (codex): visual only. A plain statement, never a // `return`: control ALWAYS falls through into the send path below, // which is the byte-identity guarantee for #218/#219/#220/#222 — // with the predictor active, absent or throwing, the wire sees the // same bytes. Body in _predictHookOnData (vm-testable). this._predictHookOnData(data); // ── 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); } } } }; // Chrome on Android delivers a `composed: true` input event preceded by a // keydown, which is exactly the shape xterm's _inputEvent refuses to // forward, so the committed character is silently dropped. The controller // forwards the input event's own `data` when xterm produced nothing for // that keystroke. Created AFTER terminal.open() on purpose: for an event // targeting the textarea, at-target listeners run in registration order, // so xterm's listener (added in open()) still runs first. The controller // registers its own listener with `capture: true`; on bubble xterm's // `cancel()` (stopPropagation) would swallow exactly the handled events — // see the measured table in terminal-keycode229-recovery.js. try { this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({ textarea: this.terminal.textarea, emitRecovered: (data) => handleTerminalData(data), isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true, }); } catch { this._keyCode229Recovery = null; } this.terminal.onData((data) => { // Canonical xterm data. Telling the controller is what lets it know a // keystroke was already delivered and needs no recovery. // // ⚠️ onData ALSO fires for output xterm produces on its own initiative: // the DA/DSR/CPR/OSC replies it answers during Ink redraws, and the SGR // mouse and focus reports (see the two predicates above, used for exactly // this question at the send sites). Any one of those landing between the // keydown and the candidate's zero-delay resolution would be read as // "xterm spoke for this keystroke", standing the recovery down and // leaving the character dropped, worst on a busy agent pane, which is // the case this exists for. Narrowing the counter cannot cause a // duplicate: it only ever makes the controller less sure it can stand down. try { const input = window.CodemanTerminalInput; if (!input?.shouldSuppressTerminalQueryResponse(data) && !input?.isTerminalFocusOrMouseReport(data)) { this._keyCode229Recovery?.notifyCanonicalData?.(); } } catch { // Bookkeeping must never block real input. } handleTerminalData(data); }); }, /** * 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; const provider = { 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; } // Stitch the LOGICAL line back together. // // xterm invokes this provider per visible ROW and translateToString returns // that row alone, so a URL or path longer than the terminal is wide matched // only as far as the row boundary and the link opened a PREFIX of the real // target. `terminalLogicalLine` (constants.js) owns the reconstruction — // both continuation kinds, the indent a hard wrap leaves on its // continuation, and the offset↔cell mapping — because touch selection // measures the SAME lines and the two must not disagree. // Bounded so a screenful of full-width output (wide tables, box drawing) // cannot make every hover stitch and re-scan the entire viewport. const MAX_STITCHED_ROWS = 12; const logical = window.CodemanTerminalLines?.terminalLogicalLine( buffer, bufferLineNumber - 1, self.terminal.cols, MAX_STITCHED_ROWS ); if (!logical) { callback(undefined); return; } const lineText = logical.text; /** Map an offset in the stitched text back to a 1-based terminal cell. */ const coordAt = (index) => { const cell = logical.offsetToCell(index); return { x: cell.col + 1, y: cell.row + 1 }; }; if (!lineText || !lineText.includes('/')) { callback(undefined); return; } const links = []; // Pattern 0: URLs (https://, http://) — matched first so they take priority // // A single `&` is PART of the URL: it separates query parameters, so excluding // it truncated every real query string (`?post=1479&action=edit` linked only // through `1479`, landing on the wrong page). `&&` is still a boundary, since // that is the shell operator and never appears inside a URL. A lone trailing // `&` is trimmed below with the other trailing punctuation. 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; const start = coordAt(startCol); const end = coordAt(startCol + cleaned.length); if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return; links.push({ text: cleaned, range: { start, end }, decorations: { pointerCursor: true, underline: true }, activate(_event, text) { // A `localhost` link tapped from another device can only work // through the server: route it into a proxied web tab // (webview-tabs.js). Anything else opens as before. if (self.openLinkThroughWebTabIfLoopback?.(text)) return; window.open(text, '_blank', 'noopener,noreferrer'); }, hover() { self._linkHovered = true; }, leave() { self._linkHovered = false; }, }); }; // Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.) // Handles: tail -f /path, grep pattern /path, cat -n /path // ⚠ The arg group must stay linear-time: `(?:[^\s\/]*\s+)*` (empty-matchable // token, unbounded) backtracks exponentially on lines with a trigger word // followed by multi-space runs (e.g. wrapped heredoc/table output) — froze // the whole tab on hover. Non-empty token + bounded reps is O(n). const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g; // Pattern 2: Paths with common extensions. Image/PDF/media extensions are // included so pasted-attachment paths (`.claude-images/paste-*.png`) and // screenshots an agent just wrote are clickable; those open the file // preview rather than the log viewer (see addLink). // // The literal lives in constants.js because the response viewer linkifies // the SAME paths out of markdown — one definition, two consumers. A fresh // instance per call: `lastIndex` is per-object state. const extPattern = absoluteFilePathPattern(); // 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; const start = coordAt(startCol); const end = coordAt(startCol + filePath.length); // Skip if already have link at this position if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return; links.push({ text: filePath, range: { start, end }, // 1-based, may span wrapped rows decorations: { pointerCursor: true, underline: true, }, activate(event, text) { // Tailing a PNG in the log viewer shows binary noise; the file preview // already renders images, PDFs, documents and media inline — and it // now reaches files outside the workspace too, which is where an // agent's screenshots and scratchpad captures actually land. // // Text goes to the log viewer, which follows a file that is still // being written — but ONLY where it can actually read: it spawns // `tail -f` and allows the workspace, /var/log and ~/logs, so an // out-of-workspace path there answered "Path must be within // working directory or allowed log directories" while the SAME // path clicked in the response viewer previewed fine. The preview // reads those through the guarded attachment routes, so external // paths route there and the two surfaces agree. if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) { self.openFilePreview(text, self.activeSessionId); return; } self.openLogViewerWindow(text, self.activeSessionId); }, hover() { self._linkHovered = true; }, leave() { self._linkHovered = false; }, }); }; // 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); }, }; // Keep the provider reachable: on touch devices xterm's linkifier never // resolves a link (it is driven by mousemove/mouseup, which a tap does not // produce), so the tap path asks this SAME provider what is under the finger // rather than growing a second, driftable copy of the patterns. // See _terminalLinkAtPoint. this._terminalLinkProvider = provider; this.terminal.registerLinkProvider(provider); console.log('[LinkProvider] File path link provider registered'); }, /** * The terminal link under a viewport point, or null. * * Resolved through the provider registered above, so a tap and a desktop click * can never disagree about what is a link or where it ends. Containment * mirrors xterm's own `_linkAtPosition` — flattened `y * cols + x`, inclusive * at both ends — for the same reason. * * ⚠️ The provider answers its callback SYNCHRONOUSLY (every path in * `registerFilePathLinkProvider` does, including the empty ones). xterm's * ILinkProvider contract permits an async reply, so this reads whatever * arrived by the time the call returns and answers null otherwise: a tap then * keeps its normal meaning instead of opening a link late, after the gesture * that made `window.open` permissible is gone. */ _terminalLinkAtPoint(clientX, clientY) { const provider = this._terminalLinkProvider; const buffer = this.terminal?.buffer?.active; if (!provider || !buffer) return null; const pos = this._clientPointToCell(clientX, clientY); if (!pos) return null; // Link ranges are 1-based ABSOLUTE buffer lines (xterm adds ydisp to the // viewport row before asking), which is what the provider's coordAt() emits. const y = (buffer.viewportY || 0) + pos.row; let links = null; try { provider.provideLinks(y, (result) => { links = result || []; }); } catch { return null; } if (!links || links.length === 0) return null; const cols = Math.max(1, this.terminal.cols || 1); const current = y * cols + pos.col; return ( links.find((link) => { const start = link?.range?.start; const end = link?.range?.end; if (!start || !end) return false; return start.y * cols + start.x <= current && current <= end.y * cols + end.x; }) || null ); }, /** * Is this point on the caret's logical line — the editable composer? * * There a tap means "put the cursor here", so a URL the USER typed or pasted * into a prompt must stay editable rather than opening itself. The caret is the * signal that works for every CLI: claude's composer row carries it, and in a * plain shell it sits on the prompt line while output scrolls above, so the * same test covers both without asking what mode is running (tap * classification cannot answer this — a shell session classifies EVERY tap as * 'input', which would leave every URL in shell output inert). * * The caret's line is walked out through soft wraps, since a long prompt spans * rows. */ _tapIsOnCaretLine(clientX, clientY) { const buffer = this.terminal?.buffer?.active; if (!buffer?.getLine) return false; const pos = this._clientPointToCell(clientX, clientY); if (!pos) return false; const rows = Math.max(1, this.terminal.rows || 1); const cursorRow = Math.max(0, Math.min(rows - 1, buffer.cursorY || 0)); const tappedRow = pos.row - 1; if (tappedRow === cursorRow) return true; let start = cursorRow; while (start > 0 && buffer.getLine(buffer.viewportY + start)?.isWrapped) start--; let end = cursorRow; while (end + 1 < rows && buffer.getLine(buffer.viewportY + end + 1)?.isWrapped) end++; return tappedRow >= start && tappedRow <= end; }, /** * Activate the terminal link under a touch point. Returns true when one was. * * xterm activates a link from a `mousemove` that resolves what is under the * pointer, followed by a `mouseup` on its SCREEN element — and on a touch * device it receives neither: `touch-action: none` plus touchstart's * preventDefault suppress the browser's compatibility mouse events, * _installMobileTapMouseGuard drops the ones that still arrive, and the * synthetic pair dispatched for mouse REPORTING goes to the `.xterm` root, * an ANCESTOR of the node the linkifier listens on (so it cannot reach it) and * carries no mousemove either way. Every URL and file path in the terminal was * therefore inert on phones and tablets — Claude Code's own `/login` URL * included, which is unfinishable from a phone without this. * * Activating here, synchronously inside the touchend handler, is what keeps * the user gesture that lets the URL branch's `window.open` through the popup * blocker; a later activation (a timer, a promise) is silently swallowed. */ _activateTerminalLinkAtPoint(clientX, clientY) { const link = this._terminalLinkAtPoint(clientX, clientY); if (!link || typeof link.activate !== 'function') return false; try { link.activate(null, link.text); } catch (err) { console.warn('[LinkProvider] tap activation failed:', err); return false; } return true; }, // ═══════════════════════════════════════════════════════════════ // Touch text selection — long-press to select, tap to extend, Copy // ═══════════════════════════════════════════════════════════════ // // There was no way to copy terminal text from a phone at all. Three layers // ruled it out at once: `user-select: none` on the whole terminal subtree // (taps are cursor gestures there, so the OS callout had to go), the WebGL // renderer drawing glyphs as pixels with only the accessibility tree behind // them, and xterm's own selection being a mouse DRAG — while the tap path // dispatches a zero-movement mousedown/mouseup pair, i.e. a click. // // So the gesture drives xterm's selection API directly (`select`, public and // renderer-independent, and the highlight is drawn by xterm itself). Long-press // is free real estate: tap and swipe are taken, long-press and double-tap are // used by nothing. /** * While a selection gesture is in flight, the terminal input must not hold focus. * * ⚠️ This is the guard that actually fixes "the keyboard pops up the moment the * selection appears". The mouse-event guard cannot: the focus does not arrive * through a mouse event at all. Android Chrome runs its own long-press handling * at ~500ms and focuses the nearest editable element — xterm's helper textarea, * a real