
(function() {
  var surface = document.getElementById('terminal-surface');
  var ESC = String.fromCharCode(27);
  var C1_CSI = String.fromCharCode(155);
  var CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa);
  var TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e);
  var EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f);
  var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g');
  var statusDotPendingSelector = false;
  var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
  var term = null; 
  var terminalDataRepliesEnabled = false;

  function resetTerminalDataReplyAuthority() {
    terminalDataRepliesEnabled = false;
  }

  function resumeTerminalDataReplyAuthority() {
    terminalDataRepliesEnabled = true;
  }

  function forwardTerminalDataReply(data) {
    if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data });
  }

  function enqueueTerminalDataReplyBoundary(gen) {
    enqueueWriteBoundary(function() {
      if (gen === terminalGeneration) terminalDataRepliesEnabled = true;
    });
  }

  function attachTerminalQueryReplyBridge(term, gen) {
    // Why: parser replies require stdin enabled, but mobile input is owned by
    // native controls. Keep xterm's textarea inert for touch/hardware keys.
    try {
      term.attachCustomKeyEventHandler(function() { return false; });
      if (term.textarea) {
        term.textarea.readOnly = true;
        term.textarea.tabIndex = -1;
        term.textarea.setAttribute('inputmode', 'none');
      }
    } catch (e) {}
    try {
      termObserverDisposables.push(term.onData(function(data) {
        forwardTerminalDataReply(data);
      }));
    } catch (e) {}
    // Why: live output can queue before initial replay finishes. Enable replies
    // at the replay boundary so those live queries are answered, never replayed ones.
    enqueueTerminalDataReplyBoundary(gen);
  }

  
  // Why: phone-fit startup can issue several init() calls before xterm finishes
  // replaying. Track the last painted surface separately from its replacement.
  var committedTerm = null;
  var committedSurface = surface;
  var pendingTerm = null;
  var pendingSurface = null;

  function beginTerminalSurfaceSwap() {
    // Why: a superseded hidden replacement must not remain between the last
    // painted surface and the newest one, or the newest commits below the viewport.
    if (pendingSurface) {
      try { pendingSurface.remove(); } catch (e) {}
      if (pendingTerm) try { pendingTerm.dispose(); } catch (e) {}
      pendingSurface = null;
      pendingTerm = null;
    }
    var swap = {
      oldTerm: committedTerm,
      oldSurface: committedSurface,
      nextSurface: document.createElement('div')
    };
    disposeTermObservers();
    swap.nextSurface.id = 'terminal-surface';
    swap.nextSurface.style.visibility = 'hidden';
    swap.nextSurface.style.position = 'absolute';
    swap.nextSurface.style.left = '0';
    swap.nextSurface.style.top = '0';
    document.getElementById('terminal-container').appendChild(swap.nextSurface);
    surface = swap.nextSurface;
    pendingSurface = swap.nextSurface;
    attachSurfaceEventHandlers(surface);
    swap.oldSurface.removeAttribute('id');
    return swap;
  }

  function commitTerminalSurfaceSwap(swap, nextTerm) {
    swap.nextSurface.style.visibility = 'visible';
    swap.nextSurface.style.position = '';
    swap.nextSurface.style.left = '';
    swap.nextSurface.style.top = '';
    swap.oldSurface.remove();
    if (swap.oldTerm) swap.oldTerm.dispose();
    committedTerm = nextTerm;
    committedSurface = swap.nextSurface;
    pendingTerm = null;
    pendingSurface = null;
  }

  var scrollIndicator = document.getElementById('scroll-indicator');
  var scrollThumb = document.getElementById('scroll-thumb');
  var scrollIndicatorHideTimer = null;
  var writeQueue = [];
  var writeQueueHead = 0;
  var writesDraining = false;
  var afterDrainCallbacks = [];
  var termObserverDisposables = [];
  var ready = false;
  // Why: init() flips ready false on every re-init (live width reflow included)
  // while the old surface stays visible; a document-scoped latch drives the
  // fatal/non-fatal decision so a transient reflow cannot blank a live terminal.
  var everReady = false;
  var currentScale = 1;
  // Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a
  // gesture only; it resets to 1 on release. The persistent "text size" is the
  // real xterm fontSize (currentTextScale × BASE_FONT_PX), so changing it
  // reflows the grid: a bigger cell means fewer columns fit, and RN re-measures
  // and resizes the PTY (terminal.updateViewport) so the shell rewraps to the
  // new width. A finished pinch snaps to the nearest preset and reports it to RN.
  var userScale = 1;
  var BASE_FONT_PX = 13;
  var MIN_FONT_PX = 6;
  var MIN_FIT_COLS = 20;
  var currentTextScale = 1;
  var TEXT_SCALE_PRESETS = [0.5,0.75,1,1.25,1.5,2];
  var MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0];
  var MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1];
  function snapToTextScalePreset(value) {
    var best = TEXT_SCALE_PRESETS[0], bestDelta = Infinity;
    for (var i = 0; i < TEXT_SCALE_PRESETS.length; i++) {
      var delta = Math.abs(TEXT_SCALE_PRESETS[i] - value);
      if (delta < bestDelta) { bestDelta = delta; best = TEXT_SCALE_PRESETS[i]; }
    }
    return best;
  }
  function fontPxForScale(scale) {
    return Math.max(MIN_FONT_PX, Math.round(BASE_FONT_PX * scale));
  }
  function isIOSWebView() {
    if (/iP(ad|hone|od)/.test(navigator.userAgent)) return true;
    return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
  }
  // Why: iOS WebKit does not reliably resolve "SF Mono" by CSS family name and can
  // fall to a non-monospace face; lead with the ui-monospace generic to avoid that.
  var TERMINAL_FONT_FALLBACKS = '"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace';
  var terminalFontFamily = (isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS;
  // Why: change the real font size, then resize the grid to fit the viewport at
  // the new cell metrics so the text shows at its true size immediately. RN's
  // refit (measure → updateViewport) then makes the server reflow the PTY to the
  // same column count so the shell rewraps. cell metrics update on the frame
  // after fontSize changes, so the resize/fit is deferred one rAF.
  function applyTextScale(scale) {
    currentTextScale = scale;
    if (!term) return;
    var px = fontPxForScale(scale);
    if (term.options.fontSize === px) return;
    term.options.fontSize = px;
    requestAnimationFrame(function() {
      if (!term) return;
      var cellW = getCellWidth();
      var cellH = getCellHeight();
      if (cellW > 0 && cellH > 0) {
        var cols = Math.floor(window.innerWidth / cellW);
        if (cols < MIN_FIT_COLS) return;
        var rows = Math.max(8, Math.floor(window.innerHeight / cellH));
        term.resize(cols, rows);
        emitKeyboardAvoidanceMetrics();
      }
      applyFitScale('text-scale');
    });
  }
  var panX = 0, panY = 0;
  var smoothScrollOffsetY = 0;
  var pendingNormalScrollDeltaY = 0;
  var normalScrollFrameId = null;
  var initRows = 24;
  var terminalGeneration = 0;
  var defaultTheme = {"background":"#1a1b26","foreground":"#c0caf5","cursor":"#c0caf5","cursorAccent":"#1a1b26","selectionBackground":"#33467c","selectionForeground":"#c0caf5","black":"#15161e","red":"#f7768e","green":"#9ece6a","yellow":"#e0af68","blue":"#7aa2f7","magenta":"#bb9af7","cyan":"#7dcfff","white":"#a9b1d6","brightBlack":"#414868","brightRed":"#f7768e","brightGreen":"#9ece6a","brightYellow":"#e0af68","brightBlue":"#7aa2f7","brightMagenta":"#bb9af7","brightCyan":"#7dcfff","brightWhite":"#c0caf5"};
  var terminalThemeInput = null;
  var terminalTheme = defaultTheme;
  var terminalMinimumContrastRatio = 3;
  var webglAddon = null;
  var webglRecoveryTimer = null;
  var activeAltScreenSnapshot = false;
  var trackedMouseTrackingMode = 'none';
  var sgrMouseMode = false;
  var sgrMousePixelsMode = false;
  var initialOscLinks = [], initialOscLinkRowOffset = 0;
  var initialOscLinkEvictionReady = false;
  var mouseModeScanTail = '';
  var handledMessageIds = [];
  // Why: after init() the initial scrollback applyFitScale may have run
  // against an empty buffer (or one without the widest line yet). Re-fit
  // once when the first live data chunk arrives so a wider line that pushes
  // scrollWidth past the previously-measured value gets re-scaled to fit.
  var firstDataPending = false;

  // Diagnostic logger — bridges WebView console.log to RN via postMessage.
  // Tag with [fit] so it's easy to filter in the Expo/Metro logs.
  function flog(tag, payload) {
    try {
      if (window.ReactNativeWebView) {
        window.ReactNativeWebView.postMessage(JSON.stringify({
          type: 'log', tag: '[fit]' + tag, payload: payload
        }));
      }
    } catch (e) {}
  }

  function getCellWidth() {
    if (!term || !term._core) return 0;
    var core = term._core;
    if (core._renderService && core._renderService.dimensions) {
      return core._renderService.dimensions.css.cell.width || 0;
    }
    return 0;
  }

  // Why: width measurement strategy.
  //   1. Prefer cellWidth × term.cols — this is what xterm's renderer uses
  //      to lay out and is independent of buffer content. It's the "logical
  //      width" of the terminal grid.
  //   2. Fall back to term.element.scrollWidth — the actual rendered DOM
  //      width — only when cellWidth isn't available yet (renderer not
  //      initialized). This is content-dependent (reflects widest row),
  //      but better than nothing.
  //   3. If both are 0, return 1 (no scale change). The retry loop in
  //      applyFitScale will keep trying until one is positive.
  function computeFitScale() {
    if (!term) return 1;
    var cellW = getCellWidth();
    var termWidth = cellW > 0 ? cellW * term.cols : (term.element ? term.element.scrollWidth : 0);
    if (termWidth <= 0) return 1;
    var vpWidth = window.innerWidth;
    return Math.min(1, vpWidth / termWidth);
  }

  function getTotalScale() { return currentScale * userScale; }

  function updateTransform() {
    surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')';
    updateScrollIndicator(false);
    if (selMode === 'select') repositionOverlay();
  }

  function updateScrollIndicator(reveal) {
    if (!scrollIndicator || !scrollThumb || !term || !term.buffer || !term.buffer.active) return;
    var buffer = term.buffer.active;
    var maxViewportY = buffer.baseY || 0;
    if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) {
      scrollIndicator.classList.remove('visible');
      return;
    }
    var trackHeight = Math.max(0, window.innerHeight - 8);
    var totalRows = maxViewportY + (term.rows || 0);
    if (trackHeight <= 0 || totalRows <= 0) return;
    var thumbHeight = Math.max(24, trackHeight * (term.rows || 0) / totalRows);
    var maxTop = Math.max(0, trackHeight - thumbHeight);
    var top = maxViewportY > 0 ? (buffer.viewportY / maxViewportY) * maxTop : 0;
    scrollThumb.style.height = thumbHeight + 'px';
    scrollThumb.style.transform = 'translateY(' + top + 'px)';
    if (!reveal) return;
    scrollIndicator.classList.add('visible');
    if (scrollIndicatorHideTimer) clearTimeout(scrollIndicatorHideTimer);
    scrollIndicatorHideTimer = setTimeout(function() {
      scrollIndicator.classList.remove('visible');
      scrollIndicatorHideTimer = null;
    }, 550);
  }


  var DARK_BG_MIN_CONTRAST = 3;
  var LIGHT_BG_MIN_CONTRAST = 4.5;
  // Dark app surface a transparent terminal background composites over (matches desktop APP_SURFACE_COLORS.dark).
  var CONTRAST_APP_SURFACE = { r: 10, g: 10, b: 10 };

  function parseTerminalBackgroundRgba(value) {
    if (typeof value !== 'string') return null;
    var v = value.trim().toLowerCase();
    if (!v) return null;
    if (v === 'black') return { r: 0, g: 0, b: 0, a: 1 };
    if (v === 'white') return { r: 255, g: 255, b: 255, a: 1 };
    if (v === 'transparent') return { r: 0, g: 0, b: 0, a: 0 };
    var hex = v.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
    if (hex) {
      var h = hex[1];
      var ch;
      if (h.length === 3 || h.length === 4) {
        ch = h.split('').map(function (p) { return parseInt(p + p, 16); });
      } else {
        ch = [];
        for (var i = 0; i < h.length; i += 2) ch.push(parseInt(h.slice(i, i + 2), 16));
      }
      return { r: ch[0], g: ch[1], b: ch[2], a: ch[3] === undefined ? 1 : ch[3] / 255 };
    }
    var rgb = v.match(/^rgba?\(([^)]+)\)$/);
    if (!rgb) return null;
    var parts = rgb[1].indexOf(',') >= 0 ? rgb[1].split(',') : rgb[1].split(/[\s/]+/);
    parts = parts.map(function (p) { return p.trim(); }).filter(function (p) { return p.length > 0; });
    if (parts.length < 3) return null;
    var channel = function (p) {
      var n = p.charAt(p.length - 1) === '%' ? (parseFloat(p) / 100) * 255 : parseFloat(p);
      return isFinite(n) ? Math.min(255, Math.max(0, Math.round(n))) : null;
    };
    var r = channel(parts[0]), g = channel(parts[1]), b = channel(parts[2]);
    if (r === null || g === null || b === null) return null;
    var a = 1;
    if (parts[3] !== undefined) {
      var raw = parts[3].charAt(parts[3].length - 1) === '%' ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]);
      a = isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 1;
    }
    return { r: r, g: g, b: b, a: a };
  }

  function terminalRelativeLuminance(rgb) {
    var lin = function (c) {
      var n = c / 255;
      return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4);
    };
    return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b);
  }

  function terminalContrastRatio(a, b) {
    var la = terminalRelativeLuminance(a), lb = terminalRelativeLuminance(b);
    return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
  }

  // Clamp an explicit desktop override to xterm's 1-21 range; null means "no usable override".
  function normalizeTerminalContrastOverride(value) {
    if (typeof value !== 'number' || !isFinite(value)) return null;
    return Math.min(21, Math.max(1, value));
  }

  // Pick the xterm minimumContrastRatio floor from the composed terminal background.
  // Unparseable input defaults to the dark floor so agent output never stays invisible.
  function resolveTerminalContrastFloor(background) {
    var color = parseTerminalBackgroundRgba(background);
    if (!color) return DARK_BG_MIN_CONTRAST;
    var composited = color.a < 1
      ? {
          r: Math.round(color.r * color.a + CONTRAST_APP_SURFACE.r * (1 - color.a)),
          g: Math.round(color.g * color.a + CONTRAST_APP_SURFACE.g * (1 - color.a)),
          b: Math.round(color.b * color.a + CONTRAST_APP_SURFACE.b * (1 - color.a))
        }
      : color;
    var isLight = terminalContrastRatio({ r: 0, g: 0, b: 0 }, composited) >=
      terminalContrastRatio({ r: 255, g: 255, b: 255 }, composited);
    return isLight ? LIGHT_BG_MIN_CONTRAST : DARK_BG_MIN_CONTRAST;
  }

  function normalizeTerminalTheme(input) {
    var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object'
      ? input.theme
      : null;
    if (!source) return defaultTheme;
    var next = {};
    var keys = Object.keys(defaultTheme);
    for (var i = 0; i < keys.length; i++) {
      var key = keys[i];
      if (typeof source[key] === 'string') next[key] = source[key];
    }
    return Object.assign({}, defaultTheme, next);
  }

  function applyTerminalTheme(input) {
    terminalThemeInput = input;
    terminalTheme = normalizeTerminalTheme(input);
    var background = terminalTheme.background || '#1a1b26';
    document.documentElement.style.background = background;
    document.body.style.background = background;
    // Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754);
    // an older host omits the field and the luminance gate stays authoritative.
    var publishedFloor = normalizeTerminalContrastOverride(
      input && typeof input === 'object' ? input.minimumContrastRatio : undefined
    );
    terminalMinimumContrastRatio =
      publishedFloor === null ? resolveTerminalContrastFloor(background) : publishedFloor;
    if (term) {
      term.options.theme = terminalTheme;
      term.options.minimumContrastRatio = terminalMinimumContrastRatio;
    }
  }


  function getCellHeight() {
    if (!term || !term._core) return 15;
    var core = term._core;
    if (core._renderService && core._renderService.dimensions) {
      return core._renderService.dimensions.css.cell.height || 15;
    }
    return 15;
  }

  // Why: clamp pan so the terminal content always covers the viewport
  // when zoomed in. When content is smaller than viewport in a
  // dimension, pin to top-left (no floating in the middle).
  function clampPan() {
    if (!term || !term.element) return;
    var ts = getTotalScale();
    var cw = term.element.scrollWidth * ts;
    var ch = term.element.scrollHeight * ts;
    var vpW = window.innerWidth;
    var vpH = window.innerHeight;
    if (cw > vpW) {
      panX = Math.min(0, Math.max(vpW - cw, panX));
    } else {
      panX = 0;
    }
    if (ch > vpH) {
      panY = Math.min(0, Math.max(vpH - ch, panY));
    } else {
      panY = 0;
    }
  }

  // Why: intentional no-op. Mobile replays a live PTY snapshot then applies
  // live cursor-relative chunks from that same PTY; resizing only the WebView
  // xterm changes cursor coordinates and makes TUI repaint chunks duplicate or
  // overlap. Kept as a no-op so its call sites stay legible.
  function adjustRowsForViewport() {}

  // Why: cold-start fit. After init() opens xterm, the renderer needs
  // several frames before cell dimensions are computed. Reading too early
  // gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM
  // not laid out), and computeFitScale returns 1 → no zoom.
  //
  // Gate: cellWidth × cols is the canonical "logical width" of the grid
  // and reflects xterm's layout decision, independent of buffer content.
  // We commit when cellWidth becomes positive (renderer ready). Fallback:
  // if cellWidth never becomes available, gate on stable positive
  // scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz)
  // so a backgrounded WebView never spins forever.
  var FIT_RETRY_MAX_FRAMES = 60;
  var fitRetryToken = 0;
  function applyFitScale(reason) {
    if (!term || !term.element) return;
    var token = ++fitRetryToken;
    var attempts = 0;
    var lastScrollWidth = -1;
    function attempt() {
      if (token !== fitRetryToken) return;
      if (!term || !term.element) return;
      attempts++;
      var cellW = getCellWidth();
      if (cellW > 0 && term.cols > 0) {
        commitFitScale(reason, attempts, 'cellW');
        return;
      }
      var w = term.element.scrollWidth;
      if (w > 0 && w === lastScrollWidth) {
        commitFitScale(reason, attempts, 'stableSW');
        return;
      }
      lastScrollWidth = w;
      if (attempts >= FIT_RETRY_MAX_FRAMES) {
        flog('commit-timeout', {
          reason: reason,
          attempts: attempts,
          cellW: cellW,
          scrollWidth: w,
          cols: term.cols
        });
        commitFitScale(reason, attempts, 'timeout');
        return;
      }
      requestAnimationFrame(attempt);
    }
    requestAnimationFrame(attempt);
  }

  function commitFitScale(reason, attempts, gate) {
    if (!term || !term.element) return;
    var preSnapScale = computeFitScale();
    currentScale = preSnapScale;
    // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar
    // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents
    // a second applyFitScale from observing a "no-op needed" state.
    if (currentScale >= 0.95) currentScale = 1;
    userScale = 1;
    panX = 0;
    panY = 0;
    smoothScrollOffsetY = 0;
    updateTransform();
    adjustRowsForViewport();

    var cellW = getCellWidth();
    var sw = term.element.scrollWidth;
    var vpW = window.innerWidth;
    var expectedW = cellW * term.cols;
    var suspect =
      currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom
    if (suspect) {
      flog('commit-SUSPECT', {
        reason: reason,
        attempts: attempts,
        gate: gate,
        preSnapScale: preSnapScale,
        finalScale: currentScale,
        cellW: cellW,
        cols: term.cols,
        expectedW: expectedW,
        scrollWidth: sw,
        vpWidth: vpW
      });
    }
    repositionOverlay();
  }

  function isAltScreenActive(data) {
    if (typeof data !== 'string') return false;
    var on = data.lastIndexOf(ESC + '[?1049h');
    var off = data.lastIndexOf(ESC + '[?1049l');
    return on !== -1 && on > off;
  }

  function normalizeInitialData(data) {
    if (!isAltScreenActive(data)) return data;
    var on = data.lastIndexOf(ESC + '[?1049h');
    // Why: SerializeAddon can include normal-buffer scrollback before the
    // active alternate-screen snapshot. Replaying both into a fresh mobile
    // xterm duplicates TUI frames and can flatten SGR attributes.
    return on > 0 ? data.slice(on) : data;
  }

  function updateMouseModeFromData(data) {
    if (typeof data !== 'string' || data.length === 0) return;
    var input = mouseModeScanTail + data;
    mouseModeScanTail = extractMouseModeScanTail(input);
    var re = new RegExp(ESC + 'c|' + ESC + '\\[\\?([0-9;]+)([hl])|' + C1_CSI + '\\?([0-9;]+)([hl])', 'g');
    var match;
    while ((match = re.exec(input)) !== null) {
      if (match[0] === ESC + 'c') {
        trackedMouseTrackingMode = 'none';
        sgrMouseMode = false;
        sgrMousePixelsMode = false;
        continue;
      }
      var enabled = (match[2] || match[4]) === 'h';
      var params = (match[1] || match[3]).split(';');
      for (var i = 0; i < params.length; i++) {
        if (params[i] === '') continue;
        var param = Number(params[i]);
        if (!Number.isInteger(param)) continue;
        if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none';
        if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none';
        if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none';
        if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none';
        if (param === 1006) {
          sgrMouseMode = enabled;
          sgrMousePixelsMode = false;
        }
        if (param === 1016) {
          sgrMouseMode = false;
          sgrMousePixelsMode = enabled;
        }
      }
    }
  }

  function resetWriteQueue() {
    writeQueue = [];
    writeQueueHead = 0;
  }

  function isStatusDotPresentationSelector(value) {
    return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR;
  }

  function endsWithStatusDotPresentationSequence(data) {
    var i = data.length - 1;
    while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--;
    return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT;
  }

  // Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph.
  function normalizeStatusDotPresentation(data) {
    if (typeof data !== 'string' || data.length === 0) return data;
    if (statusDotPendingSelector) {
      statusDotPendingSelector = false;
      var strippedPendingSelectors = false;
      while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1);
      strippedPendingSelectors = data.length === 0;
      if (strippedPendingSelectors) {
        statusDotPendingSelector = true;
        return '';
      }
    }
    var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR);
    statusDotPendingSelector = endsWithStatusDotPresentationSequence(data);
    return normalized;
  }

  function enqueueWrite(data) {
    writeQueue.push(normalizeStatusDotPresentation(data));
  }

  function enqueueWriteBoundary(callback) {
    writeQueue.push(callback);
  }

  function nextQueuedWrite() {
    if (writeQueueHead >= writeQueue.length) {
      resetWriteQueue();
      return undefined;
    }
    var next = writeQueue[writeQueueHead];
    writeQueue[writeQueueHead] = undefined;
    writeQueueHead++;
    // Why: high-throughput terminals can enqueue faster than xterm parses;
    // compact consumed slots so drain work stays O(1) without retaining old chunks.
    if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) {
      writeQueue = writeQueue.slice(writeQueueHead);
      writeQueueHead = 0;
    }
    return next;
  }

  function disposeTermObservers() {
    var disposables = termObserverDisposables;
    termObserverDisposables = [];
    for (var i = 0; i < disposables.length; i++) {
      try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {}
    }
  }

  function extractMouseModeScanTail(input) {
    var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI));
    if (start === -1) return '';
    var tail = input.slice(start);
    // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
    // Keep parser state far beyond normal mode lists while still bounding memory.
    if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return '';
    if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail;
    if (tail.indexOf(ESC + '[?') === 0) {
      return /^[0-9;]*$/.test(tail.slice(3)) ? tail : '';
    }
    if (tail.indexOf(C1_CSI + '?') === 0) {
      return /^[0-9;]*$/.test(tail.slice(2)) ? tail : '';
    }
    return '';
  }

  function pumpWrites(gen) {
    if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
    var next = nextQueuedWrite();
    if (typeof next !== 'string') {
      if (typeof next === 'function') return next(), pumpWrites(gen);
      var callbacks = afterDrainCallbacks;
      afterDrainCallbacks = [];
      for (var i = 0; i < callbacks.length; i++) callbacks[i]();
      return;
    }
    writesDraining = true;
    // Why: xterm.write() parses asynchronously. Row adjustment/resizing must
    // wait until replayed SGR attributes have landed in the buffer.
    term.write(next, function() {
      if (gen !== terminalGeneration) return;
      writesDraining = false;
      pumpWrites(gen);
    });
  }

  function afterWritesDrained(callback) {
    afterDrainCallbacks.push(callback);
    pumpWrites(terminalGeneration);
  }


  function refreshTerminalSurface() {
    if (!term) return;
    try { term.refresh(0, Math.max(0, term.rows - 1)); } catch (e) {}
  }

  function cancelWebglContextRecovery() {
    if (!webglRecoveryTimer) return;
    clearTimeout(webglRecoveryTimer);
    webglRecoveryTimer = null;
  }

  function attachWebglAddon(allowRecovery) {
    if (!term || !window.WebglAddon || !window.WebglAddon.WebglAddon) return false;
    var addon = null;
    try {
      addon = new window.WebglAddon.WebglAddon();
      webglAddon = addon;
      if (addon.onContextLoss) addon.onContextLoss(function() {
        if (webglAddon !== addon) return;
        flog('webgl-context-loss', { retry: allowRecovery });
        webglAddon = null;
        try { addon.dispose(); } catch (e) {}
        refreshTerminalSurface();
        if (!allowRecovery) return;
        // Why: one delayed retry handles transient iOS context loss without
        // entering a GPU crash loop; a second loss stays on the DOM renderer.
        cancelWebglContextRecovery();
        var recoveryTerm = term;
        var recoveryGeneration = terminalGeneration;
        webglRecoveryTimer = setTimeout(function() {
          webglRecoveryTimer = null;
          if (term !== recoveryTerm || terminalGeneration !== recoveryGeneration) return;
          attachWebglAddon(false);
        }, 100);
      });
      term.loadAddon(addon);
      if (!allowRecovery) {
        try { if (addon.clearTextureAtlas) addon.clearTextureAtlas(); } catch (e) {}
        refreshTerminalSurface();
      }
      return true;
    } catch (e) {
      flog('webgl-attach-failed', { retry: !allowRecovery, message: String(e) });
      if (webglAddon === addon) webglAddon = null;
      try { if (addon) addon.dispose(); } catch (disposeError) {}
      refreshTerminalSurface();
      return false;
    }
  }

  document.addEventListener('visibilitychange', function() {
    if (document.visibilityState !== 'visible') return;
    // Why: iOS may restore the xterm model while discarding GPU pixels/theme
    // paint state, so visibility must rebuild the atlas and repaint every row.
    applyTerminalTheme(terminalThemeInput);
    try { if (webglAddon && webglAddon.clearTextureAtlas) webglAddon.clearTextureAtlas(); } catch (e) {}
    refreshTerminalSurface();
  });


  function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) {
    if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale;
    // Why: a width-reflow re-stream rewraps the same content at new cols.
    // Distance-from-bottom (rows) is the only stable anchor across reflow,
    // since line counts and cell positions change. null = stay pinned to bottom.
    var prevB = preserveScroll && term && term.buffer && term.buffer.active ? term.buffer.active : null;
    var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1;
    terminalGeneration++;
    var gen = terminalGeneration;
    // Why: snapshot replay can contain old queries whose replies must never
    // re-enter the live PTY. Each replacement terminal earns authority anew.
    resetTerminalDataReplyAuthority();
    cancelWebglContextRecovery();
    webglAddon = null;
    ready = false;
    resetWriteQueue();
    statusDotPendingSelector = false;
    writesDraining = false;
    afterDrainCallbacks = [];
    initRows = rows || 24;
    firstDataPending = true;
    smoothScrollOffsetY = 0;
    wheelAccumDeltaY = 0;
    mouseModeScanTail = '';
    trackedMouseTrackingMode = 'none';
    sgrMouseMode = false;
    sgrMousePixelsMode = false;
    lastEmittedModes = {
      bracketedPasteMode: false,
      altScreen: false,
      mouseTrackingMode: 'none',
      sgrMouseMode: false,
      sgrMousePixelsMode: false
    };
    var replayData = normalizeInitialData(initialData);
    // Why: normalizeInitialData can discard pre-alt-screen bytes. Keep the
    // mirrored modes aligned with exactly what this mobile xterm replays.
    updateMouseModeFromData(replayData);
    activeAltScreenSnapshot = isAltScreenActive(replayData);
    initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : [];
    initialOscLinkRowOffset = 0;
    initialOscLinkEvictionReady = false;
    var surfaceSwap = beginTerminalSurfaceSwap();
    var nextSurface = surfaceSwap.nextSurface;

    applyTerminalTheme(nextTheme);
    term = new Terminal({
      cols: cols || 80,
      rows: rows || 24,
      theme: terminalTheme,
      minimumContrastRatio: terminalMinimumContrastRatio,
      fontFamily: terminalFontFamily,
      fontSize: fontPxForScale(currentTextScale),
      fontWeight: '300',
      fontWeightBold: '500',
      scrollback: 5000,
      // Why: xterm suppresses parser-generated query replies when disableStdin
      // is true. Native accepts only validated reply grammars from onData.
      disableStdin: false,
      cursorBlink: false,
      cursorStyle: "bar",
      // Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret.
      showCursorImmediately: true,
      // A full inactive cell remains visible under the terminal's phone-fit scale.
      cursorInactiveStyle: "block",
      convertEol: false,
      allowProposedApi: true
    });
    var nextTerm = term;
    pendingTerm = nextTerm;
    term.open(surface);
    attachWebglAddon(true);
    if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
    if (typeof replayData === 'string' && replayData.length > 0) {
      // Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output.
      enqueueWrite(ESC + '[0m' + replayData);
    }

    // Why: reset eviction tracking + attach observers for the new term.
    resetEvictionCounter();
    cancelSelect();
    attachTermObservers();
    attachTerminalQueryReplyBridge(term, gen);

    requestAnimationFrame(function() {
      if (gen !== terminalGeneration) return;
      ready = true;
      everReady = true;
      afterWritesDrained(function() {
        if (gen !== terminalGeneration) return;
        commitTerminalSurfaceSwap(surfaceSwap, nextTerm);
        // Why: restore the reader's place after the rewrapped buffer replays.
        // Replay lands at bottom, so only act when they were scrolled up (rows>0).
        if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) {
          try { term.scrollToLine(Math.max(0, (term.buffer.active.baseY || 0) - scrollAnchorRows)); } catch (e) {}
        }
        captureInitialOscLinkTexts();
        initialOscLinkRowOffset = 0;
        initialOscLinkEvictionReady = true;
        applyFitScale('init-replay');
        notify({ type: 'ready', cols: cols, rows: rows });
      });
    });
  }

  function write(data) {
    updateMouseModeFromData(data);
    enqueueWrite(data);
    pumpWrites(terminalGeneration);
    // Why: first live data chunk after init may widen the buffer past
    // what the post-replay applyFitScale measured. Re-fit once after this
    // chunk drains to catch the wider line. Subsequent chunks don't re-fit
    // (the user's manual zoom is sticky after that).
    if (firstDataPending) {
      firstDataPending = false;
      var gen = terminalGeneration;
      afterWritesDrained(function() {
        if (gen !== terminalGeneration) return;
        applyFitScale('first-data');
      });
    }
  }

  function resize(cols, rows) {
    if (!term) return;
    initRows = rows || initRows;
    term.resize(cols || term.cols, rows || term.rows);
    emitKeyboardAvoidanceMetrics();
    applyFitScale('resize-msg');
    notify({ type: 'ready', cols: cols, rows: rows });
  }

  // reflow(): see terminal-webview-reflow-injected.ts (extracted for max-lines).
  
  // Why: rewrap the local xterm buffer (scrollback included) to a new width
  // after a server PTY reflow. Skip the alternate screen: those snapshots are
  // fully repainted by the PTY and a local resize there can drop SGR attributes
  // (see init's alt-screen handling), which shows as white text.
  function reflow(cols, rows) {
    if (!term || isAlternateBufferActive()) return;
    var nextCols = cols || term.cols;
    var nextRows = rows || term.rows;
    if (nextCols === term.cols && nextRows === term.rows) return;
    var buffer = term.buffer.active;
    // Why: anchor reflow on whether the user was pinned to the live bottom so
    // their scroll position survives the rewrap — if they were scrolled up,
    // hold the same distance from the bottom; if at the bottom, stay there.
    var wasAtBottom = buffer.viewportY >= buffer.baseY;
    var distanceFromBottom = buffer.baseY - buffer.viewportY;
    initRows = nextRows;
    term.resize(nextCols, nextRows);
    var rewrapped = term.buffer.active;
    if (wasAtBottom) {
      term.scrollToBottom();
    } else {
      term.scrollLines(rewrapped.baseY - distanceFromBottom - rewrapped.viewportY);
    }
    applyFitScale('reflow-msg');
    updateScrollIndicator(false);
    emitKeyboardAvoidanceMetrics();
  }


  function notify(msg) {
    if (window.ReactNativeWebView) {
      window.ReactNativeWebView.postMessage(JSON.stringify(msg));
    }
  }

  function engineErrorText(err) {
    if (!err) return '';
    if (typeof err === 'string') return err;
    if (err && typeof err.message === 'string') return err.message;
    try { return String(err); } catch (e) { return ''; }
  }

  function chromeVersionText() {
    var match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\/([0-9.]+)/);
    return match ? 'Chrome ' + match[1] : 'Chrome version unknown';
  }

  var nonFatalErrorNotifies = 0;

  function reportEngineError(context, err, fatal) {
    var isFatal = fatal === undefined ? !everReady : !!fatal;
    if (!isFatal) {
      // Why: a constructed-but-degraded engine can throw per frame; cap
      // non-fatal notifies so RN isn't flooded. Fatal reports always emit.
      nonFatalErrorNotifies++;
      if (nonFatalErrorNotifies > 5) return;
    }
    var parts = [context];
    var errText = engineErrorText(err);
    if (errText) parts.push(errText);
    if (window.__engineErrors && window.__engineErrors.length) {
      parts.push('captured: ' + window.__engineErrors.join(' | '));
    }
    parts.push(chromeVersionText());
    notify({
      type: 'error',
      fatal: isFatal,
      message: parts.join(' - ')
    });
  }

  window.onerror = function(msg, source, line, column, err) {
    if (window.__engineErrors.length < 20) window.__engineErrors.push(String(msg));
    reportEngineError('terminal runtime error', err || msg);
  };

  function measureFitDimensions(containerHeightPx, retriesLeft) {
    if (typeof retriesLeft !== 'number') retriesLeft = 30;
    // Why: init and measure are posted back-to-back from React, but
    // init has an async rAF chain. A measure that runs synchronously
    // after init can find term null, disposed, lacking element, or
    // with cells size 0. Retry the whole gate for ~500ms.
    var notReady = !term || !term.element;
    var cellWidth = 0;
    var cellHeight = 0;
    if (!notReady) {
      var core = term._core;
      if (core && core._renderService && core._renderService.dimensions) {
        cellWidth = core._renderService.dimensions.css.cell.width;
        cellHeight = core._renderService.dimensions.css.cell.height;
      }
    }
    if (notReady || cellWidth <= 0 || cellHeight <= 0) {
      if (retriesLeft > 0) {
        requestAnimationFrame(function() {
          measureFitDimensions(containerHeightPx, retriesLeft - 1);
        });
        return;
      }
      flog('measure-fail', {
        notReady: notReady,
        cellWidth: cellWidth,
        cellHeight: cellHeight,
        retriesLeft: retriesLeft
      });
      notify({ type: 'measure-result', cols: null, rows: null });
      return;
    }
    var vpWidth = window.innerWidth;
    // Why: prefer the container height passed from React Native over
    // window.innerHeight. The RN layout system knows the exact pixel
    // height of the terminal frame after the accessory/input bars are
    // subtracted, whereas innerHeight can overstate the visible area
    // due to layout timing or safe-area insets.
    var vpHeight = (typeof containerHeightPx === 'number' && containerHeightPx > 0)
      ? containerHeightPx
      : window.innerHeight;
    var cols = Math.floor(vpWidth / cellWidth);
    if (cols < MIN_FIT_COLS) {
      flog('measure-skip-small-width', {
        vpWidth: vpWidth,
        cellWidth: cellWidth,
        cols: cols
      });
      notify({ type: 'measure-result', cols: null, rows: null });
      return;
    }
    // Why: the rows we report become the PTY's actual row count after the
    // server fits to viewport, and xterm renders exactly that many lines
    // anchored top-left of the WebView. Subtracting rows here would leave
    // dead xterm-background space at the bottom of the container and make
    // the last PTY rows visually appear above an "invisible line." Any
    // safety margin between the prompt and the accessory bar must come
    // from RN layout (terminalFrame's flex bounds), not from undersizing
    // the PTY.
    var rows = Math.max(8, Math.floor(vpHeight / cellHeight));
    notify({ type: 'measure-result', cols: cols, rows: rows });
  }

  function handleMsg(msg) {
    if (typeof msg.id === 'number') {
      if (handledMessageIds.indexOf(msg.id) !== -1) return;
      handledMessageIds.push(msg.id);
      if (handledMessageIds.length > 256) handledMessageIds.shift();
    }
    if (msg.type === 'ping') {
      notify({ type: 'pong', pingId: msg.id });
    } else if (msg.type === 'init') {
      init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme, msg.fontScale, msg.preserveScroll, msg.oscLinks);
    } else if (msg.type === 'set-font-scale') {
      // Why: ignore RN echoing back the value a pinch just set (msg.fontScale ===
      // currentTextScale) so the post-pinch state isn't reset; only apply changes.
      if (typeof msg.fontScale === 'number' && msg.fontScale > 0 && msg.fontScale !== currentTextScale) {
        userScale = 1;
        panX = 0;
        panY = 0;
        applyTextScale(msg.fontScale);
      }
    } else if (msg.type === 'resize') {
      resize(msg.cols, msg.rows);
    } else if (msg.type === 'reflow') { reflow(msg.cols, msg.rows);
    } else if (msg.type === 'write') {
      write(msg.data);
    } else if (msg.type === 'clear') {
      terminalGeneration++;
      resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary.
      statusDotPendingSelector = false;
      afterDrainCallbacks = [];
      writesDraining = false;
      mouseModeScanTail = '';
      trackedMouseTrackingMode = 'none';
      sgrMouseMode = false;
      sgrMousePixelsMode = false;
      initialOscLinks = [];
      initialOscLinkRowOffset = 0;
      initialOscLinkEvictionReady = false;
      if (term) { term.clear(); term.reset(); }
      emitModesIfChanged();
      emitKeyboardAvoidanceMetrics();
      resetEvictionCounter();
      if (selMode === 'select') {
        notify({ type: 'selection-evicted' });
        cancelSelect();
      }
    } else if (msg.type === 'measure') {
      measureFitDimensions(msg.containerHeight);
    } else if (msg.type === 'reset-zoom') {
      applyFitScale('reset-zoom-msg');
    } else if (msg.type === 'set-theme') {
      applyTerminalTheme(msg.terminalTheme);
    } else if (msg.type === 'cancel-select') {
      if (selMode === 'select') cancelSelect();
    } else if (msg.type === 'do-select-all') {
      if (term) {
        try {
          term.selectAll();
          var b = term.buffer.active;
          if (selMode !== 'select') {
            selMode = 'select';
            selectionOverlay.classList.add('active');
            notify({ type: 'set-select-mode', enabled: true });
          }
          sel = {
            anchor: { col: 0, row: 0 },
            focus: { col: term.cols - 1, row: b.length - 1 },
            activeHandle: null
          };
          repositionOverlay();
        } catch (e) {}
      }
    }
  }

  // ============================================================
  // SELECTION MODE (long-press → handles → Copy)
  // ============================================================
  var WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u;
  var LONG_PRESS_MS = 500;
  var LONG_PRESS_SLOP = 10;
  // Why: a tap that opens a link/path must survive small finger jitter. The
  // long-press slop (10px) only cancels the press-to-select timer; reusing it
  // to gate the tap dropped any URL/file tap that wandered >10px — at fit scale
  // a few screen px of jitter is a normal tap. Use a wider, time-bounded tap
  // window so deliberate scrolls/pans still don't fire a tap.
  var TAP_SLOP = 24;
  var TAP_MAX_MS = 700;
  var EDGE_SCROLL_PX = 40;
  var EDGE_SCROLL_INTERVAL = 60;

  var selectionOverlay = document.getElementById('selection-overlay');
  var handleStart = document.getElementById('sel-handle-start');
  var handleEnd = document.getElementById('sel-handle-end');
  var selMenu = document.getElementById('sel-menu');
  var btnCopy = document.getElementById('sel-menu-copy');
  var btnSelAll = document.getElementById('sel-menu-all');

  // mode: 'navigate' | 'select'
  var selMode = 'navigate';
  var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' }
  var longPressTimer = null;
  var longPressOrigin = null; // {x,y, identifier}
  // Why: tap detection is tracked separately from the long-press timer so a
  // small jitter that cancels the press-to-select timer does not also cancel
  // the tap (which opens links/paths). {x,y,t,identifier} or null once the
  // gesture is disqualified as a tap (moved too far or held too long).
  var tapCandidate = null;
  var edgeScrollTimer = null;
  var edgeScrollDir = 0;
  var edgeScrollClientX = 0;
  var edgeScrollClientY = 0;

  // Eviction watchdog: linesEverWritten counts onLineFeed since last init.
  // Once buffer is full, every onLineFeed evicts the top row in xterm and
  // we mirror that by decrementing stored absolute rows.
  var linesEverWritten = 0;

  function resetEvictionCounter() { linesEverWritten = 0; }

  function isBufferFull() {
    if (!term) return false;
    return linesEverWritten >= 5000 + (term.rows || 0);
  }

  function checkEviction() {
    if (selMode !== 'select' || !sel) return;
    var oldest = Math.min(sel.anchor.row, sel.focus.row);
    if (oldest < 0) {
      notify({ type: 'selection-evicted' });
      cancelSelect();
    }
  }

  function logFeedAndEvict() {
    linesEverWritten++;
    if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1;
    if (selMode === 'select' && sel && isBufferFull()) {
      sel.anchor.row -= 1;
      sel.focus.row -= 1;
      checkEviction();
      repositionOverlay();
    }
  }

  function emitModesIfChanged() {
    if (!term) return;
    var bp = !!(term.modes && term.modes.bracketedPasteMode);
    var alt = false;
    var mouseTrackingMode = getMouseTrackingMode();
    try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {}
    if (
      bp !== lastEmittedModes.bracketedPasteMode ||
      alt !== lastEmittedModes.altScreen ||
      mouseTrackingMode !== lastEmittedModes.mouseTrackingMode ||
      sgrMouseMode !== lastEmittedModes.sgrMouseMode ||
      sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode
    ) {
      lastEmittedModes = {
        bracketedPasteMode: bp,
        altScreen: alt,
        mouseTrackingMode: mouseTrackingMode,
        sgrMouseMode: sgrMouseMode,
        sgrMousePixelsMode: sgrMousePixelsMode
      };
      notify({
        type: 'modes',
        bracketedPasteMode: bp,
        altScreen: alt,
        mouseTrackingMode: mouseTrackingMode,
        sgrMouseMode: sgrMouseMode,
        sgrMousePixelsMode: sgrMousePixelsMode
      });
    }
  }
  var lastEmittedModes = {
    bracketedPasteMode: false,
    altScreen: false,
    mouseTrackingMode: 'none',
    sgrMouseMode: false,
    sgrMousePixelsMode: false
  };

  
  function lineHasVisibleContent(line, cell) {
    if (line.translateToString(true).trim().length > 0) return true;
    if (!cell || !line.getCell) return false;
    var limit = Math.min(term.cols || 0, line.length || 0);
    for (var x = 0; x < limit; x++) {
      var current = line.getCell(x, cell);
      if (!current) continue;
      if (!current.isBgDefault() || current.isInverse()) return true;
      if (typeof current.isUnderline === 'function' && current.isUnderline()) return true;
      if (typeof current.isStrikethrough === 'function' && current.isStrikethrough()) return true;
      if (typeof current.isOverline === 'function' && current.isOverline()) return true;
    }
    return false;
  }

  function computeContentBottomRow() {
    if (!term || !term.buffer || !term.buffer.active) return 0;
    var buffer = term.buffer.active;
    var top = buffer.viewportY || 0;
    var cell = buffer.getNullCell ? buffer.getNullCell() : null;
    for (var y = (term.rows || 0) - 1; y >= 0; y--) {
      try {
        var line = buffer.getLine(top + y);
        if (line && lineHasVisibleContent(line, cell)) return y;
      } catch (e) {}
    }
    return 0;
  }

  function emitKeyboardAvoidanceMetrics() {
    if (!term) return;
    var alt = false;
    try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {}
    notify({
      type: 'keyboard-avoidance-metrics',
      cursorY: term.buffer && term.buffer.active ? term.buffer.active.cursorY : 0,
      contentBottomRow: alt ? 0 : computeContentBottomRow(),
      rows: term.rows || 0,
      altScreen: alt
    });
  }


  function attachTermObservers() {
    if (!term) return;
    disposeTermObservers();
    try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {}
    try {
      termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); }));
    } catch (e) {}
    // Why: emit modes on every parsed write so RN's mirror stays current
    // without round-trip; covers \x1b[?2004h/l and alt-screen toggles.
    try {
      if (term.onWriteParsed) {
        termObserverDisposables.push(term.onWriteParsed(function() {
          emitModesIfChanged();
          emitKeyboardAvoidanceMetrics();
        }));
      }
    } catch (e) {}
    // Initial emit once buffer settles.
    afterWritesDrained(function() {
      emitModesIfChanged();
      emitKeyboardAvoidanceMetrics();
    });
  }

  function viewportToCell(clientX, clientY) {
    if (!term) return null;
    var cellW = getCellWidth();
    var cellH = getCellHeight();
    if (cellW <= 0 || cellH <= 0) return null;
    var total = getTotalScale();
    if (total <= 0) total = 1;
    var sx = (clientX - panX) / total;
    var sy = (clientY - panY) / total;
    var col = Math.floor(sx / cellW);
    var viewportRow = Math.floor(sy / cellH);
    if (col < 0) col = 0;
    if (col > term.cols - 1) col = term.cols - 1;
    if (viewportRow < 0) viewportRow = 0;
    if (viewportRow > term.rows - 1) viewportRow = term.rows - 1;
    var viewportY = term.buffer.active.viewportY;
    return { col: col, row: viewportRow + viewportY };
  }

  
  function viewportToMouseReportCell(clientX, clientY) {
    if (!term) return null;
    var cellW = getCellWidth();
    var cellH = getCellHeight();
    if (cellW <= 0 || cellH <= 0) return null;
    if (typeof clientX !== 'number') clientX = window.innerWidth / 2;
    if (typeof clientY !== 'number') clientY = window.innerHeight / 2;
    var total = getTotalScale();
    if (total <= 0) total = 1;
    var sx = (clientX - panX) / total;
    var sy = (clientY - panY) / total;
    var maxX = Math.max(0, term.cols * cellW - 1);
    var maxY = Math.max(0, term.rows * cellH - 1);
    if (sx < 0) sx = 0;
    if (sx > maxX) sx = maxX;
    if (sy < 0) sy = 0;
    if (sy > maxY) sy = maxY;
    var col = Math.floor(sx / cellW);
    var row = Math.floor(sy / cellH);
    if (col < 0) col = 0;
    if (col > term.cols - 1) col = term.cols - 1;
    if (row < 0) row = 0;
    if (row > term.rows - 1) row = term.rows - 1;
    return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) };
  }


  function isAlternateBufferActive() {
    try {
      return !!(term && term.buffer && term.buffer.active && term.buffer.active.type === 'alternate');
    } catch (e) {
      return false;
    }
  }

  function getMouseTrackingMode() {
    try {
      if (term && term.modes && typeof term.modes.mouseTrackingMode === 'string') {
        var mode = term.modes.mouseTrackingMode;
        if (mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any') return mode;
        return 'none';
      }
    } catch (e) {}
    if (
      trackedMouseTrackingMode === 'x10' ||
      trackedMouseTrackingMode === 'vt200' ||
      trackedMouseTrackingMode === 'drag' ||
      trackedMouseTrackingMode === 'any'
    ) {
      return trackedMouseTrackingMode;
    }
    return 'none';
  }

  function repeatSequence(sequence, count) {
    var out = '';
    for (var i = 0; i < count; i++) out += sequence;
    return out;
  }

  function buildArrowScrollSequence(lines) {
    var prefix = '[';
    try {
      if (term && term.modes && term.modes.applicationCursorKeysMode) prefix = 'O';
    } catch (e) {}
    return ESC + prefix + (lines < 0 ? 'A' : 'B');
  }

  function buildMouseWheelSequence(lines, clientX, clientY) {
    var cell = viewportToMouseReportCell(clientX, clientY);
    if (!cell) return '';
    var eventCode = lines < 0 ? 64 : 65;
    if (sgrMousePixelsMode) {
      if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return '';
      return ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M';
    }
    if (sgrMouseMode) {
      // Why: xterm increments zero-based mouse cells before encoding reports.
      var sgrCol = cell.col + 1;
      var sgrRow = cell.row + 1;
      if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return '';
      return ESC + '[<' + eventCode + ';' + sgrCol + ';' + sgrRow + 'M';
    }
    // Why: xterm increments zero-based mouse cells before encoding reports.
    var button = eventCode + 32;
    var col = cell.col + 1 + 32;
    var row = cell.row + 1 + 32;
    // Why: non-SGR mouse bytes above ASCII are not preserved reliably through
    // the mobile JSON/RPC string path. Fall back to keys for wide terminals.
    if (button > 126 || col > 126 || row > 126) return '';
    return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row);
  }

  function isSafeSgrMouseCoordinate(value) {
    return Number.isInteger(value) && value >= 0 && value <= 9999;
  }

  function buildMouseClickInput(clientX, clientY) {
    var mouseTrackingMode = getMouseTrackingMode();
    if (!isClickMouseTrackingMode(mouseTrackingMode)) return '';
    var cell = viewportToMouseReportCell(clientX, clientY);
    if (!cell) return '';
    if (sgrMousePixelsMode) {
      // Why: xterm 1016 keeps SGR syntax but reports raw zero-based pixel positions.
      var pixelX = cell.x;
      var pixelY = cell.y;
      if (!isSafeSgrMouseCoordinate(pixelX) || !isSafeSgrMouseCoordinate(pixelY)) return '';
      var pixelPress = ESC + '[<0;' + pixelX + ';' + pixelY + 'M';
      if (mouseTrackingMode === 'x10') return pixelPress;
      return pixelPress + ESC + '[<0;' + pixelX + ';' + pixelY + 'm';
    }
    if (sgrMouseMode) {
      // Why: xterm increments zero-based mouse cells before encoding reports.
      var sgrCol = cell.col + 1;
      var sgrRow = cell.row + 1;
      if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return '';
      var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M';
      if (mouseTrackingMode === 'x10') return sgrPress;
      return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm';
    }
    // Why: non-SGR click coordinates use printable ASCII bytes on the mobile
    // bridge; unsafe wide-terminal cells must not turn into corrupted input.
    var col = cell.col + 1 + 32;
    var row = cell.row + 1 + 32;
    if (col > 126 || row > 126) return '';
    var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row);
    if (mouseTrackingMode === 'x10') return press;
    return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row);
  }

  function isClickMouseTrackingMode(mode) {
    return mode !== 'none';
  }

  function isWheelMouseTrackingMode(mode) {
    return mode !== 'none' && mode !== 'x10';
  }

  function shouldRouteScrollToTerminalInput() {
    return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive();
  }

  function buildMouseWheelScrollInput(lines, clientX, clientY) {
    var count = Math.min(Math.abs(lines), 32);
    if (count === 0) return '';
    var sequence = buildMouseWheelSequence(lines, clientX, clientY);
    if (!sequence) return '';
    return repeatSequence(sequence, count);
  }

  function buildTuiScrollInput(lines, clientX, clientY) {
    var count = Math.min(Math.abs(lines), 32);
    if (count === 0) return '';
    var mouseTrackingMode = getMouseTrackingMode();
    var sequence = '';
    if (isWheelMouseTrackingMode(mouseTrackingMode)) {
      sequence = buildMouseWheelSequence(lines, clientX, clientY);
    }
    if (!sequence) sequence = buildArrowScrollSequence(lines);
    return repeatSequence(sequence, count);
  }

  function routeScrollLines(lines, clientX, clientY) {
    if (!term || lines === 0) return;
    var mouseTrackingMode = getMouseTrackingMode();
    var alternateBufferActive = isAlternateBufferActive();
    if (isWheelMouseTrackingMode(mouseTrackingMode)) {
      // Why: xterm sends wheel events to mouse-aware TUIs before considering
      // scrollback, even if the app stays on the normal buffer.
      var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY);
      if (mouseInput) {
        notify({ type: 'terminal-input', bytes: mouseInput });
        return;
      }
      // Why: default mouse encoding can be unrepresentable in our ASCII-safe
      // RPC path on wide terminals. Send bounded arrows instead of local
      // scrollback/no-op while a mouse-aware app owns scroll gestures.
      var fallbackInput = buildTuiScrollInput(lines, clientX, clientY);
      if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput });
      return;
    }
    if (alternateBufferActive) {
      // Why: alternate-screen TUIs own their scroll state and xterm has no
      // scrollback there, so mobile scroll gestures must become terminal input.
      var input = buildTuiScrollInput(lines, clientX, clientY);
      if (input) notify({ type: 'terminal-input', bytes: input });
      return;
    }
    term.scrollLines(lines);
  }

  function clampNormalScrollLines(lines) {
    if (!term || !term.buffer || !term.buffer.active || lines === 0) return 0;
    var buffer = term.buffer.active;
    if (lines > 0) {
      return Math.min(lines, Math.max(0, buffer.baseY - buffer.viewportY));
    }
    return Math.max(lines, -buffer.viewportY);
  }

  function canScrollNormalBufferDelta(deltaY) {
    if (!term || !term.buffer || !term.buffer.active || deltaY === 0) return false;
    var buffer = term.buffer.active;
    if (deltaY > 0) return buffer.viewportY < buffer.baseY;
    return buffer.viewportY > 0;
  }

  function applyNormalBufferScrollDelta(deltaY) {
    if (!term || deltaY === 0) return false;
    var effectiveCellH = getCellHeight() * getTotalScale();
    if (effectiveCellH <= 0) return false;
    if (!canScrollNormalBufferDelta(deltaY)) {
      resetSmoothScrollOffset();
      return false;
    }
    smoothScrollOffsetY -= deltaY;
    var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);
    if (lines !== 0) {
      var applied = clampNormalScrollLines(lines);
      if (applied !== 0) {
        term.scrollLines(applied);
        // Why: xterm's renderer is row-based. Buffer touch pixels and only
        // commit whole rows so TUI canvas layers do not shimmer between
        // fractional transforms and xterm repaints.
        smoothScrollOffsetY += applied * effectiveCellH;
      }
      if (applied !== lines) smoothScrollOffsetY = 0;
    }
    var limit = effectiveCellH - 1;
    if (smoothScrollOffsetY > limit) smoothScrollOffsetY = limit;
    if (smoothScrollOffsetY < -limit) smoothScrollOffsetY = -limit;
    updateScrollIndicator(true);
    return true;
  }

  function enqueueNormalBufferScrollDelta(deltaY) {
    if (!term || deltaY === 0) return false;
    if (!canScrollNormalBufferDelta(deltaY)) {
      resetSmoothScrollOffset();
      return false;
    }
    pendingNormalScrollDeltaY += deltaY;
    if (normalScrollFrameId !== null) return true;
    // Why: dense terminal rows are expensive to repaint. Coalesce touchmove
    // deltas into one xterm row-scroll per frame instead of repainting from
    // the input event stream.
    normalScrollFrameId = requestAnimationFrame(function() {
      normalScrollFrameId = null;
      var delta = pendingNormalScrollDeltaY;
      pendingNormalScrollDeltaY = 0;
      if (!applyNormalBufferScrollDelta(delta)) {
        resetSmoothScrollOffset();
      }
    });
    return true;
  }

  function resetSmoothScrollOffset() {
    pendingNormalScrollDeltaY = 0;
    if (normalScrollFrameId !== null) {
      cancelAnimationFrame(normalScrollFrameId);
      normalScrollFrameId = null;
    }
    if (smoothScrollOffsetY === 0) return;
    smoothScrollOffsetY = 0;
    updateScrollIndicator(false);
  }

  function cellToViewportPx(col, absRow) {
    if (!term) return { x: 0, y: 0 };
    var cellW = getCellWidth();
    var cellH = getCellHeight();
    var viewportRow = absRow - term.buffer.active.viewportY;
    var sx = col * cellW;
    var sy = viewportRow * cellH;
    var total = getTotalScale();
    return { x: sx * total + panX, y: sy * total + panY };
  }

  function getLineText(absRow) {
    if (!term) return '';
    var line = term.buffer.active.getLine(absRow);
    if (!line) return '';
    return line.translateToString(false);
  }

  // Why: getLineText collapses wide chars (emoji, CJK) to one string char, so a
  // tap's CELL column no longer equals the STRING index that url/path matchers use.
  // Convert by measuring the string length up to the tapped cell (the count of
  // string chars before it). Without this, taps on lines with a leading wide char
  // (e.g. agent output prefixed with ⏺) resolve to the wrong column and miss.
  function cellColToStringIndex(absRow, col) {
    if (!term) return col;
    var line = term.buffer.active.getLine(absRow);
    if (!line) return col;
    return line.translateToString(false, 0, col).length;
  }

  // File-path-under-tap detection (matchFilePathAtColumn). See
  // terminal-path-tap-injected.ts; mirrors the unit-tested terminal-path-tap.ts.
  
	  var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g;
	  var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g;
	  var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 };
	  var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 };

	  function parsePathLineCol(value) {
    var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value);
    if (!m) return null;
    var pathText = m[1];
    var last = pathText.charAt(pathText.length - 1);
    if (!pathText || last === '/' || last === '\\') return null;
    var line = m[2] ? parseInt(m[2], 10) : null;
    var column = m[3] ? parseInt(m[3], 10) : null;
    if ((line !== null && line < 1) || (column !== null && column < 1)) return null;
	    return { pathText: pathText, line: line, column: column };
	  }

	  function trimPathBoundaryPunctuation(raw, rawStart) {
	    var start = 0, end = raw.length;
	    while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1;
	    while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1;
	    if (start >= end) return null;
	    return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end };
	  }

	  function hasSeparatorAfterWhitespace(text) {
	    var sawWhitespace = false;
	    for (var i = 0; i < text.length; i++) {
	      var ch = text.charAt(i);
	      if (/\s/.test(ch)) { sawWhitespace = true; continue; }
	      if (sawWhitespace && (ch === '/' || ch === '\\')) return true;
	    }
	    return false;
	  }

	  function trimSpacedPathTrailingProse(range, col) {
	    // A line-end extension token only extends the span when the added segment
	    // is path-like (contains a separator) — prose must not be swallowed.
	    var selected = null;
	    var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g;
	    var match;
	    while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
	      var end = match.index + match[0].length;
	      var text = range.text.slice(0, end);
	      if (countPathStarts(text) > 1) continue;
	      if (end < range.text.length || selected === null || /[\\/]/.test(range.text.slice(selected.length, end))) {
	        selected = text;
	      }
	    }
	    if (selected) {
	      if (col !== undefined && col >= range.startIndex + selected.length) return null;
	      return { text: selected, startIndex: range.startIndex, endIndex: range.startIndex + selected.length };
	    }
	    var text = range.text.replace(/\s+$/, '');
	    return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length };
	  }

	  function countPathStarts(text) {
	    var count = 0;
	    var pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g;
	    while (pathStartPattern.exec(text) !== null) count += 1;
	    return count;
	  }

	  function hasSpacedPathExtension(text) {
	    var range = trimSpacedPathTrailingProse({ text: text, startIndex: 0, endIndex: text.length });
	    if (!range) return false;
	    var trimmed = range.text.replace(/\s+$/, '');
	    return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed);
	  }

	  function matchSpacedFilePathAtColumn(lineText, col) {
	    SPACED_PATH_RE.lastIndex = 0;
	    var match;
	    while ((match = SPACED_PATH_RE.exec(lineText)) !== null) {
	      var trimmed = trimPathBoundaryPunctuation(match[0], match.index);
	      if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue;
	      var candidate = trimSpacedPathTrailingProse(trimmed, col);
	      if (!candidate) continue;
	      if (col < candidate.startIndex || col >= candidate.endIndex) continue;
	      var parsed = parsePathLineCol(candidate.text);
	      if (parsed) return parsed;
	    }
	    return null;
	  }

	  function matchFilePathAtColumn(lineText, col) {
	    var spaced = matchSpacedFilePathAtColumn(lineText, col);
	    if (spaced) return spaced;
	    FILE_PATH_RE.lastIndex = 0;
    var match;
    while ((match = FILE_PATH_RE.exec(lineText)) !== null) {
      var raw = match[0];
      if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; }
	      var trimmed = trimPathBoundaryPunctuation(raw, match.index);
	      if (!trimmed) continue;
	      if (col < trimmed.startIndex || col >= trimmed.endIndex) continue;
	      var parsed = parsePathLineCol(trimmed.text);
	      if (parsed) return parsed;
    }
    return null;
  }

  // Returns the path candidate under the tap, or null. Query-only so the tap
  // handler can try file detection before forwarding a mouse click — which lets
  // file paths open even inside a mouse-tracking TUI. Relies on viewportToCell/
  // getLineText from the host script scope.
  function filePathAtViewportPoint(originX, originY) {
    var tapCell = viewportToCell(originX, originY);
    if (!tapCell) return null;
    // Map the cell column to a string index so wide chars (emoji/CJK) earlier on
    // the line don't shift the match column off the tapped path.
    return matchFilePathAtColumn(
      getLineText(tapCell.row),
      cellColToStringIndex(tapCell.row, tapCell.col)
    );
  }

  
  var URL_TAP_RE_SOURCE = "\\bhttps?:\\/\\/[^\\s\"'!*(){}|\\\\^<>`]*[^\\s\"':,.!?{}|\\\\^~[\\]`()<>]";
  var FILE_URL_TAP_RE_SOURCE = "\\bfile:\\/\\/[^\\s\"'!*(){}|\\\\^<>`]*[^\\s\"',!?{}|\\\\^~[\\]`()<>]";
  var URL_TAP_MAX_LENGTH = 2048;
  function findUrlAtColumn(lineText, col) {
    return findTerminalUrlAtColumn(lineText, col, URL_TAP_RE_SOURCE);
  }
  function findFileUrlAtColumn(lineText, col) {
    return findTerminalUrlAtColumn(lineText, col, FILE_URL_TAP_RE_SOURCE);
  }
  function findTerminalUrlAtColumn(lineText, col, source) {
    if (typeof lineText !== 'string' || lineText.length === 0) return null;
    var re = new RegExp(source, 'gi');
    var match;
    while ((match = re.exec(lineText)) !== null) {
      var end = match.index + match[0].length;
      if (match[0].length <= URL_TAP_MAX_LENGTH && col >= match.index && col < end) return match[0];
      if (match[0].length === 0) re.lastIndex++;
    }
    return null;
  }
  function fileUrlAtViewportPoint(clientX, clientY) {
    var cell = viewportToCell(clientX, clientY);
    if (!cell) return null;
    return findFileUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col));
  }
  function urlAtViewportPoint(clientX, clientY) {
    var cell = viewportToCell(clientX, clientY);
    if (!cell) return null;
    // Map the cell column to a string index so wide chars earlier on the line
    // don't shift the match column off the tapped URL.
    return findUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col));
  }

  // Why: OSC 8 links can render as labels like "#1234"; the URI lives in
  // xterm's internal link service, so every access is guarded and falls through.
  function oscLinkService() {
    try {
      var core = term && term._core;
      if (!core) return null;
      return core._oscLinkService
        || (core._inputHandler && core._inputHandler._oscLinkService)
        || null;
    } catch (e) { return null; }
  }
  function oscLinkAtViewportPoint(clientX, clientY) {
    try {
      var cell = viewportToCell(clientX, clientY);
      if (!cell) return null;
      var line = term.buffer.active.getLine(cell.row);
      if (!line) return null;
      var urlId = oscLinkIdAtCell(line, cell.col);
      if (!urlId) return initialOscLinkAtCell(cell.row, cell.col);
      var svc = oscLinkService();
      if (!svc || !svc.getLinkData) return initialOscLinkAtCell(cell.row, cell.col);
      var data = svc.getLinkData(urlId);
      var uri = data && data.uri;
      return terminalOscLinkTarget(uri);
    } catch (e) { return null; }
  }
  function initialOscLinkAtCell(row, col) {
    for (var i = 0; i < initialOscLinks.length; i++) {
      var link = initialOscLinks[i];
      if (!link || typeof link.uri !== 'string') continue;
      if (link.row < initialOscLinkRowOffset) continue;
      var shiftedRow = link.row - initialOscLinkRowOffset;
      if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return terminalOscLinkTarget(link.uri);
    }
    return null;
  }
  function terminalOscLinkTarget(uri) {
    if (typeof uri !== 'string') return null;
    if (/^https?:/i.test(uri)) return { kind: 'url', url: uri };
    var fileTap = resolveTerminalOscFileTap(uri);
    return fileTap ? { kind: 'file', fileTap: fileTap } : null;
  }
  function resolveTerminalOscFileTap(uri) {
    return resolveTerminalFileUrlTap(uri) || parseOscPathLikeTarget(uri);
  }
  function resolveTerminalFileUrlTap(uri) {
    var parsed;
    try {
      parsed = new URL(uri);
    } catch (e) {
      return null;
    }
    if (parsed.protocol !== 'file:') return null;
    var filePath;
    try {
      filePath = decodeURIComponent(parsed.pathname || '');
    } catch (e) {
      return null;
    }
    if (parsed.hostname && !isLocalFileUriHostname(parsed.hostname)) {
      filePath = '//' + parsed.hostname + filePath;
    } else if (/^\/[A-Za-z]:\//.test(filePath)) {
      filePath = filePath.slice(1);
    }
    if (!filePath) return null;
    var hashTarget = parseFileUrlLineHash(parsed.hash || '');
    if (hashTarget) {
      return { pathText: filePath, line: hashTarget.line, column: hashTarget.column };
    }
    if (/%3a/i.test(parsed.pathname || '')) {
      return { pathText: filePath, line: null, column: null };
    }
    return parseFilePathTrailingLineTarget(filePath) || { pathText: filePath, line: null, column: null };
  }
  function isLocalFileUriHostname(hostname) {
    var normalized = String(hostname).toLowerCase();
    return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]';
  }
	  function parseOscPathLikeTarget(value) {
	    if (!/^(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))/.test(value)) return null;
	    return parsePathLineCol(value);
	  }
  function parseFileUrlLineHash(hash) {
    var match = /^#?L(\d+)(?:C(\d+))?$/i.exec(hash);
    if (!match) return null;
    var line = parseInt(match[1], 10);
    var column = match[2] ? parseInt(match[2], 10) : null;
    if (line < 1 || (column !== null && column < 1)) return null;
    return { line: line, column: column };
  }
  function parseFilePathTrailingLineTarget(filePath) {
    var match = /^(.*?)(?::(\d+))(?::(\d+))?$/.exec(filePath);
    if (!match || !match[1] || match[1].charAt(match[1].length - 1) === '/' || match[1].charAt(match[1].length - 1) === '\\') return null;
    var line = parseInt(match[2], 10);
    var column = match[3] ? parseInt(match[3], 10) : null;
    if (line < 1 || (column !== null && column < 1)) return null;
    return { pathText: match[1], line: line, column: column };
  }
  function captureInitialOscLinkTexts() {
    if (!Array.isArray(initialOscLinks)) return;
    for (var i = 0; i < initialOscLinks.length; i++) {
      var link = initialOscLinks[i];
      if (!link || typeof link.text === 'string') continue;
      link.text = initialOscLinkTextAtRow(link, link.row);
    }
  }
  function initialOscLinkTextStillMatches(link, row) {
    if (typeof link.text !== 'string') return false;
    return link.text.length > 0 && initialOscLinkTextAtRow(link, row) === link.text;
  }
  function initialOscLinkTextAtRow(link, row) {
    try {
      var lineText = getLineText(row);
      var start = cellColToStringIndex(row, link.startCol);
      var end = cellColToStringIndex(row, link.endCol);
      return lineText.slice(start, end);
    } catch (e) {
      return '';
    }
  }
  function oscLinkIdAtCell(line, col) {
    try {
      var bufCell = line.getCell(col);
      return bufCell && bufCell.extended && bufCell.extended.urlId ? bufCell.extended.urlId : 0;
    } catch (e) { return 0; }
  }

  function notifyTerminalSurfaceTap(originX, originY, focusKeyboard) {
    var tappedOscLink = oscLinkAtViewportPoint(originX, originY);
    if (tappedOscLink && tappedOscLink.kind === 'file') {
      notify({
        type: 'terminal-file-tap',
        pathText: tappedOscLink.fileTap.pathText,
        line: tappedOscLink.fileTap.line,
        column: tappedOscLink.fileTap.column
      });
      return;
    }
    var tappedFileUrl = fileUrlAtViewportPoint(originX, originY);
    var tappedFileUrlPath = tappedFileUrl ? resolveTerminalFileUrlTap(tappedFileUrl) : null;
    if (tappedFileUrlPath) {
      notify({
        type: 'terminal-file-tap',
        pathText: tappedFileUrlPath.pathText,
        line: tappedFileUrlPath.line,
        column: tappedFileUrlPath.column
      });
      return;
    }
    var tappedUrl = tappedOscLink && tappedOscLink.kind === 'url' ? tappedOscLink.url : urlAtViewportPoint(originX, originY);
    if (tappedUrl) {
      notify({ type: 'open-url', url: tappedUrl });
      return;
    }
    var tappedPath = filePathAtViewportPoint(originX, originY);
    if (tappedPath) {
      notify({
        type: 'terminal-file-tap',
        pathText: tappedPath.pathText,
        line: tappedPath.line,
        column: tappedPath.column
      });
      return;
    }
    var clickInput = buildMouseClickInput(originX, originY);
    if (clickInput) {
      notify({ type: 'terminal-input', bytes: clickInput });
    }
    // Touch still needs native input focus after the TUI consumes its mouse click.
    if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) {
      notify({ type: 'terminal-tap' });
    }
  }


  function seedWordSelection(col, absRow) {
    var line = getLineText(absRow);
    if (!line) {
      sel = { anchor: { col: col, row: absRow }, focus: { col: col, row: absRow }, activeHandle: null };
      applyXtermSelection();
      return;
    }
    var s = col;
    var e = col;
    if (col >= 0 && col < line.length && WORD_RE.test(line[col])) {
      while (s > 0 && WORD_RE.test(line[s - 1])) s--;
      while (e < line.length - 1 && WORD_RE.test(line[e + 1])) e++;
    }
    sel = {
      anchor: { col: s, row: absRow },
      focus: { col: e, row: absRow },
      activeHandle: null
    };
    applyXtermSelection();
  }

  function isStartFirst(a, b) {
    if (a.row !== b.row) return a.row < b.row;
    return a.col <= b.col;
  }

  function selRange() {
    if (!sel) return null;
    if (isStartFirst(sel.anchor, sel.focus)) return { start: sel.anchor, end: sel.focus };
    return { start: sel.focus, end: sel.anchor };
  }

  function applyXtermSelection() {
    if (!term || !sel) return;
    var r = selRange();
    if (!r) return;
    // Why: term.select(col, row, length) takes a buffer-absolute row,
    // not a viewport-relative one. Subtracting viewportY here drifts the
    // selection by the scrollback height — handles render where the user
    // pressed (their math is independent), but xterm highlights an
    // off-screen scrollback region and copies the wrong text.
    var length;
    if (r.start.row === r.end.row) {
      length = Math.max(1, r.end.col - r.start.col + 1);
    } else {
      var first = term.cols - r.start.col;
      var middle = Math.max(0, r.end.row - r.start.row - 1) * term.cols;
      var last = r.end.col + 1;
      length = first + middle + last;
    }
    try { term.select(r.start.col, r.start.row, length); } catch (e) {}
  }

  function cancelSelect() {
    selMode = 'navigate';
    sel = null;
    stopEdgeScroll();
    if (term) {
      try { term.clearSelection(); } catch (e) {}
      // Why: some xterm renderers cache cells and skip repaint on
      // clearSelection alone, leaving the previously-highlighted cells
      // visually selected. Force a full refresh so the selection layer
      // actually clears on screen.
      try { term.refresh(0, term.rows - 1); } catch (e) {}
    }
    selectionOverlay.classList.remove('active');
    notify({ type: 'set-select-mode', enabled: false });
  }

  function enterSelect(col, absRow) {
    selMode = 'select';
    seedWordSelection(col, absRow);
    selectionOverlay.classList.add('active');
    notify({ type: 'set-select-mode', enabled: true });
    notify({ type: 'haptic', kind: 'selection' });
    repositionOverlay();
  }

  function repositionOverlay() {
    if (selMode !== 'select' || !sel || !term) return;
    var r = selRange();
    var sPx = cellToViewportPx(r.start.col, r.start.row);
    var ePx = cellToViewportPx(r.end.col + 1, r.end.row);
    var cellH = getCellHeight() * getTotalScale();
    // Why: native iOS pattern — start handle anchors at the TOP of the
    // first selected cell (dot above, stem covers the cell going down);
    // end handle anchors at the BOTTOM of the last selected cell (dot
    // below, stem covers the cell going up).
    handleStart.style.left = sPx.x + 'px';
    handleStart.style.top = sPx.y + 'px';
    handleEnd.style.left = ePx.x + 'px';
    handleEnd.style.top = (ePx.y + cellH) + 'px';
    var startVisible = sPx.y >= 0 && sPx.y <= window.innerHeight;
    var endVisible = ePx.y >= 0 && ePx.y <= window.innerHeight;
    handleStart.style.visibility = startVisible ? 'visible' : 'hidden';
    handleEnd.style.visibility = endVisible ? 'visible' : 'hidden';
    var menuCenterX, menuY, vTransform, marginTop;
    if (startVisible && sPx.y > 56) {
      menuCenterX = sPx.x; menuY = sPx.y;
      vTransform = 'translateY(-100%)';
      marginTop = '-12px';
    } else if (endVisible && ePx.y + cellH + 56 < window.innerHeight) {
      menuCenterX = ePx.x; menuY = ePx.y + cellH;
      vTransform = 'translateY(0)';
      marginTop = '12px';
    } else {
      // selection covers full viewport — pin to visible center
      menuCenterX = window.innerWidth / 2;
      menuY = window.innerHeight / 2;
      vTransform = 'translateY(-50%)';
      marginTop = '0';
    }
    // Why: clamp horizontally so the pill stays fully visible when the
    // selection sits near a screen edge. We position via plain left
    // (no horizontal translate) so the clamp math is straightforward.
    selMenu.style.transform = vTransform;
    selMenu.style.marginTop = marginTop;
    selMenu.style.top = menuY + 'px';
    selMenu.style.left = '0px';
    var EDGE_MARGIN = 8;
    var menuW = selMenu.offsetWidth || 0;
    var minLeft = EDGE_MARGIN;
    var maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - menuW - EDGE_MARGIN);
    var desiredLeft = menuCenterX - menuW / 2;
    var clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft));
    selMenu.style.left = clampedLeft + 'px';
  }

  function syncSelectionHandleToViewportPoint(handle, clientX, clientY) {
    var c = viewportToCell(clientX, clientY);
    if (!c || !sel) return false;
    if (handle === 'start') sel.anchor = c;
    else sel.focus = c;
    applyXtermSelection();
    return true;
  }

  function syncEdgeScrollSelectionEndpoint() {
    if (!sel || !sel.activeHandle) return false;
    // Why: WebView may not emit new touchmove events while a handle is held
    // at the edge; resample the stored finger point after each viewport scroll.
    return syncSelectionHandleToViewportPoint(
      sel.activeHandle,
      edgeScrollClientX,
      edgeScrollClientY
    );
  }

  function startEdgeScroll(dir) {
    if (edgeScrollDir === dir) return;
    stopEdgeScroll();
    edgeScrollDir = dir;
    edgeScrollTimer = setInterval(function() {
      if (!term || edgeScrollDir === 0) return;
      var beforeY = term.buffer.active.viewportY;
      term.scrollLines(edgeScrollDir);
      var afterY = term.buffer.active.viewportY;
      if (beforeY === afterY) {
        notify({ type: 'haptic', kind: 'edge-bump' });
        stopEdgeScroll();
        return;
      }
      syncEdgeScrollSelectionEndpoint();
      repositionOverlay();
    }, EDGE_SCROLL_INTERVAL);
  }

  function stopEdgeScroll() {
    if (edgeScrollTimer) {
      clearInterval(edgeScrollTimer);
      edgeScrollTimer = null;
    }
    edgeScrollDir = 0;
  }

  function handleDragMove(handle, clientX, clientY) {
    edgeScrollClientX = clientX;
    edgeScrollClientY = clientY;
    if (!syncSelectionHandleToViewportPoint(handle, clientX, clientY)) return;
    repositionOverlay();
    if (clientY < EDGE_SCROLL_PX) startEdgeScroll(-1);
    else if (clientY > window.innerHeight - EDGE_SCROLL_PX) startEdgeScroll(1);
    else stopEdgeScroll();
  }

  // Latching document-level touch dispatcher: see
  // terminal-webview-tap-dispatch-injected.ts (extracted for max-lines).
  
  // ============================================================
  // LATCHING TOUCH DISPATCHER (document-level)
  // ============================================================
  var dispatch = { mode: 'idle', touchId: null, touchIds: null, longPressFingerInsideOverlay: false };

  function touchById(touches, id) {
    for (var i = 0; i < touches.length; i++) {
      if (touches[i].identifier === id) return touches[i];
    }
    return null;
  }

  function targetInside(target, el) {
    if (!target || !el) return false;
    return el.contains(target);
  }

  function clearLongPress() {
    if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
    longPressOrigin = null;
  }

  function armLongPress(touch) {
    longPressOrigin = { x: touch.clientX, y: touch.clientY, identifier: touch.identifier };
    longPressTimer = setTimeout(function() {
      longPressTimer = null;
      if (!longPressOrigin) return;
      var c = viewportToCell(longPressOrigin.x, longPressOrigin.y);
      if (!c) return;
      enterSelect(c.col, c.row);
    }, LONG_PRESS_MS);
  }

  function touchSlopExceeded(t) {
    if (!longPressOrigin) return false;
    var dx = Math.abs(t.clientX - longPressOrigin.x);
    var dy = Math.abs(t.clientY - longPressOrigin.y);
    return (dx + dy) > LONG_PRESS_SLOP;
  }

  // Why: existing surface handlers stay attached to surface but we wrap
  // their entry to no-op when the dispatcher latches into select-drag.
  function dispatcherShouldBlockSurface() {
    return dispatch.mode === 'select-drag';
  }

  document.addEventListener('touchstart', function(e) {
    var t = e.touches[0];
    var target = e.target;
    var onHandle = target === handleStart || target === handleEnd;
    var inOverlay = targetInside(target, selectionOverlay);
    var inSurface = targetInside(target, surface);
    // Why: clear any stale tap candidate up front; only a fresh single-finger
    // surface touch (below) re-arms it, so handle drags / pinches / dismiss
    // taps never resolve as a link tap on touchend.
    tapCandidate = null;

    if (e.touches.length === 2) {
      // pinch latch
      if (selMode === 'select') {
        notify({ type: 'mobile-clip-cancel-by-pinch' });
        cancelSelect();
      }
      dispatch.mode = 'pinch';
      dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier];
      clearLongPress();
      return;
    }

    if (onHandle && selMode === 'select') {
      // start handle drag
      var handleName = (target === handleStart) ? 'start' : 'end';
      sel.activeHandle = handleName;
      dispatch.mode = 'select-drag';
      dispatch.touchId = t.identifier;
      e.preventDefault();
      return;
    }

    if (inOverlay) {
      // tap on menu pill — let the buttons' own handlers fire
      return;
    }

    if (inSurface && selMode === 'select') {
      // Why: tap-to-dismiss matches native iOS/Android — touching outside the
      // selection clears it. We cancel immediately and latch to 'surface' so
      // the same gesture still drives scroll/pan without a second touch.
      cancelSelect();
      dispatch.mode = 'surface';
      dispatch.touchId = t.identifier;
      return;
    }

    if (inSurface) {
      dispatch.mode = 'surface';
      dispatch.touchId = t.identifier;
      tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier };
      armLongPress(t);
    }
  }, { capture: true, passive: false });

  document.addEventListener('touchmove', function(e) {
    if (dispatch.mode === 'select-drag') {
      var t = touchById(e.touches, dispatch.touchId);
      if (!t || !sel || !sel.activeHandle) return;
      e.preventDefault();
      handleDragMove(sel.activeHandle, t.clientX, t.clientY);
      return;
    }
    if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') {
      // long-press slop check
      if (longPressTimer && e.touches.length === 1) {
        if (touchSlopExceeded(e.touches[0])) clearLongPress();
      }
      // Why: disqualify the tap only once the finger travels past TAP_SLOP
      // (a scroll/pan), independent of the long-press timer — so a tap that
      // jitters under TAP_SLOP still opens the link/path under the finger.
      if (tapCandidate && e.touches.length === 1) {
        var mt = e.touches[0];
        if (mt.identifier === tapCandidate.identifier) {
          var dx = Math.abs(mt.clientX - tapCandidate.x);
          var dy = Math.abs(mt.clientY - tapCandidate.y);
          if (dx + dy > TAP_SLOP) tapCandidate = null;
        }
      } else if (e.touches.length !== 1) {
        tapCandidate = null;
      }
      // existing surface handler will run from its own listener
    }
  }, { capture: true, passive: false });

  document.addEventListener('touchend', function(e) {
    if (dispatch.mode === 'select-drag') {
      if (sel) sel.activeHandle = null;
      stopEdgeScroll();
      dispatch.mode = 'idle';
      dispatch.touchId = null;
      return;
    }
    if (dispatch.mode === 'pinch') {
      if (e.touches.length < 2) {
        dispatch.mode = (e.touches.length === 1) ? 'surface' : 'idle';
        dispatch.touchIds = null;
        if (e.touches.length === 1) dispatch.touchId = e.touches[0].identifier;
      }
      return;
    }
    if (dispatch.mode === 'surface') {
      // Why: fire the tap from the tap-candidate origin (survives jitter under
      // TAP_SLOP) rather than longPressOrigin, which the press-to-select slop
      // can null mid-tap — that was dropping URL/file taps that moved a few px.
      if (
        e.touches.length === 0 &&
        tapCandidate &&
        selMode !== 'select' &&
        Date.now() - tapCandidate.t <= TAP_MAX_MS
      ) {
        notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true);
      }
      clearLongPress();
      tapCandidate = null;
      if (e.touches.length === 0) {
        dispatch.mode = 'idle';
        dispatch.touchId = null;
      }
    }
  }, { capture: true, passive: true });

  document.addEventListener('touchcancel', function() {
    clearLongPress();
    tapCandidate = null;
    stopEdgeScroll();
    if (dispatch.mode === 'select-drag') {
      if (sel) sel.activeHandle = null;
    }
    dispatch.mode = 'idle';
    dispatch.touchId = null;
    dispatch.touchIds = null;
  }, { capture: true, passive: true });


  // External mouse / trackpad scroll: see
  // terminal-webview-wheel-scroll-injected.ts (extracted for max-lines).
  
  var wheelAccumDeltaY = 0;

  function wheelEventPixelDeltaY(e) {
    var delta = e.deltaY;
    if (typeof delta !== 'number' || !isFinite(delta) || delta === 0) return 0;
    // DOM_DELTA_LINE / DOM_DELTA_PAGE: Android WebView reports line-mode deltas
    // for external mouse wheels, iOS trackpads report pixels.
    if (e.deltaMode === 1) return delta * getCellHeight() * getTotalScale();
    if (e.deltaMode === 2) return delta * window.innerHeight;
    return delta;
  }

  function attachSurfaceWheelHandler(targetSurface) {
    targetSurface.addEventListener('wheel', function(e) {
      if (dispatcherShouldBlockSurface()) return;
      if (!term) return;
      // Why: xterm's own wheel handler scrolls its hidden viewport or emits
      // cursor keys through onData, which the mobile query-reply gate drops.
      // Claim the event so indirect pointers share the touch scroll router.
      e.preventDefault();
      e.stopPropagation();

      // Why: a trackpad pinch arrives as ctrl+wheel. Swallow it rather than
      // firing cursor keys at the TUI; two-finger pinch still drives text size.
      if (e.ctrlKey) return;

      var deltaY = wheelEventPixelDeltaY(e);
      if (deltaY === 0) return;

      if (shouldRouteScrollToTerminalInput()) {
        resetSmoothScrollOffset();
        var effectiveCellH = getCellHeight() * getTotalScale();
        if (!(effectiveCellH > 0)) return;
        wheelAccumDeltaY += deltaY;
        var lines = Math.trunc(wheelAccumDeltaY / effectiveCellH);
        if (lines !== 0) {
          wheelAccumDeltaY -= lines * effectiveCellH;
          routeScrollLines(lines, e.clientX, e.clientY);
        }
        return;
      }
      wheelAccumDeltaY = 0;
      enqueueNormalBufferScrollDelta(deltaY);
    }, { capture: true, passive: false });
  }


  // External mouse click/drag: see
  // terminal-webview-mouse-click-drag-injected.ts (extracted for max-lines).
  
  var mouseGesture = null;

  // One report per transition, built with the same encoding ladder as
  // buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns ''
  // when the mode does not report this transition (x10 has no release, only
  // drag/any report motion) or the cell is not encodable.
  function buildMouseButtonReport(kind, clientX, clientY) {
    var mouseTrackingMode = getMouseTrackingMode();
    if (mouseTrackingMode === 'none') return '';
    if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') return '';
    if (kind === 'release' && mouseTrackingMode === 'x10') return '';
    var cell = viewportToMouseReportCell(clientX, clientY);
    if (!cell) return '';
    var sgrButton = kind === 'motion' ? 32 : 0;
    var sgrFinal = kind === 'release' ? 'm' : 'M';
    if (sgrMousePixelsMode) {
      if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return '';
      return ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal;
    }
    if (sgrMouseMode) {
      // Why: xterm increments zero-based mouse cells before encoding reports.
      var sgrCol = cell.col + 1;
      var sgrRow = cell.row + 1;
      if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return '';
      return ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal;
    }
    var button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32;
    var col = cell.col + 1 + 32;
    var row = cell.row + 1 + 32;
    // Why: non-SGR mouse bytes above ASCII are not preserved reliably through
    // the mobile JSON/RPC string path; drop instead of corrupting input.
    if (col > 126 || row > 126) return '';
    return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row);
  }

  function mouseReportCellKey(clientX, clientY) {
    var cell = viewportToMouseReportCell(clientX, clientY);
    return cell ? cell.col + ',' + cell.row : null;
  }

  function abandonMouseGesture() {
    var gesture = mouseGesture;
    mouseGesture = null;
    if (!gesture) return;
    if (gesture.mode === 'tracking') {
      // Why: the press report already went to the TUI; a lost pointer must not
      // leave the button latched down on the far side.
      var release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY);
      if (release) notify({ type: 'terminal-input', bytes: release });
    } else if (gesture.mode === 'selecting') {
      if (sel) sel.activeHandle = null;
      stopEdgeScroll();
    }
  }

  function beginMouseDrag(gesture) {
    gesture.moved = true;
    if (getMouseTrackingMode() !== 'none') {
      gesture.mode = 'tracking';
      gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY);
      var press = buildMouseButtonReport('press', gesture.startX, gesture.startY);
      if (press) notify({ type: 'terminal-input', bytes: press });
      return;
    }
    var anchor = viewportToCell(gesture.startX, gesture.startY);
    if (!anchor) {
      gesture.mode = 'cancelled';
      return;
    }
    // Why: mouse drags select character-anchored ranges like desktop terminals,
    // not the word-seeded long-press selection; reuse the touch handle-drag
    // plumbing (edge scroll included) by acting as a live 'end' handle.
    gesture.mode = 'selecting';
    selMode = 'select';
    sel = { anchor: anchor, focus: anchor, activeHandle: 'end' };
    selectionOverlay.classList.add('active');
    notify({ type: 'set-select-mode', enabled: true });
    applyXtermSelection();
    repositionOverlay();
  }

  function attachSurfaceMouseClickDragHandler(targetSurface) {
    targetSurface.addEventListener('pointerdown', function(e) {
      if (e.pointerType !== 'mouse' || e.button !== 0) return;
      if (dispatcherShouldBlockSurface() || !term) return;
      // Why: a pointerup lost outside the WebView must not leave the previous
      // gesture latched (tracking press with no release) when the next one lands.
      if (mouseGesture) abandonMouseGesture();
      // Why: mouse pointers have no implicit capture; without it a drag that
      // leaves the surface drops pointermove/pointerup and strands the gesture.
      try {
        if (targetSurface.setPointerCapture) targetSurface.setPointerCapture(e.pointerId);
      } catch (err) {}
      mouseGesture = {
        startX: e.clientX, startY: e.clientY,
        lastX: e.clientX, lastY: e.clientY,
        lastCellKey: null,
        moved: false,
        mode: 'pending',
        dismissedSelection: false
      };
      if (selMode === 'select') {
        // Why: touch parity — pressing outside the pill dismisses the current
        // selection; the same press may still start a new drag selection.
        cancelSelect();
        mouseGesture.dismissedSelection = true;
      }
    }, true);

    targetSurface.addEventListener('pointermove', function(e) {
      var gesture = mouseGesture;
      if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') return;
      if (!term) return;
      gesture.lastX = e.clientX;
      gesture.lastY = e.clientY;
      if ((e.buttons & 1) === 0) {
        // Why: a pointerup lost outside the WebView (capture unavailable) must
        // end the gesture here, or a tracked press stays latched at the TUI.
        // Coordinates first, so the synthesized release lands where the
        // pointer re-entered rather than at the previous cell.
        abandonMouseGesture();
        return;
      }
      if (!gesture.moved) {
        var dx = Math.abs(e.clientX - gesture.startX);
        var dy = Math.abs(e.clientY - gesture.startY);
        if (dx + dy <= TAP_SLOP) return;
        beginMouseDrag(gesture);
      }
      if (gesture.mode === 'tracking') {
        // Why: one motion report per cell keeps drags bounded by grid size, not
        // by pointermove cadence, so the RN rate limiter is never the bottleneck.
        var cellKey = mouseReportCellKey(e.clientX, e.clientY);
        if (cellKey && cellKey !== gesture.lastCellKey) {
          gesture.lastCellKey = cellKey;
          var motion = buildMouseButtonReport('motion', e.clientX, e.clientY);
          if (motion) notify({ type: 'terminal-input', bytes: motion });
        }
      } else if (gesture.mode === 'selecting') {
        handleDragMove('end', e.clientX, e.clientY);
      }
    }, true);

    targetSurface.addEventListener('pointerup', function(e) {
      var gesture = mouseGesture;
      if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) return;
      mouseGesture = null;
      if (gesture.mode === 'cancelled' || !term) return;
      if (gesture.mode === 'tracking') {
        var release = buildMouseButtonReport('release', e.clientX, e.clientY);
        if (release) notify({ type: 'terminal-input', bytes: release });
        return;
      }
      if (gesture.mode === 'selecting') {
        if (sel) sel.activeHandle = null;
        stopEdgeScroll();
        repositionOverlay();
        return;
      }
      if (dispatcherShouldBlockSurface()) return;
      // Why: a dismissing tap only clears the selection (touch parity); it must
      // not also open a link or focus the keyboard underneath.
      if (gesture.dismissedSelection) return;
      // Pointer clicks keep their current link, file, TUI mouse, and focus priority.
      notifyTerminalSurfaceTap(e.clientX, e.clientY, false);
    }, true);

    targetSurface.addEventListener('pointercancel', function(e) {
      if (e.pointerType !== 'mouse') return;
      abandonMouseGesture();
    }, true);

    // Why: Android input injection can pair a mouse-flavored pointerdown with
    // real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives,
    // the document touch dispatcher owns the gesture.
    targetSurface.addEventListener('touchstart', function() {
      if (mouseGesture) abandonMouseGesture();
    }, true);
  }


  btnCopy.addEventListener('click', function(e) {
    e.preventDefault();
    e.stopPropagation();
    if (!term) return;
    var text = term.getSelection ? term.getSelection() : '';
    if (text && text.length > 0) {
      notify({ type: 'selection', text: text });
    } else {
      cancelSelect();
    }
  });

  btnSelAll.addEventListener('click', function(e) {
    e.preventDefault();
    e.stopPropagation();
    if (!term) return;
    try {
      term.selectAll();
      var b = term.buffer.active;
      sel = {
        anchor: { col: 0, row: 0 },
        focus: { col: term.cols - 1, row: b.length - 1 },
        activeHandle: null
      };
      repositionOverlay();
    } catch (err) {}
  });

  var ts = {
    lastX: 0, lastY: 0, lastTime: 0, velY: 0,
    accumDelta: 0, momentumId: null, isPinching: false,
    pinchDist: 0, pinchScale: 0, pinchSurfX: 0, pinchSurfY: 0
  };

  function updateTouchVelocity(deltaY, dt) {
    if (dt <= 0) return;
    var instantVelocity = deltaY / dt;
    if (!isFinite(instantVelocity)) return;
    // Why: touchmove cadence is uneven in WebView. Blend recent samples so
    // momentum launch doesn't inherit a one-frame spike or stall.
    ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45;
  }

  function getDistance(a, b) {
    var dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
    return Math.sqrt(dx * dx + dy * dy);
  }

  function attachSurfaceEventHandlers(targetSurface) {
    if (!targetSurface || targetSurface.__orcaSurfaceHandlersAttached) return;
    targetSurface.__orcaSurfaceHandlersAttached = true;
    // Why: init() swaps in a new hidden surface to avoid flicker; each
    // replacement needs gesture handlers or tab-switch replays stop scrolling.
    targetSurface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
    targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true);

    attachSurfaceWheelHandler(targetSurface);
    attachSurfaceMouseClickDragHandler(targetSurface);

    targetSurface.addEventListener('touchstart', function(e) {
      if (dispatcherShouldBlockSurface()) return;
      if (ts.momentumId) {
        cancelAnimationFrame(ts.momentumId);
        ts.momentumId = null;
      }
      if (e.touches.length === 2) {
        ts.isPinching = true;
        smoothScrollOffsetY = 0;
        ts.pinchDist = getDistance(e.touches[0], e.touches[1]);
        ts.pinchScale = userScale;
        var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
        var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
        var total = getTotalScale();
        ts.pinchSurfX = (mx - panX) / total;
        ts.pinchSurfY = (my - panY) / total;
      } else if (e.touches.length === 1) {
        ts.isPinching = false;
        ts.lastX = e.touches[0].clientX;
        ts.lastY = e.touches[0].clientY;
        ts.lastTime = Date.now();
        ts.velY = 0;
        ts.accumDelta = 0;
      }
    }, { capture: true, passive: true });

    targetSurface.addEventListener('touchmove', function(e) {
      if (dispatcherShouldBlockSurface()) return;
      if (!term) return;
      e.preventDefault();
      e.stopPropagation();

      if (e.touches.length === 2) {
        ts.isPinching = true;
        var dist = getDistance(e.touches[0], e.touches[1]);
        var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
        var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;

        var ratio = dist / ts.pinchDist;
        // Why: userScale is a CSS multiplier on the current font size; bound it so
        // the resulting apparent size (currentTextScale × userScale) stays within
        // the preset range, since release snaps to one of those presets.
        var loScale = MIN_TEXT_SCALE / currentTextScale;
        var hiScale = MAX_TEXT_SCALE / currentTextScale;
        userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio));

        var total = getTotalScale();
        panX = mx - ts.pinchSurfX * total;
        panY = my - ts.pinchSurfY * total;
        clampPan();
        updateTransform();

      } else if (e.touches.length === 1 && !ts.isPinching) {
        var x = e.touches[0].clientX, y = e.touches[0].clientY;
        var now = Date.now(), dt = now - ts.lastTime;

        // Why: pan horizontally only when content overflows the viewport (larger
        // than fit) — same check clampPan() uses. Vertical always drives buffer
        // scroll so scrollback stays reachable at any text size; calling the
        // never-defined contentWiderThanViewport() here threw and killed all
        // single-finger scrolling, scrollback included.
        if (term.element && term.element.scrollWidth * getTotalScale() > window.innerWidth + 1) {
          panX += x - ts.lastX;
          clampPan();
          updateTransform();
        }

        var deltaY = ts.lastY - y;
        ts.lastTime = now;
        if (shouldRouteScrollToTerminalInput()) {
          updateTouchVelocity(deltaY, dt);
          resetSmoothScrollOffset();
          var effectiveCellH = getCellHeight() * getTotalScale();
          ts.accumDelta += deltaY;
          var lines = Math.trunc(ts.accumDelta / effectiveCellH);
          if (lines !== 0) {
            ts.accumDelta -= lines * effectiveCellH;
            routeScrollLines(lines, x, y);
          }
        } else {
          if (enqueueNormalBufferScrollDelta(deltaY)) {
            updateTouchVelocity(deltaY, dt);
          } else {
            ts.velY = 0;
          }
        }
        ts.lastX = x;
        ts.lastY = y;
      }
    }, { capture: true, passive: false });

    targetSurface.addEventListener('touchend', function(e) {
      if (dispatcherShouldBlockSurface()) return;
      if (!term) return;

      if (ts.isPinching && e.touches.length < 2) {
        ts.isPinching = false;
        // Why: a finished pinch snaps to the nearest preset and becomes the new
        // font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way
        // to set the text size. The CSS pinch zoom (userScale) is reset; the real
        // size change reflows columns and RN persists + resizes the PTY to match.
        var target = snapToTextScalePreset(currentTextScale * userScale);
        var changed = target !== currentTextScale;
        userScale = 1;
        panX = 0; panY = 0;
        applyTextScale(target);
        updateTransform();
        notify({ type: 'font-scale-changed', fontScale: target });
        if (changed) notify({ type: 'haptic', kind: 'selection' });
        if (e.touches.length === 1) {
          ts.lastX = e.touches[0].clientX;
          ts.lastY = e.touches[0].clientY;
          ts.lastTime = Date.now();
          ts.velY = 0;
          ts.accumDelta = 0;
        }
        return;
      }

      if (e.touches.length === 0) {
        var vel = ts.velY;
        var FRICTION = 0.972;
        var MIN_VEL = 0.012;
        function momentumStep() {
          vel *= FRICTION;
          if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; }
          var delta = vel * 16;
          if (shouldRouteScrollToTerminalInput()) {
            resetSmoothScrollOffset();
            var effectiveCellH = getCellHeight() * getTotalScale();
            ts.accumDelta += delta;
            var lines = Math.trunc(ts.accumDelta / effectiveCellH);
            if (lines !== 0) {
              ts.accumDelta -= lines * effectiveCellH;
              routeScrollLines(lines, ts.lastX, ts.lastY);
            }
          } else {
            if (!applyNormalBufferScrollDelta(delta)) {
              ts.momentumId = null;
              return;
            }
          }
          ts.momentumId = requestAnimationFrame(momentumStep);
        }
        if (Math.abs(vel) > MIN_VEL) {
          ts.momentumId = requestAnimationFrame(momentumStep);
        }
      }
    }, { capture: true, passive: true });
  }

  attachSurfaceEventHandlers(surface);

  function handleIncomingMessage(e) {
    var msg;
    try {
      msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
    } catch (ex) {
      return;
    }
    try {
      handleMsg(msg);
    } catch(ex) {
      reportEngineError(
        msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed',
        ex,
        msg && msg.type === 'init' && !everReady
      );
    }
  }

  window.addEventListener('message', handleIncomingMessage);

  document.addEventListener('message', handleIncomingMessage);

  window.addEventListener('resize', function() {
    // Why: viewport changed (keyboard open/close, orientation, RN container
    // size update). Re-fit so the scale matches the new vpWidth — without
    // this, opening the keyboard leaves the terminal at the old scale even
    // though there's now less vertical room and the fit ratio may differ.
    applyFitScale('window-resize');
    adjustRowsForViewport();
    repositionOverlay();
    clampPan();
    updateTransform();
  });

  if (window.Terminal) {
    notify({ type: 'web-ready' });
  } else {
    reportEngineError('terminal engine missing', 'xterm failed to load', true);
  }
})();
