import type { ComputedRef, Ref } from "vue";
import type { Line } from "../../../ui/chat/layout-model";
import { appendFileSync } from "node:fs";
import { focusFileDisplayLabel } from "#cli/core/focus-files";
import {
  type DomRenderer,
  applyWheelScroll,
  createWheelScrollState,
  wrapByCells,
} from "@simon_he/vue-tui";
import { computed, getCurrentScope, nextTick, onScopeDispose, ref, watch } from "vue";
import { clamp } from "../../utils/math";

const MENTION_TOKEN = "\uFFF9";
type WheelDeltaMode = "auto" | "line" | "pixel";

// Header renders 1 content row (summary + stats/version) with a 1-row top padding,
// with a 1-row gap below (used for the separator).
export const CHAT_HEADER_H = 2;
export const CHAT_HEADER_GAP = 1;

function mentionLabel(absPath: string, index: number): string {
  const p = String(absPath || "");
  if (!p) return "[file]";
  return `[${focusFileDisplayLabel(p, index)}]`;
}

export function countWrappedLines(
  text: string,
  width: number,
  mentions: readonly string[] = [],
): number {
  if (width <= 0) return 1;
  let mentionIndex = 0;
  let expanded = "";
  for (const ch of String(text)) {
    if (ch === MENTION_TOKEN) {
      expanded += mentionLabel(mentions[mentionIndex] ?? "", mentionIndex);
      mentionIndex++;
      continue;
    }
    expanded += ch;
  }

  return wrapByCells(expanded, width).length || 1;
}

export function useChatLayout(
  opts: Readonly<{
    cols: ComputedRef<number>;
    rows: ComputedRef<number>;
    input: Ref<string>;
    mentions: Ref<readonly string[]>;
    hasRevertedMessages: ComputedRef<boolean>;
    bottomOverlayH?: ComputedRef<number>;
    panelContentRows?: ComputedRef<number | null | undefined>;
    footerReserveRows?: ComputedRef<number | null | undefined>;
  }>,
): Readonly<{
  contentW: ComputedRef<number>;
  contentH: ComputedRef<number>;
  mainW: ComputedRef<number>;
  mainH: ComputedRef<number>;
  mainY: number;
  panelW: ComputedRef<number>;
  panelX: ComputedRef<number>;
  panelTopY: ComputedRef<number>;
  panelH: ComputedRef<number>;
  inputX: ComputedRef<number>;
  inputInnerW: ComputedRef<number>;
  inputLines: ComputedRef<number>;
  modelRowGap: ComputedRef<number>;
  inputWrapWidth: ComputedRef<number>;
  footerH: number;
  contentPadX: number;
}> {
  // Full-screen layout (no outer border)
  const contentW = computed(() => Math.max(0, opts.cols.value ?? 0));
  const contentH = computed(() => Math.max(0, opts.rows.value ?? 0));

  // Simpler layout: main area takes most space, no side panel by default
  const mainW = computed(() => Math.max(0, contentW.value));

  // Top: header has a bit of breathing room; keep a separator row below it.
  const headerH = CHAT_HEADER_H;
  const headerGap = CHAT_HEADER_GAP;
  const topH = headerH + headerGap;
  // Bottom: panel area (input + model row) - footer is outside panel
  const contentPadX = 1;
  const minInputLines = 2;
  // Keep the input compact; preserve a 1-row "breathing" space either as bottom padding
  // (when below max) or as a gap above the mode/model row (when clamped at max).
  const maxInputLines = 4;
  const modelRowH = 1;
  const panelBorderH = 2; // 1 for top border + 1 for bottom border
  const footerH = 2; // footer row + 1 row bottom spacing

  const inputWrapWidth = computed(() => {
    // Use inputInnerW equivalent for width calculation (accounts for borders and padding)
    // panelW = mainW - 4, inputInnerW = panelW - 3 - contentPadX * 2
    return Math.max(1, Math.max(0, mainW.value - 4) - 3 - contentPadX * 2);
  });

  const wrappedLines = computed(() =>
    countWrappedLines(opts.input.value, inputWrapWidth.value, opts.mentions.value),
  );
  const inputLines = computed(() => {
    return clamp(wrappedLines.value + 1, minInputLines, maxInputLines);
  });
  // When the input hits its max height, the extra "breathing" line gets clamped away.
  // In that case, add a 1-row gap above the mode/model row instead.
  const modelRowGap = computed(() => (wrappedLines.value + 1 > maxInputLines ? 1 : 0));

  // Spacing between messages and the input panel:
  // - Always leave 1 row so the UI breathes a bit.
  // - When reverted messages banner is shown (2 rows), reserve space for it plus an extra gap.
  const panelSpacing = computed(() => (opts.hasRevertedMessages.value ? 3 : 1));
  const basePanelH = computed(() => inputLines.value + modelRowGap.value + modelRowH); // input + (optional) gap + model row
  const panelH = computed(() => {
    const forced = Number(opts.panelContentRows?.value ?? 0);
    const forcedRows = Number.isFinite(forced) ? Math.max(0, Math.floor(forced)) : 0;
    return Math.max(basePanelH.value, forcedRows);
  });
  const panelTotalH = computed(() => panelH.value + panelBorderH); // total height including borders
  const overlayH = computed(() => Math.max(0, Math.floor(opts.bottomOverlayH?.value ?? 0)));
  const bottomReserveH = computed(() => Math.max(panelTotalH.value, overlayH.value));
  const footerReserveH = computed(() => {
    const raw = Number(opts.footerReserveRows?.value ?? footerH);
    if (!Number.isFinite(raw)) return footerH;
    return Math.max(0, Math.floor(raw));
  });
  const mainH = computed(() =>
    Math.max(
      0,
      contentH.value - topH - bottomReserveH.value - footerReserveH.value - panelSpacing.value,
    ),
  );
  const mainY = topH;
  const panelTopY = computed(() => topH + mainH.value + panelSpacing.value + 1); // +1 for top border

  // Panel layout - aligned with message area (starts at x=2)
  const panelW = computed(() => Math.max(0, mainW.value - 4)); // same width as message area
  const panelX = computed(() => 2); // align with message area
  const inputX = computed(() => panelX.value + 1 + contentPadX); // +1 for left border
  const inputInnerW = computed(() => Math.max(1, panelW.value - 3 - contentPadX * 2)); // -3 for borders + right-side breathing room

  return {
    contentW,
    contentH,
    mainW,
    mainH,
    mainY,
    panelW,
    panelX,
    panelTopY,
    panelH,
    inputX,
    inputInnerW,
    inputLines,
    modelRowGap,
    inputWrapWidth,
    footerH,
    contentPadX,
  };
}

export function useChatScroll(
  opts: Readonly<{
    lineCount?: ComputedRef<number>;
    getLines?: (start: number, end: number) => readonly Line[];
    lines?: ComputedRef<readonly Line[]>;
    mainY?: number;
    mainH: ComputedRef<number>;
    scheduler?: { flushNow: () => void };
    renderer?: Ref<DomRenderer | null>;
  }>,
): Readonly<{
  scrollTop: Ref<number>;
  stickToBottom: Ref<boolean>;
  maxScroll: ComputedRef<number>;
  visibleLines: ComputedRef<readonly Line[]>;
  visibleStartRow: ComputedRef<number>;
  onWheel: (e: any) => void;
}> {
  const scrollTop = ref(0);
  const stickToBottom = ref(true);
  const lineCountRef = opts.lineCount ?? computed(() => opts.lines?.value.length ?? 0);
  const getLines =
    opts.getLines ?? ((start: number, end: number) => (opts.lines?.value ?? []).slice(start, end));
  const env = (globalThis as any).process?.env ?? {};

  function readScrollEnvNumber(
    dimKey: string,
    fallback: number,
    min: number,
    max: number,
    integer = false,
  ): number {
    const raw = String(env[dimKey] ?? "").trim();
    if (!raw) return fallback;
    const n = Number(raw);
    if (!Number.isFinite(n)) return fallback;
    const bounded = Math.max(min, Math.min(max, n));
    return integer ? Math.floor(bounded) : bounded;
  }

  function readScrollEnvOptionalNumber(
    dimKey: string,
    min: number,
    max: number,
    integer = false,
  ): number | null {
    const raw = String(env[dimKey] ?? "").trim();
    if (!raw) return null;
    const n = Number(raw);
    if (!Number.isFinite(n)) return null;
    const bounded = Math.max(min, Math.min(max, n));
    return integer ? Math.floor(bounded) : bounded;
  }

  function hasLiveDomRenderer(): boolean {
    return Boolean(opts.renderer?.value);
  }

  const NEAR_BOTTOM_FOLLOW_LINES = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_NEAR_BOTTOM_LINES",
    2,
    0,
    100,
    true,
  );
  const WHEEL_BATCH_WINDOW_MS = readScrollEnvOptionalNumber(
    "DIMCODE_CHAT_SCROLL_WHEEL_BATCH_MS",
    0,
    200,
    true,
  );
  const IMMEDIATE_WHEEL_DELTA_THRESHOLD = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_WHEEL_IMMEDIATE_DELTA",
    20,
    0,
    100_000,
    false,
  );
  const GHOSTTY_OSCILLATION_WINDOW_MS = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_GHOSTTY_OSCILLATION_MS",
    400,
    0,
    1000,
    true,
  );
  const GHOSTTY_OSCILLATION_DAMPING_LINES = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_GHOSTTY_OSCILLATION_DAMPING_LINES",
    0.45,
    0,
    10,
    false,
  );
  const GHOSTTY_OSCILLATION_COMMIT_LINES = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_GHOSTTY_OSCILLATION_COMMIT_LINES",
    0.8,
    0.1,
    4,
    false,
  );
  const GHOSTTY_IDLE_RESET_MS = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_GHOSTTY_IDLE_RESET_MS",
    90,
    0,
    2000,
    true,
  );
  const GHOSTTY_DIRECTION_CONFIRM_MS = Math.min(24, GHOSTTY_OSCILLATION_WINDOW_MS);
  const GHOSTTY_EDGE_BLOCK_WINDOW_MS = 120;
  const GHOSTTY_EDGE_HARD_BLOCK_COUNT = 6;
  const GHOSTTY_EDGE_ESCAPE_LINES = 4;
  const GHOSTTY_EDGE_ESCAPE_WINDOW_MS = 80;
  const GHOSTTY_EDGE_ARRIVAL_WINDOW_MS = 140;
  const GHOSTTY_EDGE_EXIT_CONFIRM_WINDOW_MS = 80;
  const SOFT_LIMIT_START_LINES = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_WHEEL_SOFT_LIMIT_START_LINES",
    24,
    0,
    100,
    false,
  );
  const SOFT_LIMIT_POWER = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_WHEEL_SOFT_LIMIT_POWER",
    1,
    0.1,
    3,
    false,
  );
  const PIXELS_PER_LINE = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_WHEEL_PIXELS_PER_LINE",
    0.75,
    1,
    2000,
    false,
  );
  const LINE_UNIT_MULTIPLIER = readScrollEnvOptionalNumber(
    "DIMCODE_CHAT_SCROLL_WHEEL_LINE_UNIT_MULTIPLIER",
    1,
    1000,
    false,
  );
  const LINE_UNIT_MAX_LINES = readScrollEnvOptionalNumber(
    "DIMCODE_CHAT_SCROLL_WHEEL_LINE_UNIT_MAX_LINES",
    1,
    1000,
    false,
  );
  const LINE_UNIT_MAX_ACCEL = readScrollEnvOptionalNumber(
    "DIMCODE_CHAT_SCROLL_WHEEL_LINE_UNIT_MAX_ACCEL",
    1,
    100,
    false,
  );
  const LINE_UNIT_ACCEL_WINDOW_MS = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_WHEEL_LINE_UNIT_ACCEL_WINDOW_MS",
    160,
    0,
    2000,
    true,
  );
  const WHEEL_EASE_OUT_INTERVAL_MS = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_WHEEL_EASE_OUT_INTERVAL_MS",
    16,
    0,
    200,
    true,
  );
  const WHEEL_EASE_OUT_FACTOR = readScrollEnvOptionalNumber(
    "DIMCODE_CHAT_SCROLL_WHEEL_EASE_OUT_FACTOR",
    0,
    1,
    false,
  );
  const WHEEL_EASE_OUT_DECAY = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_WHEEL_EASE_OUT_DECAY",
    0.82,
    0.05,
    0.99,
    false,
  );
  const WHEEL_EASE_OUT_MIN_LINES = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_WHEEL_EASE_OUT_MIN_LINES",
    0.18,
    0.01,
    20,
    false,
  );
  const DOM_SMOOTH_SCROLL_ENABLED =
    readScrollEnvNumber("DIMCODE_CHAT_SCROLL_DOM_SMOOTH", 1, 0, 1, true) === 1;
  const DOM_SMOOTH_SCROLL_RESPONSE = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_DOM_SMOOTH_RESPONSE",
    20,
    1,
    120,
    false,
  );
  const DOM_SMOOTH_SCROLL_EPSILON = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_DOM_SMOOTH_EPSILON",
    0.01,
    0.001,
    1,
    false,
  );
  const DOM_SMOOTH_SCROLL_OVERSCAN_ROWS = readScrollEnvNumber(
    "DIMCODE_CHAT_SCROLL_DOM_SMOOTH_OVERSCAN_ROWS",
    1,
    0,
    4,
    true,
  );
  const LINE_UNIT_DELTA_THRESHOLD = 3;

  const maxScroll = computed(() => Math.max(0, lineCountRef.value - opts.mainH.value));

  // Detect if running in ghostty for special handling
  const isGhostty = "GHOSTTY_RESOURCES_DIR" in ((globalThis as any).process?.env ?? {});

  const wheelState = createWheelScrollState();
  let ghosttyStableDir = 0;
  let lastAppliedDir = 0;
  let lastAppliedAt = 0;
  let lastWheelInputAt = 0;
  let ghosttyOppositeCarry = 0;
  let ghosttyPendingDir = 0;
  let ghosttyPendingAt = 0;
  let ghosttyEdgeBlockDir = 0;
  let ghosttyEdgeBlockCount = 0;
  let ghosttyEdgeBlockAt = 0;
  let ghosttyEdgeArrivalDir = 0;
  let ghosttyEdgeArrivalAt = 0;
  let ghosttyEdgeArrivalExitDir = 0;
  let ghosttyEdgeArrivalExitAt = 0;
  let ghosttyEdgeExitDir = 0;
  let ghosttyEdgeExitAt = 0;
  let ghosttyEdgeEscapeDir = 0;
  let ghosttyEdgeEscapeAt = 0;
  let pendingWheelDelta = 0;
  let pendingWheelMode: WheelDeltaMode | null = null;
  let lastWheelFlushAt = 0;
  let wheelFlushTimer: ReturnType<typeof setTimeout> | null = null;
  let easeWheelDelta = 0;
  let easeWheelMode: WheelDeltaMode | null = null;
  let wheelEaseTimer: ReturnType<typeof setTimeout> | null = null;
  let domSmoothVisualTop = 0;
  let domSmoothTargetTop = 0;
  let domSmoothFrameHandle: number | ReturnType<typeof setTimeout> | null = null;
  let domSmoothFrameIsTimer = false;
  let domSmoothLastAt = 0;
  let domSmoothSuppressScrollWatch = false;
  const domSmoothOverscanActive = ref(false);

  watch(
    maxScroll,
    (nextMax, prevMax) => {
      if (stickToBottom.value) {
        scrollTop.value = nextMax;
        return;
      }

      const prev = typeof prevMax === "number" && Number.isFinite(prevMax) ? prevMax : nextMax;
      const wasNearBottom = prev > 0 && prev - scrollTop.value <= NEAR_BOTTOM_FOLLOW_LINES;
      if (nextMax > prev && wasNearBottom) {
        stickToBottom.value = true;
        scrollTop.value = nextMax;
        return;
      }

      scrollTop.value = clamp(scrollTop.value, 0, nextMax);
    },
    { immediate: true },
  );

  watch(
    maxScroll,
    (nextMax) => {
      domSmoothTargetTop = clamp(domSmoothTargetTop, 0, nextMax);
      domSmoothVisualTop = clamp(domSmoothVisualTop, 0, nextMax);
      if (hasDomSmoothScroll() && scrollTop.value === nextMax) syncDomSmoothState(nextMax);
    },
    { immediate: true },
  );

  watch(
    scrollTop,
    (next) => {
      if (domSmoothSuppressScrollWatch) return;
      if (!hasDomSmoothScroll()) {
        setTranscriptPlaneOffset(0);
        return;
      }
      syncDomSmoothState(next);
    },
    { flush: "sync" },
  );

  watch(
    () => opts.renderer?.value,
    () => {
      setTranscriptPlaneViewport();
      if (!hasDomSmoothScroll()) {
        setTranscriptPlaneOffset(0);
        return;
      }
      setTranscriptPlaneOffset(domSmoothVisualTop - scrollTop.value);
    },
    { immediate: true },
  );

  watch(opts.mainH, () => {
    setTranscriptPlaneViewport();
  });

  function applySoftLimitDelta(delta: number): number {
    if (!Number.isFinite(delta) || delta === 0) return 0;
    const lines = delta / PIXELS_PER_LINE;
    const abs = Math.abs(lines);
    if (abs <= SOFT_LIMIT_START_LINES) return delta;
    const softened = SOFT_LIMIT_START_LINES + (abs - SOFT_LIMIT_START_LINES) ** SOFT_LIMIT_POWER;
    return Math.sign(lines) * softened * PIXELS_PER_LINE;
  }

  function nearBottom(top: number, maxTop: number): boolean {
    return maxTop - top <= NEAR_BOTTOM_FOLLOW_LINES;
  }

  function maybeEnableBottomStick(top: number, dir: -1 | 0 | 1): void {
    if (dir <= 0 || stickToBottom.value) return;
    if (nearBottom(top, maxScroll.value)) {
      stickToBottom.value = true;
      scrollTop.value = maxScroll.value;
    }
  }

  const visibleStartRow = computed(() => {
    if (!domSmoothOverscanActive.value) return 0;
    return -Math.min(DOM_SMOOTH_SCROLL_OVERSCAN_ROWS, scrollTop.value);
  });

  const visibleLines = computed(() => {
    const start = Math.max(0, scrollTop.value + visibleStartRow.value);
    const bottomOverscan = domSmoothOverscanActive.value
      ? Math.min(
          DOM_SMOOTH_SCROLL_OVERSCAN_ROWS,
          Math.max(0, lineCountRef.value - (scrollTop.value + opts.mainH.value)),
        )
      : 0;
    return getLines(start, scrollTop.value + opts.mainH.value + bottomOverscan);
  });

  const scrollDebugEnabled = String(env.DIMCODE_SCROLL_DEBUG ?? "") === "1";
  const scrollDebugPath = String(env.DIMCODE_SCROLL_DEBUG_PATH ?? "/tmp/dimcode-scroll-debug.log");
  function debugLog(message: string): void {
    if (!scrollDebugEnabled) return;
    try {
      appendFileSync(scrollDebugPath, `${message}\n`);
    } catch {}
  }

  watch(maxScroll, (max, prevMax) => {
    debugLog(
      `[${Date.now()}] [CHAT_SCROLL] maxScroll ${prevMax} -> ${max} stick=${stickToBottom.value ? 1 : 0} top=${scrollTop.value}`,
    );
  });

  function stopWheelEase(): void {
    if (wheelEaseTimer) clearTimeout(wheelEaseTimer);
    wheelEaseTimer = null;
    easeWheelDelta = 0;
    easeWheelMode = null;
  }

  function hasDomSmoothScroll(): boolean {
    return DOM_SMOOTH_SCROLL_ENABLED && Boolean(opts.renderer?.value);
  }

  function wheelBatchWindowMs(): number {
    if (WHEEL_BATCH_WINDOW_MS != null) return WHEEL_BATCH_WINDOW_MS;
    return hasLiveDomRenderer() ? 16 : 0;
  }

  function lineUnitMultiplier(): number {
    if (LINE_UNIT_MULTIPLIER != null) return LINE_UNIT_MULTIPLIER;
    return hasLiveDomRenderer() ? 4 : 2;
  }

  function lineUnitMaxAccel(): number {
    if (LINE_UNIT_MAX_ACCEL != null) return LINE_UNIT_MAX_ACCEL;
    if (hasLiveDomRenderer()) return 3;
    return isGhostty ? 6 : 1;
  }

  function lineUnitMaxLines(): number {
    if (LINE_UNIT_MAX_LINES != null) return LINE_UNIT_MAX_LINES;
    return isGhostty && !hasLiveDomRenderer() ? 10 : 6;
  }

  function wheelEaseOutFactor(): number {
    if (WHEEL_EASE_OUT_FACTOR != null) return WHEEL_EASE_OUT_FACTOR;
    return hasLiveDomRenderer() ? 0.08 : 0;
  }

  function setTranscriptPlaneOffset(lines: number): void {
    const renderer = opts.renderer?.value;
    const nextOverscanActive =
      Boolean(renderer) && DOM_SMOOTH_SCROLL_ENABLED && Math.abs(lines) > DOM_SMOOTH_SCROLL_EPSILON;
    if (domSmoothOverscanActive.value !== nextOverscanActive)
      domSmoothOverscanActive.value = nextOverscanActive;
    if (!renderer) return;
    const cellHeight = renderer.metrics.cellHeight;
    const px = Number.isFinite(lines) ? -lines * cellHeight : 0;
    renderer.setPlaneOffset("transcript", px);
  }

  function setTranscriptPlaneViewport(): void {
    const renderer = opts.renderer?.value;
    if (!renderer) return;
    if (!hasDomSmoothScroll()) {
      renderer.setPlaneViewport("transcript", null);
      return;
    }
    const cellHeight = renderer.metrics.cellHeight;
    renderer.setPlaneViewport("transcript", {
      topPx: Math.max(0, Math.floor((opts.mainY ?? 0) * cellHeight)),
      heightPx: Math.max(0, Math.floor(opts.mainH.value * cellHeight)),
    });
  }

  function domSmoothCommittedTop(top: number): number {
    return clamp(Math.round(top), 0, maxScroll.value);
  }

  function cancelDomSmoothFrame(): void {
    if (domSmoothFrameHandle == null) return;
    if (domSmoothFrameIsTimer) clearTimeout(domSmoothFrameHandle as ReturnType<typeof setTimeout>);
    else if (typeof domSmoothFrameHandle === "number" && domSmoothFrameHandle > 0)
      cancelAnimationFrame(domSmoothFrameHandle as number);
    domSmoothFrameHandle = null;
    domSmoothFrameIsTimer = false;
  }

  function syncDomSmoothState(top: number = scrollTop.value): void {
    domSmoothVisualTop = clamp(top, 0, maxScroll.value);
    domSmoothTargetTop = domSmoothVisualTop;
    domSmoothLastAt = 0;
    cancelDomSmoothFrame();
    setTranscriptPlaneOffset(0);
  }

  function scheduleDomSmoothFrame(): void {
    if (!hasDomSmoothScroll() || domSmoothFrameHandle != null) return;
    const tick = (frameNow: number) => {
      domSmoothFrameHandle = null;
      const now = Number.isFinite(frameNow) ? frameNow : Date.now();
      const dt = domSmoothLastAt > 0 ? Math.max(1, now - domSmoothLastAt) : 16;
      domSmoothLastAt = now;

      const diff = domSmoothTargetTop - domSmoothVisualTop;
      if (Math.abs(diff) <= DOM_SMOOTH_SCROLL_EPSILON) {
        domSmoothVisualTop = domSmoothTargetTop;
        const settledTop = domSmoothCommittedTop(domSmoothTargetTop);
        if (scrollTop.value !== settledTop) {
          domSmoothSuppressScrollWatch = true;
          scrollTop.value = settledTop;
          domSmoothSuppressScrollWatch = false;
        }
        setTranscriptPlaneOffset(0);
        return;
      }

      const alpha = 1 - Math.exp(-(DOM_SMOOTH_SCROLL_RESPONSE * dt) / 1000);
      domSmoothVisualTop = clamp(domSmoothVisualTop + diff * alpha, 0, maxScroll.value);
      domSmoothTargetTop = clamp(domSmoothTargetTop, 0, maxScroll.value);

      const nextTop = domSmoothCommittedTop(domSmoothVisualTop);
      const prevTop = scrollTop.value;
      if (nextTop !== prevTop) {
        const dir = nextTop > prevTop ? 1 : -1;
        domSmoothSuppressScrollWatch = true;
        scrollTop.value = nextTop;
        domSmoothSuppressScrollWatch = false;
        if (dir < 0 && stickToBottom.value) stickToBottom.value = false;
        maybeEnableBottomStick(nextTop, dir);
      }
      setTranscriptPlaneOffset(domSmoothVisualTop - nextTop);
      if (Math.abs(domSmoothTargetTop - domSmoothVisualTop) > DOM_SMOOTH_SCROLL_EPSILON) {
        scheduleDomSmoothFrame();
      } else {
        setTranscriptPlaneOffset(0);
      }
    };

    if (typeof requestAnimationFrame === "function") {
      domSmoothFrameIsTimer = false;
      domSmoothFrameHandle = -1;
      const id = requestAnimationFrame(tick);
      if (domSmoothFrameHandle === -1) domSmoothFrameHandle = id;
      return;
    }

    domSmoothFrameIsTimer = true;
    domSmoothFrameHandle = setTimeout(() => tick(Date.now()), 16);
  }

  function applyDomSmoothWheelDelta(raw: number, now: number, deltaMode: WheelDeltaMode): boolean {
    if (!hasDomSmoothScroll() || deltaMode === "line") return false;
    const delta = adjustedWheelDelta(raw, now, deltaMode);
    if (!delta) return true;
    const lineDelta = delta / PIXELS_PER_LINE;
    if (!Number.isFinite(lineDelta) || lineDelta === 0) return true;
    if (lineDelta < 0 && stickToBottom.value) stickToBottom.value = false;
    domSmoothTargetTop = clamp(domSmoothTargetTop + lineDelta, 0, maxScroll.value);
    if (domSmoothVisualTop < 0 || domSmoothVisualTop > maxScroll.value)
      domSmoothVisualTop = clamp(domSmoothVisualTop, 0, maxScroll.value);
    scheduleDomSmoothFrame();
    return true;
  }

  function adjustedWheelDelta(delta: number, now: number, deltaMode: WheelDeltaMode): number {
    const next = delta;
    const isLineUnit =
      deltaMode === "line" ||
      (deltaMode === "auto" &&
        Number.isInteger(next) &&
        Math.abs(next) <= LINE_UNIT_DELTA_THRESHOLD);
    const rawLines = isLineUnit ? next : next / PIXELS_PER_LINE;
    const dir = rawLines > 0 ? 1 : -1;
    const leavingEdge =
      Number.isFinite(rawLines) &&
      rawLines !== 0 &&
      ((scrollTop.value <= 0 && dir > 0) || (scrollTop.value >= maxScroll.value && dir < 0));
    const ghosttyEdgeEscapeActive =
      ghosttyEdgeEscapeDir !== 0 &&
      ghosttyEdgeEscapeAt > 0 &&
      now - ghosttyEdgeEscapeAt < GHOSTTY_EDGE_ESCAPE_WINDOW_MS &&
      ((ghosttyEdgeEscapeDir > 0 && scrollTop.value <= GHOSTTY_EDGE_ESCAPE_LINES) ||
        (ghosttyEdgeEscapeDir < 0 &&
          maxScroll.value - scrollTop.value <= GHOSTTY_EDGE_ESCAPE_LINES));
    if (!ghosttyEdgeEscapeActive) {
      ghosttyEdgeEscapeDir = 0;
      ghosttyEdgeEscapeAt = 0;
    }
    const ghosttyEdgeArrivalActive =
      ghosttyEdgeArrivalDir > 0 &&
      ghosttyEdgeArrivalAt > 0 &&
      now - ghosttyEdgeArrivalAt < GHOSTTY_EDGE_ARRIVAL_WINDOW_MS &&
      scrollTop.value >= maxScroll.value;
    if (!ghosttyEdgeArrivalActive) {
      ghosttyEdgeArrivalDir = 0;
      ghosttyEdgeArrivalAt = 0;
      ghosttyEdgeArrivalExitDir = 0;
      ghosttyEdgeArrivalExitAt = 0;
    }
    const ghosttyEdgeBlockActive =
      ghosttyEdgeBlockDir !== 0 &&
      ghosttyEdgeBlockAt > 0 &&
      now - ghosttyEdgeBlockAt < GHOSTTY_EDGE_BLOCK_WINDOW_MS &&
      ((ghosttyEdgeBlockDir < 0 && scrollTop.value <= 0) ||
        (ghosttyEdgeBlockDir > 0 && scrollTop.value >= maxScroll.value));
    if (!ghosttyEdgeBlockActive) {
      ghosttyEdgeBlockDir = 0;
      ghosttyEdgeBlockCount = 0;
      ghosttyEdgeBlockAt = 0;
      ghosttyEdgeExitDir = 0;
      ghosttyEdgeExitAt = 0;
    }
    const ghosttyRecentEdgeBlock = ghosttyEdgeBlockActive && ghosttyEdgeBlockCount >= 2;
    const ghosttyHardEdgeBlock =
      ghosttyRecentEdgeBlock && ghosttyEdgeBlockCount >= GHOSTTY_EDGE_HARD_BLOCK_COUNT;
    if (leavingEdge) {
      if (ghosttyEdgeArrivalActive && dir === -ghosttyEdgeArrivalDir) {
        const arrivalDir = ghosttyEdgeArrivalDir;
        if (
          ghosttyEdgeArrivalExitDir !== dir ||
          now - ghosttyEdgeArrivalExitAt >= GHOSTTY_EDGE_EXIT_CONFIRM_WINDOW_MS
        ) {
          ghosttyEdgeArrivalExitDir = dir;
          ghosttyEdgeArrivalExitAt = now;
          debugLog(
            `[${now}] [CHAT_SCROLL] wheel ghostty-edge-arrival-arm dy=${delta} top=${scrollTop.value} arrivalDir=${arrivalDir}`,
          );
          return 0;
        }
        ghosttyEdgeArrivalDir = 0;
        ghosttyEdgeArrivalAt = 0;
        ghosttyEdgeArrivalExitDir = 0;
        ghosttyEdgeArrivalExitAt = 0;
        debugLog(
          `[${now}] [CHAT_SCROLL] wheel ghostty-edge-arrival-confirm dy=${delta} top=${scrollTop.value} arrivalDir=${arrivalDir}`,
        );
      }
      if (ghosttyHardEdgeBlock && dir === -ghosttyEdgeBlockDir) {
        ghosttyOppositeCarry = 0;
        ghosttyPendingDir = 0;
        ghosttyPendingAt = 0;
        ghosttyEdgeExitDir = 0;
        ghosttyEdgeExitAt = 0;
        wheelState.accumulator = 0;
        wheelState.lastAt = 0;
        debugLog(
          `[${now}] [CHAT_SCROLL] wheel ghostty-edge-hard-hold dy=${delta} top=${scrollTop.value} blockDir=${ghosttyEdgeBlockDir} blockCount=${ghosttyEdgeBlockCount}`,
        );
        return 0;
      }
      if (ghosttyRecentEdgeBlock && dir === -ghosttyEdgeBlockDir) {
        if (
          ghosttyEdgeExitDir !== dir ||
          now - ghosttyEdgeExitAt >= GHOSTTY_EDGE_EXIT_CONFIRM_WINDOW_MS
        ) {
          ghosttyEdgeExitDir = dir;
          ghosttyEdgeExitAt = now;
          debugLog(
            `[${now}] [CHAT_SCROLL] wheel ghostty-edge-exit-arm dy=${delta} top=${scrollTop.value} blockDir=${ghosttyEdgeBlockDir} blockCount=${ghosttyEdgeBlockCount}`,
          );
          return 0;
        }
        ghosttyEdgeExitDir = 0;
        ghosttyEdgeExitAt = 0;
        ghosttyEdgeBlockDir = 0;
        ghosttyEdgeBlockCount = 0;
        ghosttyEdgeBlockAt = 0;
        wheelState.lastEdgeDir = 0;
        wheelState.lastEdgeAt = 0;
        debugLog(
          `[${now}] [CHAT_SCROLL] wheel ghostty-edge-exit-confirm dy=${delta} top=${scrollTop.value} blockDir=${ghosttyEdgeBlockDir}`,
        );
      }
      ghosttyOppositeCarry = 0;
      ghosttyPendingDir = 0;
      ghosttyPendingAt = 0;
      ghosttyEdgeEscapeDir = dir;
      ghosttyEdgeEscapeAt = now;
      wheelState.lastEdgeDir = 0;
      wheelState.lastEdgeAt = 0;
    }
    if (isGhostty) {
      if (
        Number.isFinite(rawLines) &&
        rawLines !== 0 &&
        ghosttyEdgeEscapeDir !== 0 &&
        dir === -ghosttyEdgeEscapeDir &&
        ghosttyEdgeEscapeAt > 0 &&
        now - ghosttyEdgeEscapeAt < GHOSTTY_EDGE_ESCAPE_WINDOW_MS &&
        ((ghosttyEdgeEscapeDir > 0 && scrollTop.value <= GHOSTTY_EDGE_ESCAPE_LINES) ||
          (ghosttyEdgeEscapeDir < 0 &&
            maxScroll.value - scrollTop.value <= GHOSTTY_EDGE_ESCAPE_LINES))
      ) {
        ghosttyOppositeCarry = 0;
        ghosttyPendingDir = 0;
        ghosttyPendingAt = 0;
        ghosttyEdgeExitDir = 0;
        ghosttyEdgeExitAt = 0;
        wheelState.accumulator = 0;
        wheelState.lastAt = 0;
        debugLog(
          `[${now}] [CHAT_SCROLL] wheel ghostty-edge-hold dy=${delta} age=${now - ghosttyEdgeEscapeAt} top=${scrollTop.value} edgeDir=${ghosttyEdgeEscapeDir}`,
        );
        return 0;
      }
      if (leavingEdge) {
        ghosttyStableDir = dir;
      } else {
        const dt = now - lastAppliedAt;
        const refDir = ghosttyStableDir || lastAppliedDir;
        if (
          Number.isFinite(rawLines) &&
          rawLines !== 0 &&
          refDir !== 0 &&
          dir === -refDir &&
          dt >= 0 &&
          dt < GHOSTTY_OSCILLATION_WINDOW_MS
        ) {
          const lineUnits = isLineUnit ? Math.abs(next) : Math.abs(next / PIXELS_PER_LINE);
          const weighted =
            Math.min(3, Math.max(0.1, lineUnits)) * GHOSTTY_OSCILLATION_DAMPING_LINES;
          if (ghosttyPendingDir !== dir) {
            ghosttyPendingDir = dir;
            ghosttyPendingAt = now;
            ghosttyOppositeCarry = 0;
          }
          ghosttyOppositeCarry += Math.sign(next) * weighted;
          const confirmElapsed = ghosttyPendingAt > 0 ? now - ghosttyPendingAt : Infinity;
          if (
            Math.abs(ghosttyOppositeCarry) < GHOSTTY_OSCILLATION_COMMIT_LINES ||
            confirmElapsed < GHOSTTY_DIRECTION_CONFIRM_MS
          ) {
            debugLog(
              `[${now}] [CHAT_SCROLL] wheel ghostty-suppress dy=${delta} dt=${dt} hold=${confirmElapsed} carry=${ghosttyOppositeCarry} top=${scrollTop.value} lastAppliedDir=${lastAppliedDir}`,
            );
            return 0;
          }

          ghosttyPendingDir = 0;
          ghosttyPendingAt = 0;
          ghosttyOppositeCarry = 0;
          ghosttyEdgeBlockDir = 0;
          ghosttyEdgeBlockCount = 0;
          ghosttyEdgeBlockAt = 0;
          ghosttyEdgeExitDir = 0;
          ghosttyEdgeExitAt = 0;
          ghosttyEdgeEscapeDir = 0;
          ghosttyEdgeEscapeAt = 0;
          wheelState.accumulator = 0;
          wheelState.lastAt = 0;
          wheelState.lastEdgeDir = 0;
          wheelState.lastEdgeAt = 0;
          debugLog(
            `[${now}] [CHAT_SCROLL] wheel ghostty-confirm dy=${delta} dt=${dt} hold=${confirmElapsed} top=${scrollTop.value} refDir=${refDir}`,
          );
        } else {
          ghosttyOppositeCarry = 0;
          ghosttyPendingDir = 0;
          ghosttyPendingAt = 0;
          ghosttyEdgeExitDir = 0;
          ghosttyEdgeExitAt = 0;
          if (dir === ghosttyEdgeEscapeDir) {
            const escapedTop =
              (dir > 0 && scrollTop.value > GHOSTTY_EDGE_ESCAPE_LINES) ||
              (dir < 0 && maxScroll.value - scrollTop.value > GHOSTTY_EDGE_ESCAPE_LINES);
            if (escapedTop) {
              ghosttyEdgeEscapeDir = 0;
              ghosttyEdgeEscapeAt = 0;
            }
          }
          if (Number.isFinite(rawLines) && rawLines !== 0) ghosttyStableDir = dir;
        }
      }
    } else {
      ghosttyOppositeCarry = 0;
      ghosttyPendingDir = 0;
      ghosttyPendingAt = 0;
      ghosttyEdgeBlockDir = 0;
      ghosttyEdgeBlockCount = 0;
      ghosttyEdgeBlockAt = 0;
      ghosttyEdgeExitDir = 0;
      ghosttyEdgeExitAt = 0;
      ghosttyEdgeEscapeDir = 0;
      ghosttyEdgeEscapeAt = 0;
    }
    if (isLineUnit) {
      const dt = lastWheelInputAt > 0 ? now - lastWheelInputAt : Infinity;
      const accel =
        !leavingEdge &&
        LINE_UNIT_ACCEL_WINDOW_MS > 0 &&
        lastWheelInputAt > 0 &&
        Number.isFinite(dt) &&
        dt > 0 &&
        dt <= LINE_UNIT_ACCEL_WINDOW_MS
          ? 1 + (1 - dt / LINE_UNIT_ACCEL_WINDOW_MS) * (lineUnitMaxAccel() - 1)
          : 1;
      const boosted = Math.round(next * lineUnitMultiplier() * accel);
      const viewportCappedMax = Math.max(
        1,
        Math.min(lineUnitMaxLines(), Math.max(1, Math.floor(opts.mainH.value) - 2)),
      );
      return Math.sign(boosted) * Math.min(Math.abs(boosted), viewportCappedMax);
    }
    return applySoftLimitDelta(next);
  }

  function applyWheelDelta(
    raw: number,
    now: number,
    deltaMode: WheelDeltaMode,
    disableAcceleration = false,
  ): { nextTop: number; dir: -1 | 0 | 1; lines: number } {
    const delta = adjustedWheelDelta(raw, now, deltaMode);
    if (!delta) return { nextTop: scrollTop.value, dir: 0, lines: 0 };
    const { nextTop, dir, lines } = applyWheelScroll(
      wheelState,
      delta,
      scrollTop.value,
      maxScroll.value,
      now,
      deltaMode,
      { disableAcceleration },
    );

    debugLog(
      `[${now}] [CHAT_SCROLL] wheel dy=${raw} adj=${delta} top=${scrollTop.value} max=${maxScroll.value} stick=${stickToBottom.value ? 1 : 0} -> next=${nextTop} dir=${dir} lines=${lines} ghostty=${isGhostty ? 1 : 0}`,
    );

    if (!dir || nextTop === scrollTop.value) {
      if (isGhostty) {
        const attemptedDir = delta > 0 ? 1 : -1;
        const blockedAtEdge =
          (scrollTop.value <= 0 && attemptedDir < 0) ||
          (scrollTop.value >= maxScroll.value && attemptedDir > 0);
        if (blockedAtEdge) {
          if (scrollTop.value >= maxScroll.value && attemptedDir > 0) {
            if (attemptedDir === ghosttyEdgeArrivalDir) {
              ghosttyEdgeArrivalAt = now;
              ghosttyEdgeArrivalExitDir = 0;
              ghosttyEdgeArrivalExitAt = 0;
            } else {
              ghosttyEdgeArrivalDir = attemptedDir;
              ghosttyEdgeArrivalAt = now;
              ghosttyEdgeArrivalExitDir = 0;
              ghosttyEdgeArrivalExitAt = 0;
            }
          }
          if (
            ghosttyEdgeBlockDir === attemptedDir &&
            ghosttyEdgeBlockAt > 0 &&
            now - ghosttyEdgeBlockAt < GHOSTTY_EDGE_BLOCK_WINDOW_MS
          ) {
            ghosttyEdgeBlockCount += 1;
          } else {
            ghosttyEdgeBlockDir = attemptedDir;
            ghosttyEdgeBlockCount = 1;
          }
          ghosttyEdgeBlockAt = now;
          ghosttyEdgeExitDir = 0;
          ghosttyEdgeExitAt = 0;
        } else {
          ghosttyEdgeBlockDir = 0;
          ghosttyEdgeBlockCount = 0;
          ghosttyEdgeBlockAt = 0;
          ghosttyEdgeArrivalDir = 0;
          ghosttyEdgeArrivalAt = 0;
          ghosttyEdgeArrivalExitDir = 0;
          ghosttyEdgeArrivalExitAt = 0;
          ghosttyEdgeExitDir = 0;
          ghosttyEdgeExitAt = 0;
        }
      }
      // Avoid terminal/native scrollback movement at edges when possible.
      return { nextTop: scrollTop.value, dir: 0, lines: 0 };
    }

    ghosttyEdgeBlockDir = 0;
    ghosttyEdgeBlockCount = 0;
    ghosttyEdgeBlockAt = 0;
    ghosttyEdgeExitDir = 0;
    ghosttyEdgeExitAt = 0;
    if (dir < 0 && stickToBottom.value) stickToBottom.value = false;
    scrollTop.value = clamp(nextTop, 0, maxScroll.value);
    lastAppliedDir = dir;
    lastAppliedAt = now;
    if (isGhostty) ghosttyStableDir = dir;
    if (scrollTop.value >= maxScroll.value && dir > 0) {
      ghosttyEdgeArrivalDir = dir;
      ghosttyEdgeArrivalAt = now;
      ghosttyEdgeArrivalExitDir = 0;
      ghosttyEdgeArrivalExitAt = 0;
    } else {
      ghosttyEdgeArrivalDir = 0;
      ghosttyEdgeArrivalAt = 0;
      ghosttyEdgeArrivalExitDir = 0;
      ghosttyEdgeArrivalExitAt = 0;
    }
    maybeEnableBottomStick(scrollTop.value, dir);

    // Fast-path: flush render synchronously via microtask so Vue's watchEffect
    // runs first, then we bypass the scheduler's nextTick/setTimeout delay.
    if (opts.scheduler) {
      const sched = opts.scheduler;
      queueMicrotask(() => sched.flushNow());
      void nextTick(() => {
        queueMicrotask(() => sched.flushNow());
      });
    }

    return { nextTop, dir, lines };
  }

  function scheduleWheelEase(): void {
    if (wheelEaseTimer || !easeWheelMode || !easeWheelDelta) return;
    if (WHEEL_EASE_OUT_INTERVAL_MS <= 0) return;
    wheelEaseTimer = setTimeout(() => {
      wheelEaseTimer = null;
      const raw = easeWheelDelta;
      const mode = easeWheelMode;
      if (!raw || !mode) return;
      const result = applyWheelDelta(raw, Date.now(), mode, true);
      if (!result.dir) {
        stopWheelEase();
        return;
      }
      if (Math.abs(result.lines) <= 1) {
        stopWheelEase();
        return;
      }

      easeWheelDelta *= WHEEL_EASE_OUT_DECAY;
      const minDelta = WHEEL_EASE_OUT_MIN_LINES * PIXELS_PER_LINE;
      if (
        Math.abs(easeWheelDelta) < minDelta ||
        scrollTop.value <= 0 ||
        scrollTop.value >= maxScroll.value
      ) {
        stopWheelEase();
        return;
      }

      scheduleWheelEase();
    }, WHEEL_EASE_OUT_INTERVAL_MS);
  }

  function startWheelEase(deltaMode: WheelDeltaMode, lines: number): void {
    stopWheelEase();
    if (deltaMode === "line") return;
    const easeOutFactor = wheelEaseOutFactor();
    if (easeOutFactor <= 0 || WHEEL_EASE_OUT_INTERVAL_MS <= 0) return;
    const minDelta = WHEEL_EASE_OUT_MIN_LINES * PIXELS_PER_LINE;
    const nextDelta = lines * PIXELS_PER_LINE * easeOutFactor;
    if (!Number.isFinite(nextDelta) || Math.abs(nextDelta) < minDelta) return;
    if (scrollTop.value <= 0 || scrollTop.value >= maxScroll.value) return;
    easeWheelDelta = nextDelta;
    easeWheelMode = "pixel";
    scheduleWheelEase();
  }

  function applyPendingWheel(now: number): { nextTop: number; dir: -1 | 0 | 1; lines: number } {
    if (!pendingWheelDelta || !pendingWheelMode)
      return { nextTop: scrollTop.value, dir: 0, lines: 0 };
    const raw = pendingWheelDelta;
    const mode = pendingWheelMode;
    pendingWheelDelta = 0;
    pendingWheelMode = null;
    const result = applyWheelDelta(raw, now, mode);
    if (result.dir) startWheelEase(mode, result.lines);
    else stopWheelEase();
    return result;
  }

  function scheduleWheelFlush(now: number): void {
    const batchWindowMs = wheelBatchWindowMs();
    const elapsed = lastWheelFlushAt ? now - lastWheelFlushAt : Number.POSITIVE_INFINITY;
    if (elapsed >= batchWindowMs && !wheelFlushTimer) {
      applyPendingWheel(now);
      lastWheelFlushAt = now;
      return;
    }
    if (wheelFlushTimer) return;
    const waitMs = Math.max(1, batchWindowMs - Math.max(0, elapsed));
    wheelFlushTimer = setTimeout(() => {
      wheelFlushTimer = null;
      const ts = Date.now();
      applyPendingWheel(ts);
      lastWheelFlushAt = ts;
    }, waitMs);
  }

  function wheelDeltaModeFromEvent(e: { deltaMode?: unknown }, delta: number): WheelDeltaMode {
    if (e.deltaMode === 1) return "line";
    if (e.deltaMode === 0) return "pixel";
    const isLineUnit = Number.isInteger(delta) && Math.abs(delta) <= LINE_UNIT_DELTA_THRESHOLD;
    return isLineUnit ? "line" : "auto";
  }

  function onWheel(e: any): void {
    const delta = e.deltaY ?? 0;
    if (!delta) return;
    stopWheelEase();
    // Prevent browser/native scrolling while the chat handles wheel input.
    // (Terminal environments typically ignore this, but DOM renderers rely on it.)
    e.preventDefault?.();
    const deltaMode = wheelDeltaModeFromEvent(e, delta);
    const isLineUnit = deltaMode === "line";
    if (pendingWheelMode && pendingWheelMode !== deltaMode && pendingWheelDelta) {
      if (wheelFlushTimer) {
        clearTimeout(wheelFlushTimer);
        wheelFlushTimer = null;
      }
      applyPendingWheel(Date.now());
      lastWheelFlushAt = Date.now();
    }
    pendingWheelDelta += delta;
    pendingWheelMode = deltaMode;
    const now = Date.now();
    if (applyDomSmoothWheelDelta(delta, now, deltaMode)) {
      lastWheelInputAt = now;
      pendingWheelDelta = 0;
      pendingWheelMode = null;
      return;
    }
    if (
      isGhostty &&
      GHOSTTY_IDLE_RESET_MS > 0 &&
      lastWheelInputAt > 0 &&
      now - lastWheelInputAt > GHOSTTY_IDLE_RESET_MS
    ) {
      // Reset residual momentum after finger-up gaps so the next gesture doesn't inherit
      // stale accumulator and bounce on first contact.
      pendingWheelDelta = delta;
      pendingWheelMode = deltaMode;
      wheelState.accumulator = 0;
      wheelState.lastAt = 0;
      wheelState.lastEdgeDir = 0;
      wheelState.lastEdgeAt = 0;
      ghosttyOppositeCarry = 0;
      ghosttyPendingDir = 0;
      ghosttyPendingAt = 0;
      ghosttyEdgeBlockDir = 0;
      ghosttyEdgeBlockCount = 0;
      ghosttyEdgeBlockAt = 0;
      ghosttyEdgeArrivalDir = 0;
      ghosttyEdgeArrivalAt = 0;
      ghosttyEdgeArrivalExitDir = 0;
      ghosttyEdgeArrivalExitAt = 0;
      ghosttyEdgeExitDir = 0;
      ghosttyEdgeExitAt = 0;
      ghosttyEdgeEscapeDir = 0;
      ghosttyEdgeEscapeAt = 0;
      debugLog(
        `[${now}] [CHAT_SCROLL] wheel ghostty-idle-reset dt=${now - lastWheelInputAt} dy=${delta} top=${scrollTop.value}`,
      );
    }
    if (isLineUnit) {
      if (wheelFlushTimer) clearTimeout(wheelFlushTimer);
      wheelFlushTimer = null;
      applyPendingWheel(now);
      lastWheelFlushAt = now;
      lastWheelInputAt = now;
      return;
    }
    if (Math.abs(delta) >= IMMEDIATE_WHEEL_DELTA_THRESHOLD) {
      if (wheelFlushTimer) clearTimeout(wheelFlushTimer);
      wheelFlushTimer = null;
      applyPendingWheel(now);
      lastWheelFlushAt = now;
      lastWheelInputAt = now;
      return;
    }
    lastWheelInputAt = now;
    scheduleWheelFlush(now);
  }

  if (getCurrentScope()) {
    onScopeDispose(() => {
      if (wheelFlushTimer) clearTimeout(wheelFlushTimer);
      wheelFlushTimer = null;
      stopWheelEase();
      cancelDomSmoothFrame();
      opts.renderer?.value?.setPlaneViewport("transcript", null);
      setTranscriptPlaneOffset(0);
      pendingWheelDelta = 0;
      pendingWheelMode = null;
      ghosttyOppositeCarry = 0;
      ghosttyPendingDir = 0;
      ghosttyPendingAt = 0;
      ghosttyEdgeBlockDir = 0;
      ghosttyEdgeBlockCount = 0;
      ghosttyEdgeBlockAt = 0;
      ghosttyEdgeExitDir = 0;
      ghosttyEdgeExitAt = 0;
      ghosttyEdgeEscapeDir = 0;
      ghosttyEdgeEscapeAt = 0;
      ghosttyStableDir = 0;
      lastWheelInputAt = 0;
    });
  }

  return {
    scrollTop,
    stickToBottom,
    maxScroll,
    visibleLines,
    visibleStartRow,
    onWheel,
  };
}
