// ABOUTME: Canvas-based UI for the Rust-backed terminal grid.
// ABOUTME: Reads parsed grid snapshots from terminal_grid_snapshot and pipes raw key input back to the PTY.

import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import {
  type Component,
  createEffect,
  createMemo,
  createSignal,
  on,
  onCleanup,
  onMount,
  Show,
} from "solid-js";
import {
  ContextMenu,
  type ContextMenuItem,
} from "@/components/common/ContextMenu";
import {
  canAcceptSkillDrop,
  setCurrentSkillDragPayload,
  skillDragPayload,
  skillPromptTextFromDrag,
} from "@/lib/skill-drag";
import { appearanceState } from "@/stores/appearance.store";
import { terminalStore } from "@/stores/terminal.store";
import { threadStore } from "@/stores/thread.store";
import { encodeKeyEventToBytes } from "./keyEncoding";

interface GridCell {
  ch: number;
  width: number;
  // SGR fields. Backend skips serializing these when default, so a
  // missing field means "use the renderer default".
  fg?: number;
  bg?: number;
  attrs?: number;
}

interface GridSnapshot {
  rows: number;
  cols: number;
  cells: GridCell[];
  cursorRow: number;
  cursorCol: number;
  unhandledActions: number;
  // Mode-tracking fields. Backend skips serializing each when it
  // equals its default (cursorVisible=true, cursorKeysApp=false,
  // bracketedPaste=false), so a missing field means "default".
  cursorVisible?: boolean;
  cursorKeysApp?: boolean;
  bracketedPaste?: boolean;
  // Number of rows preserved in scrollback above the live screen.
  // Backend skips serializing when zero.
  scrollbackLen?: number;
  // Monotonic id of the oldest retained scrollback row. Backend skips
  // serializing while zero.
  scrollbackBase?: number;
  // Mouse-tracking mode requested by the running app. 0 = off,
  // 1000 = press/release, 1002 = + drag, 1003 = + all motion.
  mouseTracking?: number;
  // DECSET 1006 SGR encoding. Frontend refuses to emit reports
  // unless this is set.
  mouseSgr?: boolean;
  // Last title the running app set via OSC 0/2. Persists across
  // diff drains so the frontend can refresh after a snapshot resync
  // without waiting for the next OSC.
  title?: string;
}

interface TerminalSnapshotGrid {
  seq: number;
  kind: "grid";
  payload: GridSnapshot;
}

/**
 * Incremental update from terminal://grid-diff. Carries only the
 * rows that changed since the last drain plus the current cursor +
 * mode flags. The frontend applies the diff to its local grid
 * signal and tracks the seq so it can detect a missed diff and
 * resync via a full snapshot. A diff may cover multiple feed seqs
 * when Rust coalesces high-throughput output; baseSeq is the grid
 * seq included by the previous diff drain.
 */
interface GridDiffRow {
  row: number;
  cells: GridCell[];
}
interface GridScroll {
  top: number;
  bottom: number;
  delta: number;
}
interface GridDiffEvent {
  bufferId: string;
  baseSeq: number;
  seq: number;
  scrolls?: GridScroll[];
  rows: GridDiffRow[];
  cursorRow: number;
  cursorCol: number;
  cursorVisible: boolean;
  cursorKeysApp: boolean;
  bracketedPaste: boolean;
  rowsTotal: number;
  colsTotal: number;
  // Current scrollback length. Frontend uses the delta between diffs
  // to keep the viewport anchored when in scroll-back mode.
  scrollbackLen?: number;
  scrollbackBase?: number;
  mouseTracking?: number;
  mouseSgr?: boolean;
  // Window title set via OSC 0/2 since the previous diff. Backend
  // skip-serializes when nothing changed.
  title?: string;
}

interface ScrollbackWindow {
  start: number;
  scrollbackBase?: number;
  rows: GridCell[][];
  scrollbackLen: number;
}

const CELL_FONT_FAMILY =
  '"JetBrains Mono", "SF Mono", "Menlo", "Consolas", monospace';

const COLOR_BG = "#090b0f";
const COLOR_FG = "#d7dde8";
const COLOR_CURSOR = "#d7dde8";
const COLOR_CURSOR_FG = "#090b0f";
// Claude-themed paint colors (#2006). Gated on `isClaudeCli()` so plain
// Terminal threads keep their existing cursor/selection vocabulary.
// Sky-400 ties the cursor + selection to the Subscription pill and the
// ring/glow chrome rendered by the same gate.
const COLOR_CURSOR_CLAUDE = "#38bdf8";
const COLOR_CURSOR_FG_CLAUDE = "#090b0f";
const DIM_ALPHA = 0.82;

// Color packing matches src-tauri/src/terminal.rs:
//   0xFFFFFFFF       = default (renderer falls back to theme)
//   0xFE000000 | idx = palette index (0..255)
//   0x00RRGGBB       = truecolor
const COLOR_DEFAULT = 0xffffffff;
const COLOR_PALETTE_TAG = 0xfe000000;

// Cell attribute bits (mirror ATTR_* in Rust).
const ATTR_BOLD = 1 << 0;
const ATTR_ITALIC = 1 << 1;
const ATTR_UNDERLINE = 1 << 2;
const ATTR_REVERSE = 1 << 3;
const ATTR_DIM = 1 << 4;
const ATTR_STRIKE = 1 << 5;

// Standard xterm-ish 16-color palette. Indices 0-7 = normal, 8-15 = bright.
// Picked to feel modern/legible against the dark theme background rather
// than slavishly matching xterm's exact tones.
const ANSI_16 = [
  "#000000",
  "#cd3131",
  "#0dbc79",
  "#e5e510",
  "#2472c8",
  "#bc3fbc",
  "#11a8cd",
  "#e5e5e5",
  "#666666",
  "#f14c4c",
  "#23d18b",
  "#f5f543",
  "#3b8eea",
  "#d670d6",
  "#29b8db",
  "#ffffff",
];

const ANSI_16_FG = [
  "#7c8798",
  "#f14c4c",
  "#23d18b",
  "#f5f543",
  "#4da3ff",
  "#d670d6",
  "#29b8db",
  "#e5e5e5",
  "#8b95a5",
  "#ff6b6b",
  "#32e39a",
  "#ffff66",
  "#69b7ff",
  "#e58be5",
  "#4fd7f3",
  "#ffffff",
];

/**
 * Resolve a 256-color extended palette index (16..255) to a CSS color.
 * 16-231 are a 6x6x6 RGB cube; 232-255 are a 24-step grayscale ramp.
 */
function palette256(idx: number, role: "fg" | "bg"): string {
  if (idx < 16) return role === "fg" ? ANSI_16_FG[idx] : ANSI_16[idx];
  if (idx >= 232) {
    const raw = 8 + (idx - 232) * 10;
    const v = role === "fg" ? Math.max(132, raw) : raw;
    return `rgb(${v},${v},${v})`;
  }
  const i = idx - 16;
  const r = Math.floor(i / 36);
  const g = Math.floor((i % 36) / 6);
  const b = i % 6;
  const lvl = (n: number) => (n === 0 ? 0 : 55 + n * 40);
  return `rgb(${lvl(r)},${lvl(g)},${lvl(b)})`;
}

/**
 * Convert a packed cell color to a CSS color string. `defaultColor` is
 * what to return for the default sentinel (different for fg vs bg).
 *
 * NOTE: JS bitwise operators coerce to signed int32, so `(packed &
 * 0xff000000)` for any packed value with bit 31 set returns a negative
 * number while `0xfe000000` as a Number literal is positive
 * (4261412864). The `>>> 0` zero-fill shift coerces both sides to the
 * same unsigned int32 representation so the equality holds. Without it,
 * every palette-tagged color silently falls through to the truecolor
 * branch and renders as `rgb(0,0,idx)`.
 */
function resolveColor(
  packed: number | undefined,
  defaultColor: string,
  role: "fg" | "bg" = "fg",
): string {
  if (packed === undefined || packed === COLOR_DEFAULT) return defaultColor;
  if ((packed & 0xff000000) >>> 0 === COLOR_PALETTE_TAG) {
    return palette256(packed & 0xff, role);
  }
  // 24-bit RGB in the low 24 bits. Truecolor packs always have the high
  // byte as 0x00, so the signed-shift quirk above doesn't apply here.
  const r = (packed >> 16) & 0xff;
  const g = (packed >> 8) & 0xff;
  const b = packed & 0xff;
  return `rgb(${r},${g},${b})`;
}

/**
 * Pick the right canvas font string for a cell's bold/italic combo.
 * Dim's font effect is not used; we render Dim by halving the
 * foreground alpha at draw time instead of using a thin font weight.
 */
function fontForAttrs(attrs: number, fontSize: number): string {
  const bold = (attrs & ATTR_BOLD) !== 0;
  const italic = (attrs & ATTR_ITALIC) !== 0;
  const style = italic ? "italic " : "";
  const weight = bold ? "bold " : "";
  return `${style}${weight}${fontSize}px ${CELL_FONT_FAMILY}`;
}

interface CellPos {
  row: number;
  col: number;
}
interface SelectionRange {
  /** Cell where the user pressed mousedown. Stays put across drag. */
  anchor: CellPos;
  /** Cell where the mouse currently is (or last was on mouseup). */
  head: CellPos;
}

const SELECTION_OVERLAY_FILL = "rgba(80, 130, 200, 0.4)";
// Claude-themed selection overlay (#2006). Sky-400 alpha. Kept low so
// selected glyphs stay readable on the dark canvas.
const SELECTION_OVERLAY_FILL_CLAUDE = "rgba(56, 189, 248, 0.25)";

/**
 * Normalize a SelectionRange so the returned [start, end] pair is in
 * row-major order regardless of which way the user dragged.
 */
function normalizeSelection(sel: SelectionRange): [CellPos, CellPos] {
  const a = sel.anchor;
  const b = sel.head;
  if (a.row < b.row || (a.row === b.row && a.col <= b.col)) {
    return [a, b];
  }
  return [b, a];
}

/**
 * Walk the selected cells row-by-row and build a string. Wide-char
 * continuation cells (width=0) are skipped so a CJK glyph counts
 * once. Empty cells (ch=0) become spaces. Trailing whitespace per
 * line is trimmed; rows are joined with newlines.
 */
function selectionText(grid: GridSnapshot, sel: SelectionRange): string {
  const [start, end] = normalizeSelection(sel);
  const lines: string[] = [];
  for (let r = start.row; r <= end.row && r < grid.rows; r++) {
    const startCol = r === start.row ? start.col : 0;
    const endCol = r === end.row ? end.col : grid.cols - 1;
    let line = "";
    for (let c = startCol; c <= endCol && c < grid.cols; c++) {
      const cell = grid.cells[r * grid.cols + c];
      if (!cell) continue;
      if (cell.width === 0) continue;
      line += cell.ch ? String.fromCodePoint(cell.ch) : " ";
    }
    lines.push(line.trimEnd());
  }
  return lines.join("\n");
}

/**
 * Build a SelectionRange that covers every cell of the visible grid.
 * Used by the right-click context menu's "Select All" action so the
 * user can grab the whole screen without dragging.
 */
function selectAllGrid(grid: GridSnapshot): SelectionRange {
  return {
    anchor: { row: 0, col: 0 },
    head: { row: Math.max(0, grid.rows - 1), col: Math.max(0, grid.cols - 1) },
  };
}

const isMacPlatform = (): boolean => {
  if (typeof navigator === "undefined") return false;
  return /Mac|iPhone|iPad|iPod/.test(navigator.platform);
};

const copyShortcutLabel = (): string =>
  isMacPlatform() ? "⌘C" : "Ctrl+Shift+C";
const pasteShortcutLabel = (): string =>
  isMacPlatform() ? "⌘V" : "Ctrl+Shift+V";

/**
 * Best-effort clipboard write. Tauri's webview generally exposes the
 * Async Clipboard API, but some platforms / configurations don't, so
 * fall back to a hidden textarea + execCommand("copy").
 */
async function writeClipboard(text: string): Promise<void> {
  if (!text) return;
  try {
    await navigator.clipboard.writeText(text);
    return;
  } catch {
    // Fall through to execCommand fallback.
  }
  // Capture the current focus so we can restore it after the textarea
  // hijack. Without this, the user's next keystroke after a copy would
  // be lost - the surface div lost focus when the textarea took it.
  const previousFocus =
    document.activeElement instanceof HTMLElement
      ? document.activeElement
      : null;
  const ta = document.createElement("textarea");
  ta.value = text;
  ta.style.position = "fixed";
  ta.style.left = "-1000px";
  ta.style.top = "0";
  document.body.appendChild(ta);
  ta.focus();
  ta.select();
  try {
    document.execCommand("copy");
  } catch {
    // Out of options; nothing to do.
  }
  document.body.removeChild(ta);
  previousFocus?.focus();
}

interface TerminalBufferProps {
  threadId?: string;
}

export const TerminalBuffer: Component<TerminalBufferProps> = (props) => {
  let canvasRef: HTMLCanvasElement | undefined;
  let surfaceRef: HTMLDivElement | undefined;
  const [grid, setGrid] = createSignal<GridSnapshot | null>(null);
  // Last grid seq applied to the local grid signal. Rust may coalesce
  // many feed seqs into one diff, so applyDiff accepts any diff whose
  // baseSeq is already covered by this local seq; a baseSeq newer than
  // this local seq means we missed a diff and must resync.
  const [gridSeq, setGridSeq] = createSignal(0);
  const [cellW, setCellW] = createSignal(0);
  const [cellH, setCellH] = createSignal(0);
  const terminalFontSize = createMemo(
    () => appearanceState.appearance.terminalFontSize,
  );
  const [selection, setSelection] = createSignal<SelectionRange | null>(null);
  // Right-click context menu state. null = closed; { x, y } = open at viewport coords.
  // Reuses the shared ContextMenu primitive (src/components/common/ContextMenu.tsx).
  const [ctxMenu, setCtxMenu] = createSignal<{ x: number; y: number } | null>(
    null,
  );
  // Off-screen mirror node that holds the selected text so window.getSelection()
  // returns a real DOM Range. Without this, the system Edit > Copy menu item
  // (Tauri PredefinedMenuItem::copy in src-tauri/src/lib.rs) silently no-ops
  // on the canvas surface because Chromium only fires a `copy` event when
  // there is a DOM selection. #2091.
  let selectionMirrorRef: HTMLSpanElement | undefined;

  let unlistenDiff: UnlistenFn | null = null;
  let resizeObserver: ResizeObserver | null = null;
  // Snapshot fetch in flight - serialize to one outstanding so a burst
  // of resync requests doesn't pile up IPC.
  let inFlightSnapshot = false;
  let pendingResync = false;
  let canvasPixelWidth = 0;
  let canvasPixelHeight = 0;
  let canvasCssWidth = 0;
  let canvasCssHeight = 0;
  // Diff coalescing. The Rust side caps emits to ~60fps, but on a
  // maximized terminal each diff still carries up to ~20k cells and the
  // canvas repaint is O(grid_size). Without coalescing, sustained
  // torrents (find ~) overflow the webview event queue: per-event work
  // exceeds frame budget, events pile up unbounded, GC pressure mounts,
  // throughput collapses. The handler enqueues; a single rAF drains the
  // queue and triggers one paint per frame.
  let pendingDiffs: GridDiffEvent[] = [];
  // Rows that changed since the last paint. Lets the painter redraw
  // only those rows instead of all 12k+ cells.
  const pendingRepaintRows = new Set<number>();
  // Scroll regions that can be shifted in the canvas backing store before
  // repainting inserted/dirty rows. This avoids repainting the whole visible
  // scroll region for every bottom scroll.
  let pendingCanvasScrolls: GridScroll[] = [];
  // Forces the next paint to redraw every cell. Set on snapshot reset,
  // resize, selection change, cell-metric change, and overflow recovery.
  let needsFullRepaint = true;
  let rafHandle: number | null = null;
  let focusFrame: number | null = null;
  // Hard cap on queued diffs. If rAF is starved (background tab) we
  // drop the queue and resync via snapshot instead of growing forever.
  const MAX_PENDING_DIFFS = 240;
  // Selection drag tracking. dragAnchor non-null means a left-button
  // drag is in progress. dragMoved distinguishes drag-to-select from
  // a plain click (which clears the selection).
  let dragAnchor: CellPos | null = null;
  let dragMoved = false;
  const activeBufferId = () => props.threadId ?? threadStore.activeThreadId;

  // Scrollback viewport. `viewportOffset` is the number of rows above
  // the live bottom that the user has scrolled up; 0 means the live
  // grid is fully visible. `scrollbackLen` mirrors the backend's
  // current history length and is the upper bound on viewportOffset.
  // The cache is keyed by monotonic scrollback row id. Rust reports
  // scrollbackBase = id of its oldest retained row, so cache keys stay
  // stable when the fixed-size history ring evicts from the front.
  const [viewportOffset, setViewportOffset] = createSignal(0);
  const [scrollbackLen, setScrollbackLen] = createSignal(0);
  const [scrollbackBase, setScrollbackBase] = createSignal(0);
  const scrollbackCache = new Map<number, GridCell[]>();
  const scrollbackInFlight = new Set<number>();
  // How many wheel-delta units accumulate to one row of scroll. Wheel
  // events report deltaY in pixels (line/page modes are normalized by
  // the browser); 40px per cell row matches macOS / Linux defaults.
  const WHEEL_PIXELS_PER_ROW = 40;
  let wheelAccumulator = 0;

  const buffer = createMemo(() => terminalStore.getBuffer(activeBufferId()));
  // #2006 — trust signal used by paint code (cursor, selection, line-height)
  // AND by the header/footer JSX. Mirrors the launcher entry at
  // ThreadSidebar.tsx:702, which is locked by launcher-redesign.test.ts to
  // spawn `claude` with no flags. Drift here silently loses every themed
  // surface, so the file-string regression test in
  // tests/unit/terminal-buffer-claude-cli.test.ts pins the predicate.
  const isClaudeCli = () => buffer()?.command === "claude";
  // #2006 — version pill copy. Fetches once via the cached terminalStore
  // helper, only when the active thread is a Claude CLI thread. The pill
  // renders behind <Show when={claudeCliVersion()}> so a null (probe
  // failed, binary missing) gracefully omits the pill.
  const [claudeCliVersion, setClaudeCliVersion] = createSignal<string | null>(
    null,
  );
  createEffect(() => {
    if (!isClaudeCli()) return;
    if (claudeCliVersion()) return;
    void terminalStore.getClaudeCliVersion().then((v) => {
      if (v) setClaudeCliVersion(v);
    });
  });

  const pruneScrollbackCache = (base: number, len: number) => {
    const end = base + len;
    for (const key of scrollbackCache.keys()) {
      if (key < base || key >= end) {
        scrollbackCache.delete(key);
      }
    }
    for (const key of scrollbackInFlight) {
      if (key < base || key >= end) {
        scrollbackInFlight.delete(key);
      }
    }
  };

  const fetchGridSnapshot = async () => {
    const id = activeBufferId();
    if (!id) return;
    if (inFlightSnapshot) {
      pendingResync = true;
      return;
    }
    inFlightSnapshot = true;
    try {
      const snap = await invoke<TerminalSnapshotGrid>(
        "terminal_grid_snapshot",
        { bufferId: id },
      );
      if (snap.kind === "grid" && id === activeBufferId()) {
        // Snapshot supersedes any queued diffs - they're either older
        // (already in the snapshot) or newer (will arrive after the
        // emitter's next tick and apply cleanly on top).
        pendingDiffs = [];
        pendingRepaintRows.clear();
        pendingCanvasScrolls = [];
        needsFullRepaint = true;
        setGrid(snap.payload);
        setGridSeq(snap.seq);
        // Re-sync the title in case dropped diffs (overflow recovery)
        // carried OSC titles the frontend never observed. The diff
        // path only ever ships title once per OSC; without this, a
        // resync after a torrent could leave the buffer header
        // showing the old title until the next OSC fires.
        if (snap.payload.title) {
          terminalStore.setBufferTitle(id, snap.payload.title);
        }
        const nextLen = snap.payload.scrollbackLen ?? 0;
        const nextBase = snap.payload.scrollbackBase ?? 0;
        const prevLen = scrollbackLen();
        const prevBase = scrollbackBase();
        setScrollbackLen(nextLen);
        setScrollbackBase(nextBase);
        if (nextBase !== prevBase || nextLen !== prevLen) {
          pruneScrollbackCache(nextBase, nextLen);
        }
        const offset = viewportOffset();
        if (offset > nextLen) {
          // Snapshot reports less history than the user had scrolled
          // into (resize trim, backend buffer reset, thread-switch
          // race). Without this the viewport could sit past the live
          // edge of scrollback and cellAt would resolve to undefined
          // for every cell above the live screen.
          setViewportOffset(nextLen);
        } else if (offset > 0 && nextLen > prevLen) {
          // MAX_PENDING_DIFFS overflow drops queued diffs that may
          // have grown scrollback by `delta` rows. Mirror the
          // applyDiffInternal anchor logic so the user's view stays
          // pinned to the same historical rows instead of sliding up
          // by `delta` after the resync.
          const delta = nextBase + nextLen - (prevBase + prevLen);
          setViewportOffset(Math.min(offset + delta, nextLen));
        }
        scheduleFrame();
      }
    } catch {
      // Buffer may have been killed between trigger and fetch.
    } finally {
      inFlightSnapshot = false;
      if (pendingResync) {
        pendingResync = false;
        void fetchGridSnapshot();
      }
    }
  };

  type ApplyResult = "ok" | "noop" | "resync";

  /**
   * Apply an incremental grid diff in place and record which rows need
   * repainting. Mutates the existing cells array (no slice) to avoid
   * allocating ~20k objects per diff under torrent load - that
   * allocation rate was the dominant source of GC pressure and the
   * compounding slowdown.
   *
   * Returns "noop" when the diff is older than current state, "resync"
   * when the diff's baseSeq is past current (we missed a diff and have
   * to refetch), and "ok" when applied. Caller should NOT call setGrid
   * for "ok" results - the snapshot reference stays the same and the
   * paint loop reads its updated fields directly.
   */
  const applyDiffInternal = (diff: GridDiffEvent): ApplyResult => {
    const current = grid();
    if (!current) return "resync";
    const currentSeq = gridSeq();
    if (diff.seq <= currentSeq) return "noop";
    if (diff.baseSeq > currentSeq) return "resync";
    // Apply title side-effect early so it lands regardless of which
    // branch (dim-changed vs same-dims) takes the cells path.
    if (diff.title !== undefined) {
      terminalStore.setBufferTitle(diff.bufferId, diff.title);
    }
    const dimsChanged =
      diff.rowsTotal !== current.rows || diff.colsTotal !== current.cols;
    if (dimsChanged) {
      // Dim changes invalidate every cached row; rebuild from scratch
      // and force a full repaint on the next frame. Resize is rare
      // (window edge drag, font change) so the alloc here is fine.
      const cells: GridCell[] = new Array(diff.rowsTotal * diff.colsTotal);
      for (let i = 0; i < cells.length; i++) {
        cells[i] = { ch: 0, width: 1 };
      }
      const cols = diff.colsTotal;
      applyScrolls(cells, cols, diff);
      for (const row of diff.rows) {
        const base = row.row * cols;
        for (let i = 0; i < row.cells.length; i++) {
          cells[base + i] = row.cells[i];
        }
      }
      setGrid({
        ...current,
        rows: diff.rowsTotal,
        cols: diff.colsTotal,
        cells,
        cursorRow: diff.cursorRow,
        cursorCol: diff.cursorCol,
        cursorVisible: diff.cursorVisible,
        cursorKeysApp: diff.cursorKeysApp,
        bracketedPaste: diff.bracketedPaste,
        mouseTracking: diff.mouseTracking ?? 0,
        mouseSgr: diff.mouseSgr ?? false,
      });
      setGridSeq(diff.seq);
      needsFullRepaint = true;
      pendingRepaintRows.clear();
      pendingCanvasScrolls = [];
      // Resize reflows scrollback rows to the new col count on the
      // backend (terminal.rs). Cached rows still hold the old col
      // count, so painting them after a dim change would draw
      // wrong-width rows. Drop the cache + in-flight set; the next
      // ensureScrollbackForViewport will repopulate from the backend.
      scrollbackCache.clear();
      scrollbackInFlight.clear();
      return "ok";
    }
    // Same dims: mutate the existing cells in place. Track every row
    // touched (scrolled, written, or holding the old/new cursor) so the
    // partial-repaint pass redraws exactly those rows and nothing else.
    const cells = current.cells;
    const cols = current.cols;
    const scrolls = diff.scrolls;
    if (scrolls && scrolls.length > 0) {
      applyScrolls(cells, cols, diff);
      if (selection()) {
        // Keep selection overlays anchored to screen coordinates. Blitting
        // would copy the old translucent pixels along with the scrolled
        // content, so fall back to repainting the affected region.
        for (const scroll of scrolls) {
          const top = Math.max(0, Math.min(scroll.top, current.rows - 1));
          const bottom = Math.max(0, Math.min(scroll.bottom, current.rows - 1));
          for (let r = top; r <= bottom; r++) {
            pendingRepaintRows.add(r);
          }
        }
      } else {
        for (const scroll of scrolls) {
          shiftPendingRepaintRowsForScroll(scroll, current.rows);
        }
        pendingCanvasScrolls.push(...scrolls);
      }
    }
    for (const row of diff.rows) {
      const base = row.row * cols;
      for (let i = 0; i < row.cells.length; i++) {
        cells[base + i] = row.cells[i];
      }
      pendingRepaintRows.add(row.row);
    }
    // Cursor moves between cells without dirtying either row's contents.
    // Both the previous and new cursor rows must repaint so the old
    // cursor block clears and the new one shows up.
    pendingRepaintRows.add(current.cursorRow);
    pendingRepaintRows.add(diff.cursorRow);
    current.cursorRow = diff.cursorRow;
    current.cursorCol = diff.cursorCol;
    current.cursorVisible = diff.cursorVisible;
    current.cursorKeysApp = diff.cursorKeysApp;
    current.bracketedPaste = diff.bracketedPaste;
    current.mouseTracking = diff.mouseTracking ?? 0;
    current.mouseSgr = diff.mouseSgr ?? false;
    setGridSeq(diff.seq);
    // Track scrollback growth and keep the user's view anchored to the
    // same historical rows. When new lines scroll off in the live grid,
    // the absolute scrollback row indices we are showing should not
    // move - so bump the offset by however much the history grew. If
    // the user is at the live view (offset 0), this is a no-op.
    const nextLen = diff.scrollbackLen ?? 0;
    const nextBase = diff.scrollbackBase ?? 0;
    const prevLen = scrollbackLen();
    const prevBase = scrollbackBase();
    if (nextLen !== prevLen || nextBase !== prevBase) {
      setScrollbackLen(nextLen);
      setScrollbackBase(nextBase);
      pruneScrollbackCache(nextBase, nextLen);
      const offset = viewportOffset();
      const prevEnd = prevBase + prevLen;
      const nextEnd = nextBase + nextLen;
      if (offset > 0 && nextEnd > prevEnd) {
        const delta = nextEnd - prevEnd;
        const next = Math.min(offset + delta, nextLen);
        if (next !== offset) {
          setViewportOffset(next);
          needsFullRepaint = true;
        }
      } else if (offset > nextLen) {
        // Scrollback shrank past our position (e.g. resize trim). Clamp.
        setViewportOffset(nextLen);
        needsFullRepaint = true;
      }
    }
    return "ok";
  };

  // Apply scroll deltas to a flat cells array in place. Shared between
  // the dim-changed (fresh array) and same-dim (existing array) paths.
  const applyScrolls = (
    cells: GridCell[],
    cols: number,
    diff: GridDiffEvent,
  ) => {
    if (!diff.scrolls || diff.scrolls.length === 0) return;
    for (const scroll of diff.scrolls) {
      const top = Math.max(0, Math.min(scroll.top, diff.rowsTotal - 1));
      const bottom = Math.max(0, Math.min(scroll.bottom, diff.rowsTotal - 1));
      if (bottom < top || scroll.delta === 0) continue;
      const height = bottom - top + 1;
      const count = Math.min(Math.abs(scroll.delta), height);
      const start = top * cols;
      const end = (bottom + 1) * cols;
      const blankCount = count * cols;
      if (scroll.delta > 0) {
        cells.copyWithin(start, start + blankCount, end);
        for (let i = end - blankCount; i < end; i++) {
          cells[i] = { ch: 0, width: 1 };
        }
      } else {
        cells.copyWithin(start + blankCount, start, end - blankCount);
        for (let i = start; i < start + blankCount; i++) {
          cells[i] = { ch: 0, width: 1 };
        }
      }
    }
  };

  const shiftPendingRepaintRowsForScroll = (
    scroll: GridScroll,
    rowsTotal: number,
  ) => {
    const top = Math.max(0, Math.min(scroll.top, rowsTotal - 1));
    const bottom = Math.max(0, Math.min(scroll.bottom, rowsTotal - 1));
    if (bottom < top || scroll.delta === 0) return;
    const height = bottom - top + 1;
    const count = Math.min(Math.abs(scroll.delta), height);
    const shifted = new Set<number>();
    for (const row of pendingRepaintRows) {
      if (row < top || row > bottom) {
        shifted.add(row);
      } else if (scroll.delta > 0) {
        const next = row - count;
        if (next >= top) shifted.add(next);
      } else {
        const next = row + count;
        if (next <= bottom) shifted.add(next);
      }
    }
    pendingRepaintRows.clear();
    for (const row of shifted) {
      pendingRepaintRows.add(row);
    }
  };

  /**
   * Snap the viewport back to the live screen. Called whenever user
   * input would target the live grid (typing, paste, Ctrl+C signal),
   * matching xterm's sticky-bottom behavior.
   */
  const snapToBottom = () => {
    if (viewportOffset() === 0) return;
    setViewportOffset(0);
    needsFullRepaint = true;
    scheduleFrame();
  };

  /**
   * Fetch a window of scrollback rows starting at monotonic row id
   * `startRowId`. The backend command still takes a start index relative
   * to its current oldest retained row; scrollbackBase bridges that shifting
   * index space back into stable cache keys.
   */
  const fetchScrollbackWindow = async (startRowId: number, count: number) => {
    const id = activeBufferId();
    if (!id || count <= 0) return;
    const base = scrollbackBase();
    const len = scrollbackLen();
    if (startRowId < base || startRowId >= base + len) return;
    const relativeStart = startRowId - base;
    const boundedCount = Math.min(count, base + len - startRowId);
    let marked = 0;
    for (let i = 0; i < boundedCount; i++) {
      const rowId = startRowId + i;
      if (scrollbackInFlight.has(rowId)) break;
      scrollbackInFlight.add(rowId);
      marked++;
    }
    if (marked === 0) return;
    try {
      const win = await invoke<ScrollbackWindow>("terminal_grid_scrollback", {
        bufferId: id,
        start: relativeStart,
        count: marked,
      });
      if (id !== activeBufferId()) return;
      const nextBase = win.scrollbackBase ?? 0;
      const prevBase = scrollbackBase();
      for (let i = 0; i < win.rows.length; i++) {
        scrollbackCache.set(nextBase + win.start + i, win.rows[i]);
      }
      if (win.scrollbackLen !== scrollbackLen() || nextBase !== prevBase) {
        const prevLen = scrollbackLen();
        setScrollbackLen(win.scrollbackLen);
        setScrollbackBase(nextBase);
        pruneScrollbackCache(nextBase, win.scrollbackLen);
        const offset = viewportOffset();
        const prevEnd = prevBase + prevLen;
        const nextEnd = nextBase + win.scrollbackLen;
        if (offset > 0 && nextEnd > prevEnd) {
          setViewportOffset(
            Math.min(offset + nextEnd - prevEnd, win.scrollbackLen),
          );
        } else if (offset > win.scrollbackLen) {
          setViewportOffset(win.scrollbackLen);
        }
      }
      if (viewportOffset() > 0) {
        needsFullRepaint = true;
        scheduleFrame();
      }
    } catch {
      // Buffer killed mid-flight; nothing useful to do.
    } finally {
      for (let i = 0; i < marked; i++) {
        scrollbackInFlight.delete(startRowId + i);
      }
    }
  };

  /**
   * Ensure the cache covers every scrollback row visible in the
   * current viewport. Issues at most one fetch per call (covering the
   * contiguous missing window) - the IPC overhead per round-trip is
   * higher than fetching slightly more rows than strictly needed.
   */
  const ensureScrollbackForViewport = () => {
    const offset = viewportOffset();
    if (offset === 0) return;
    const len = scrollbackLen();
    const g = grid();
    if (!g || len === 0) return;
    const visibleRows = Math.min(offset, g.rows);
    const base = scrollbackBase();
    const firstRowId = base + Math.max(0, len - offset);
    let missingStart = -1;
    let missingEnd = -1;
    for (let i = 0; i < visibleRows; i++) {
      const rowId = firstRowId + i;
      if (rowId >= base + len) break;
      if (!scrollbackCache.has(rowId) && !scrollbackInFlight.has(rowId)) {
        if (missingStart === -1) missingStart = rowId;
        missingEnd = rowId;
      }
    }
    if (missingStart !== -1) {
      void fetchScrollbackWindow(missingStart, missingEnd - missingStart + 1);
    }
  };

  /**
   * Build the SGR-1006 mouse report for `(button, x, y)`. `released`
   * picks the trailing letter (M for press/motion, m for release).
   * Coordinates are 1-based in the protocol.
   *
   * Button encoding:
   *   0=left, 1=middle, 2=right, 3=release-of-any (legacy x10 only)
   *   64=wheel-up, 65=wheel-down
   *   +4 shift, +8 meta/alt, +16 ctrl
   *   +32 motion (added by caller for drag/move events)
   */
  const encodeMouseSgr = (
    button: number,
    col: number,
    row: number,
    released: boolean,
  ): string => {
    const x = Math.max(1, col + 1);
    const y = Math.max(1, row + 1);
    return `\x1b[<${button};${x};${y}${released ? "m" : "M"}`;
  };

  const mouseEventModifiers = (e: MouseEvent): number => {
    let m = 0;
    if (e.shiftKey) m += 4;
    if (e.metaKey || e.altKey) m += 8;
    if (e.ctrlKey) m += 16;
    return m;
  };

  const sendMouseReport = (
    bufferId: string,
    button: number,
    col: number,
    row: number,
    released: boolean,
  ) => {
    void terminalStore.write(
      bufferId,
      encodeMouseSgr(button, col, row, released),
    );
  };

  // Tracks the button currently held during a mouse-tracking drag.
  // null when no button is down. Used for 1002/1003 drag reports and
  // to know which release code to send on mouseup.
  let mouseTrackingButton: number | null = null;
  let mouseTrackingLastCell: CellPos | null = null;

  const onWindowMouseMoveTracking = (e: MouseEvent) => {
    const g = grid();
    // Grid may flip to null mid-tracking when the active thread is
    // killed or switched away; without explicit detach the window
    // listeners would keep firing until onCleanup. Same path as the
    // mode-turned-off branch below.
    if (!g) {
      detachMouseTracking();
      return;
    }
    const tracking = g.mouseTracking ?? 0;
    if (tracking === 0 || !(g.mouseSgr ?? false)) {
      detachMouseTracking();
      return;
    }
    // 1002 reports motion only while a button is held; 1003 reports
    // any motion. Either way, dedupe by cell so a fast drag doesn't
    // flood the PTY with one report per pixel.
    if (tracking === 1002 && mouseTrackingButton === null) return;
    const cell = canvasMouseToCell(e);
    if (!cell) return;
    if (
      mouseTrackingLastCell &&
      cell.row === mouseTrackingLastCell.row &&
      cell.col === mouseTrackingLastCell.col
    ) {
      return;
    }
    mouseTrackingLastCell = cell;
    const id = activeBufferId();
    if (!id) return;
    const baseButton = mouseTrackingButton ?? 3; // 3 = no-button motion
    const button = baseButton + 32 + mouseEventModifiers(e);
    sendMouseReport(id, button, cell.col, cell.row, false);
  };

  const onSurfaceMouseMoveTracking = (e: MouseEvent) => {
    const g = grid();
    const tracking = g?.mouseTracking ?? 0;
    if (
      tracking !== 1003 ||
      !(g?.mouseSgr ?? false) ||
      mouseTrackingButton !== null ||
      e.shiftKey
    ) {
      return;
    }
    onWindowMouseMoveTracking(e);
  };

  const onWindowMouseUpTracking = (e: MouseEvent) => {
    const g = grid();
    const id = activeBufferId();
    if (g && id && (g.mouseTracking ?? 0) !== 0 && (g.mouseSgr ?? false)) {
      const cell = canvasMouseToCell(e) ??
        mouseTrackingLastCell ?? { row: 0, col: 0 };
      const button = (mouseTrackingButton ?? e.button) + mouseEventModifiers(e);
      sendMouseReport(id, button, cell.col, cell.row, true);
    }
    detachMouseTracking();
  };

  const detachMouseTracking = () => {
    mouseTrackingButton = null;
    mouseTrackingLastCell = null;
    window.removeEventListener("mousemove", onWindowMouseMoveTracking);
    window.removeEventListener("mouseup", onWindowMouseUpTracking);
  };

  const onSurfaceWheel = (e: WheelEvent) => {
    const g = grid();
    const tracking = g?.mouseTracking ?? 0;
    const sgr = g?.mouseSgr ?? false;
    // App is mouse-tracking AND has SGR encoding on (without 1006 we
    // can't deliver wheel buttons safely beyond column 223). Forward
    // wheel as button 64 (up) / 65 (down) and stay out of scrollback.
    if (tracking !== 0 && sgr) {
      const id = activeBufferId();
      const cell = canvasMouseToCell(e);
      if (id && cell) {
        const wheelButton = (e.deltaY < 0 ? 64 : 65) + mouseEventModifiers(e);
        e.preventDefault();
        sendMouseReport(id, wheelButton, cell.col, cell.row, false);
      }
      return;
    }
    const len = scrollbackLen();
    const offset = viewportOffset();
    // No history and already at live: nothing to consume; let the page
    // scroll if any ancestor cares. We don't preventDefault unless we
    // actually changed the viewport.
    if (len === 0 && offset === 0) return;
    // Browsers report deltaY in pixels for line/page wheel modes when
    // the event target is a non-scrollable element; the values are
    // already normalized. Negative = wheel-up = scroll back into history.
    wheelAccumulator += e.deltaY;
    const rowsDelta = Math.trunc(wheelAccumulator / WHEEL_PIXELS_PER_ROW);
    if (rowsDelta === 0) return;
    wheelAccumulator -= rowsDelta * WHEEL_PIXELS_PER_ROW;
    // Up (negative deltaY) increases offset toward len; down decreases
    // toward 0. clamp.
    const next = Math.max(0, Math.min(len, offset - rowsDelta));
    if (next !== offset) {
      e.preventDefault();
      setViewportOffset(next);
      needsFullRepaint = true;
      ensureScrollbackForViewport();
      scheduleFrame();
    }
  };

  /**
   * Schedule a single rAF that drains queued diffs and paints. Multiple
   * scheduleFrame calls within one animation frame collapse to one
   * paint - this is the load-bearing piece of the coalescing fix.
   */
  const scheduleFrame = () => {
    if (rafHandle !== null) return;
    rafHandle = requestAnimationFrame(() => {
      rafHandle = null;
      runFrame();
    });
  };

  const runFrame = () => {
    if (pendingDiffs.length > 0) {
      const queued = pendingDiffs;
      pendingDiffs = [];
      let resync = false;
      for (const diff of queued) {
        const result = applyDiffInternal(diff);
        if (result === "resync") {
          resync = true;
          break;
        }
      }
      if (resync) {
        pendingDiffs = [];
        pendingRepaintRows.clear();
        pendingCanvasScrolls = [];
        needsFullRepaint = true;
        void fetchGridSnapshot();
        return;
      }
    }
    paintCanvas();
  };

  const measureCell = () => {
    if (!canvasRef) return;
    const ctx = canvasRef.getContext("2d");
    if (!ctx) return;
    const fontSize = terminalFontSize();
    ctx.font = fontForAttrs(0, fontSize);
    const m = ctx.measureText("M");
    // Cell width: the FONT's ideographic width if exposed, else "M"'s
    // bounding box. Both are stable for monospace.
    const w = m.width;
    // Cell HEIGHT: must be the FONT's design metrics (fontBoundingBox*),
    // not the GLYPH's ink box (actualBoundingBox*). "M" has no descender
    // so actualBoundingBoxDescent is essentially 0, which produced a cell
    // height roughly equal to cap-height and made descenders from one
    // row overlap the caps of the next row (visible bug in vim/htop).
    // Use fontBoundingBoxAscent + fontBoundingBoxDescent when the engine
    // exposes them (modern WebKit + Chromium do), and fall back to a
    // line-height multiplier of 1.4 - the conventional terminal value
    // that gives breathing room without looking double-spaced.
    const fbAscent = m.fontBoundingBoxAscent;
    const fbDescent = m.fontBoundingBoxDescent;
    // #2006 — claude threads bump line-height to 1.6 for breathing room
    // matching the rest of the app's typography. The fontBoundingBox path
    // gives ~1.2-1.3, so override unconditionally for claude. Plain
    // Terminal threads keep the 1.4 convention via font metrics or fallback.
    const h = isClaudeCli()
      ? Math.ceil(fontSize * 1.6)
      : fbAscent !== undefined && fbDescent !== undefined
        ? Math.ceil(fbAscent + fbDescent)
        : Math.ceil(fontSize * 1.4);
    setCellW(w);
    setCellH(h);
  };

  /**
   * Compute the integer cols/rows that fit in the surface element and push
   * them to the PTY via terminal_resize. Skipped if the cell metrics aren't
   * known yet or no buffer is active.
   */
  const pushResize = () => {
    if (!surfaceRef) return;
    if (surfaceRef.closest("[aria-hidden='true']")) return;
    const w = cellW();
    const h = cellH();
    if (w === 0 || h === 0) return;
    const id = activeBufferId();
    if (!id) return;
    const width = surfaceRef.clientWidth;
    const height = surfaceRef.clientHeight;
    if (width <= 0 || height <= 0) return;
    const cols = Math.max(2, Math.floor(width / w));
    const rows = Math.max(1, Math.floor(height / h));
    const current = buffer();
    if (current && current.cols === cols && current.rows === rows) {
      return;
    }
    // Resize bumps grid.seq on the Rust side and marks every row
    // dirty; the next diff event carries the new dimensions. No need
    // to manually fetch a snapshot afterwards.
    void terminalStore.resize(id, cols, rows);
  };

  /**
   * Translate a mouse event's clientX/clientY into grid (row, col)
   * coordinates. Returns null if cell metrics aren't known yet (font
   * not measured) or the surface element is gone. Both row and col
   * are clamped to grid bounds so a drag past the canvas edges
   * extends to the row/col edge instead of going negative.
   */
  const canvasMouseToCell = (e: MouseEvent): CellPos | null => {
    if (!surfaceRef) return null;
    const w = cellW();
    const h = cellH();
    if (w === 0 || h === 0) return null;
    const g = grid();
    const rect = surfaceRef.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;
    const maxRow = g ? g.rows - 1 : Number.MAX_SAFE_INTEGER;
    const maxCol = g ? g.cols - 1 : Number.MAX_SAFE_INTEGER;
    return {
      row: Math.max(0, Math.min(maxRow, Math.floor(y / h))),
      col: Math.max(0, Math.min(maxCol, Math.floor(x / w))),
    };
  };

  const onWindowMouseMove = (e: MouseEvent) => {
    if (!dragAnchor) return;
    e.preventDefault();
    const cell = canvasMouseToCell(e);
    if (!cell) return;
    if (cell.row !== dragAnchor.row || cell.col !== dragAnchor.col) {
      dragMoved = true;
    }
    setSelection({ anchor: dragAnchor, head: cell });
  };

  const onWindowMouseUp = (e?: MouseEvent) => {
    e?.preventDefault();
    window.removeEventListener("mousemove", onWindowMouseMove);
    window.removeEventListener("mouseup", onWindowMouseUp);
    if (!dragMoved) {
      // Plain click without drag - clear any prior selection so the
      // user can dismiss a selection by clicking on the canvas.
      setSelection(null);
    }
    dragAnchor = null;
    dragMoved = false;
  };

  const stopTerminalSelectionDrag = () => {
    window.removeEventListener("mousemove", onWindowMouseMove);
    window.removeEventListener("mouseup", onWindowMouseUp);
    dragAnchor = null;
    dragMoved = false;
  };

  const preventNativeSelection = (event: Event) => {
    event.preventDefault();
  };

  const onSurfaceMouseDown = (e: MouseEvent) => {
    // preventScroll: the surface can be a sub-pixel taller than its flex
    // container; without this, the first focus call after open scrolls
    // the canvas up and clips the top rows out of view.
    surfaceRef?.focus({ preventScroll: true });
    const cell = canvasMouseToCell(e);
    if (!cell) return;
    const g = grid();
    const tracking = g?.mouseTracking ?? 0;
    const sgr = g?.mouseSgr ?? false;
    // Mouse tracking takes priority over selection - that's how vim,
    // tmux, htop receive clicks. Shift bypasses tracking so the user
    // can still drag-select text in a tracked app (xterm convention).
    if (tracking !== 0 && sgr && !e.shiftKey) {
      const id = activeBufferId();
      if (id) {
        e.preventDefault();
        const button = e.button + mouseEventModifiers(e);
        sendMouseReport(id, button, cell.col, cell.row, false);
        mouseTrackingButton = button & 0x03; // strip modifiers for state
        mouseTrackingLastCell = cell;
        // Always listen for release so the app sees the matching m.
        // Drag motion only fires for 1002 / 1003 modes (handler checks).
        window.addEventListener("mousemove", onWindowMouseMoveTracking);
        window.addEventListener("mouseup", onWindowMouseUpTracking);
      }
      return;
    }
    if (e.button !== 0) return; // left button only for selection
    e.preventDefault();
    dragAnchor = cell;
    dragMoved = false;
    setSelection({ anchor: cell, head: cell });
    // Listen on window so a drag that leaves the canvas still tracks.
    window.addEventListener("mousemove", onWindowMouseMove);
    window.addEventListener("mouseup", onWindowMouseUp);
  };

  const clearCanvas = () => {
    if (!canvasRef) return;
    canvasPixelWidth = 0;
    canvasPixelHeight = 0;
    canvasCssWidth = 0;
    canvasCssHeight = 0;
    canvasRef.width = 0;
    canvasRef.height = 0;
    canvasRef.style.width = "0px";
    canvasRef.style.height = "0px";
  };

  // Paint logic. Called from the rAF in runFrame() and from the
  // signal-watching effect below for selection/metric changes. Repaints
  // ALL rows when needsFullRepaint is set (snapshot reset, resize,
  // selection change, cell-metric change); otherwise only the rows in
  // pendingRepaintRows. The partial path is the primary win for
  // sustained torrents - it bounds frame work to O(changed_rows × cols)
  // instead of O(rows × cols).
  const paintCanvas = () => {
    const g = grid();
    const w = cellW();
    const h = cellH();
    if (!g) {
      clearCanvas();
      pendingRepaintRows.clear();
      pendingCanvasScrolls = [];
      needsFullRepaint = true;
      return;
    }
    if (!canvasRef || w === 0 || h === 0) return;
    const dpr = window.devicePixelRatio || 1;
    const pxW = g.cols * w;
    const pxH = g.rows * h;
    const nextPixelWidth = Math.round(pxW * dpr);
    const nextPixelHeight = Math.round(pxH * dpr);
    // Resizing the canvas backing store clears its contents, so it
    // implicitly forces a full repaint of every row.
    if (
      canvasPixelWidth !== nextPixelWidth ||
      canvasPixelHeight !== nextPixelHeight
    ) {
      canvasRef.width = nextPixelWidth;
      canvasRef.height = nextPixelHeight;
      canvasPixelWidth = nextPixelWidth;
      canvasPixelHeight = nextPixelHeight;
      needsFullRepaint = true;
    }
    if (canvasCssWidth !== pxW) {
      canvasRef.style.width = `${pxW}px`;
      canvasCssWidth = pxW;
    }
    if (canvasCssHeight !== pxH) {
      canvasRef.style.height = `${pxH}px`;
      canvasCssHeight = pxH;
    }
    const ctx = canvasRef.getContext("2d");
    if (!ctx) return;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    // Scrollback view: when offset > 0 the top `offset` visible rows
    // come from history and the bottom rows come from the live grid.
    // Defer to full repaint and skip the blit/partial paths - they
    // assume a 1:1 visible<->live mapping.
    const offset = viewportOffset();
    const inScrollback = offset > 0;
    const fontSize = terminalFontSize();
    if (inScrollback) {
      pendingCanvasScrolls = [];
      pendingRepaintRows.clear();
      needsFullRepaint = true;
    }
    const cellAt = (visibleRow: number, col: number): GridCell | undefined => {
      if (!inScrollback) return g.cells[visibleRow * g.cols + col];
      if (visibleRow < offset) {
        const rowId = scrollbackBase() + scrollbackLen() - offset + visibleRow;
        if (rowId < scrollbackBase()) return undefined;
        return scrollbackCache.get(rowId)?.[col];
      }
      const liveRow = visibleRow - offset;
      if (liveRow >= g.rows) return undefined;
      return g.cells[liveRow * g.cols + col];
    };

    // Decide which rows to paint this frame. A full repaint clears the
    // entire backing store first; a partial repaint clears only the
    // strip for each dirty row before painting it.
    const sel = selection();
    let rowsToPaint: Iterable<number>;
    if (needsFullRepaint) {
      pendingCanvasScrolls = [];
      ctx.fillStyle = COLOR_BG;
      ctx.fillRect(0, 0, pxW, pxH);
      rowsToPaint = Array.from({ length: g.rows }, (_, row) => row);
    } else {
      for (const scroll of pendingCanvasScrolls) {
        const top = Math.max(0, Math.min(scroll.top, g.rows - 1));
        const bottom = Math.max(0, Math.min(scroll.bottom, g.rows - 1));
        if (bottom < top || scroll.delta === 0) continue;
        const height = bottom - top + 1;
        const count = Math.min(Math.abs(scroll.delta), height);
        const rowsToCopy = height - count;
        const sourceRow = scroll.delta > 0 ? top + count : top;
        const destRow = scroll.delta > 0 ? top : top + count;
        ctx.save();
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        if (rowsToCopy > 0) {
          ctx.drawImage(
            canvasRef,
            0,
            Math.round(sourceRow * h * dpr),
            canvasPixelWidth,
            Math.round(rowsToCopy * h * dpr),
            0,
            Math.round(destRow * h * dpr),
            canvasPixelWidth,
            Math.round(rowsToCopy * h * dpr),
          );
        }
        if (scroll.delta > 0) {
          for (let r = bottom - count + 1; r <= bottom; r++) {
            pendingRepaintRows.add(r);
          }
        } else {
          for (let r = top; r < top + count; r++) {
            pendingRepaintRows.add(r);
          }
        }
        // Clear any uncovered source strip immediately. The row repaint below
        // paints inserted rows, but this keeps the backing store correct even
        // if the backend emits a scroll without explicit row payloads.
        ctx.fillStyle = COLOR_BG;
        ctx.fillRect(
          0,
          Math.round((scroll.delta > 0 ? bottom - count + 1 : top) * h * dpr),
          canvasPixelWidth,
          Math.round(count * h * dpr),
        );
        ctx.restore();
      }
      pendingCanvasScrolls = [];
      // The selection overlay has to repaint along with any row in its
      // range, otherwise scrolled-in rows under a live selection lose
      // the highlight. Cheap: a Set merge.
      if (sel) {
        const [s, e] = normalizeSelection(sel);
        for (let r = s.row; r <= e.row && r < g.rows; r++) {
          pendingRepaintRows.add(r);
        }
      }
      rowsToPaint = pendingRepaintRows;
    }

    // Pass 1: backgrounds. For non-reverse cells, skip default-bg so we
    // don't repaint over the cleared canvas. For reverse cells, the
    // background is the cell's foreground (or COLOR_FG when fg is also
    // default) - ALWAYS paint it, even when fg is default, otherwise
    // `\x1b[7mtext` would render with the canvas's normal background
    // and the swap would be invisible. Partial repaints clear the row
    // strip first since we no longer have a clean canvas under it.
    for (const r of rowsToPaint) {
      if (r >= g.rows) continue;
      const y = r * h;
      if (!needsFullRepaint) {
        ctx.fillStyle = COLOR_BG;
        ctx.fillRect(0, y, pxW, h);
      }
      for (let c = 0; c < g.cols; c++) {
        const cell = cellAt(r, c);
        if (!cell) continue;
        const reverse = ((cell.attrs ?? 0) & ATTR_REVERSE) !== 0;
        if (reverse) {
          ctx.fillStyle = resolveColor(cell.fg, COLOR_FG);
          ctx.fillRect(c * w, y, w, h);
        } else {
          const bgPacked = cell.bg;
          if (bgPacked === undefined || bgPacked === COLOR_DEFAULT) continue;
          ctx.fillStyle = resolveColor(bgPacked, COLOR_BG, "bg");
          ctx.fillRect(c * w, y, w, h);
        }
      }
    }

    // Pass 2: glyphs + decorations.
    ctx.textBaseline = "top";
    let lastFont = "";
    let lastFill = "";
    for (const r of rowsToPaint) {
      if (r >= g.rows) continue;
      const y = r * h;
      for (let c = 0; c < g.cols; c++) {
        const cell = cellAt(r, c);
        if (!cell) continue;
        const attrs = cell.attrs ?? 0;
        const reverse = (attrs & ATTR_REVERSE) !== 0;
        const fgPacked = reverse ? cell.bg : cell.fg;
        const x = c * w;

        const font = fontForAttrs(attrs, fontSize);
        if (font !== lastFont) {
          ctx.font = font;
          lastFont = font;
        }
        const defaultFill = reverse ? COLOR_BG : COLOR_FG;
        const fill = resolveColor(fgPacked, defaultFill);
        if ((attrs & ATTR_DIM) !== 0) {
          ctx.globalAlpha = DIM_ALPHA;
        }
        const drawGlyph = cell.ch !== 0 && cell.width !== 0;
        if (drawGlyph) {
          if (fill !== lastFill) {
            ctx.fillStyle = fill;
            lastFill = fill;
          }
          ctx.fillText(String.fromCodePoint(cell.ch), x, y);
        }
        if ((attrs & ATTR_UNDERLINE) !== 0) {
          if (fill !== lastFill) {
            ctx.fillStyle = fill;
            lastFill = fill;
          }
          ctx.fillRect(x, y + h - 2, w, 1);
        }
        if ((attrs & ATTR_STRIKE) !== 0) {
          if (fill !== lastFill) {
            ctx.fillStyle = fill;
            lastFill = fill;
          }
          ctx.fillRect(x, y + Math.floor(h / 2), w, 1);
        }
        if ((attrs & ATTR_DIM) !== 0) {
          ctx.globalAlpha = 1.0;
        }
      }
    }

    // Selection overlay over repainted rows. Drawn AFTER glyphs so the
    // highlight blends with text but BEFORE the cursor block.
    if (sel) {
      const [start, end] = normalizeSelection(sel);
      // #2006 — claude threads use sky-400 alpha to tie selection to the
      // Subscription pill + ring/glow. Plain Terminal threads keep the
      // muted blue. The createEffect at the bottom of the component tracks
      // isClaudeCli so a thread switch triggers a full repaint.
      ctx.fillStyle = isClaudeCli()
        ? SELECTION_OVERLAY_FILL_CLAUDE
        : SELECTION_OVERLAY_FILL;
      for (const r of rowsToPaint) {
        if (r < start.row || r > end.row || r >= g.rows) continue;
        const startCol = r === start.row ? start.col : 0;
        const endCol = r === end.row ? end.col : g.cols - 1;
        const x = startCol * w;
        const y = r * h;
        const width = (Math.min(endCol, g.cols - 1) - startCol + 1) * w;
        if (width > 0) {
          ctx.fillRect(x, y, width, h);
        }
      }
    }

    // Cursor: inverted block at (cursorRow, cursorCol). The cursor row
    // is always in pendingRepaintRows after applyDiffInternal so it
    // gets cleared and repainted along with everything else. In
    // scrollback view the cursor is hidden - it tracks the LIVE
    // bottom and would be misleading if drawn over historical text.
    const cursorVisible = (g.cursorVisible ?? true) && !inScrollback;
    if (cursorVisible && g.cursorRow < g.rows && g.cursorCol < g.cols) {
      const cell = g.cells[g.cursorRow * g.cols + g.cursorCol];
      const cursorWidth = (cell?.width ?? 1) === 2 ? 2 * w : w;
      const cx = g.cursorCol * w;
      const cy = g.cursorRow * h;
      // #2006 — claude threads use a sky-400 cursor; plain Terminal stays
      // on the off-white default. Inverted-glyph foreground stays dark in
      // both cases so the cell character reads against either fill.
      ctx.fillStyle = isClaudeCli() ? COLOR_CURSOR_CLAUDE : COLOR_CURSOR;
      ctx.fillRect(cx, cy, cursorWidth, h);
      if (cell && cell.ch !== 0 && cell.width > 0) {
        ctx.fillStyle = isClaudeCli()
          ? COLOR_CURSOR_FG_CLAUDE
          : COLOR_CURSOR_FG;
        ctx.font = fontForAttrs(cell.attrs ?? 0, fontSize);
        ctx.fillText(String.fromCodePoint(cell.ch), cx, cy);
      }
    }

    pendingRepaintRows.clear();
    needsFullRepaint = false;
  };

  // Trigger a full repaint when something orthogonal to the diff stream
  // changes: snapshot reset (grid signal swapped), selection change,
  // cell-metric change. The effect doesn't paint inline - it just
  // schedules a frame, so multiple signal changes in one tick collapse
  // to one paint.
  createEffect(() => {
    void grid();
    void selection();
    void cellW();
    void cellH();
    void terminalFontSize();
    // #2006 — track isClaudeCli so cursor + selection repaint with the
    // sky-400 vs default palette when the user switches thread types.
    void isClaudeCli();
    needsFullRepaint = true;
    scheduleFrame();
  });

  createEffect(() => {
    void terminalFontSize();
    // #2006 — track isClaudeCli so line-height (1.6 vs 1.4) recomputes when
    // the user switches between a Claude CLI thread and a plain Terminal.
    void isClaudeCli();
    measureCell();
    pushResize();
  });

  // Refetch immediately when the active buffer changes (thread switch),
  // so the canvas shows the freshly-selected terminal's state without
  // waiting for the next output event. Also clear the grid signal up
  // front so a switch never momentarily shows the previous terminal's
  // content while the new fetch is in flight.
  createEffect(
    on(activeBufferId, (id) => {
      // Per-buffer state - flush so the next buffer starts clean.
      pendingDiffs = [];
      pendingRepaintRows.clear();
      pendingCanvasScrolls = [];
      needsFullRepaint = true;
      scrollbackCache.clear();
      scrollbackInFlight.clear();
      setScrollbackLen(0);
      setScrollbackBase(0);
      setViewportOffset(0);
      wheelAccumulator = 0;
      detachMouseTracking();
      setGrid(null);
      setGridSeq(0);
      setSelection(null);
      if (!id) return;
      pushResize();
      void fetchGridSnapshot();
    }),
  );

  // Mirror the canvas selection into an off-screen DOM range so the system
  // Edit > Copy menu (Tauri PredefinedMenuItem::copy) — which only fires a
  // browser `copy` event when window.getSelection() is non-empty — copies
  // the right text. Pointing the document selection at our hidden span has
  // no visible effect (the span is positioned at -10000px) and the canvas
  // owns its own selection rendering, so this purely serves the system
  // clipboard path. Cleared when the terminal selection is empty so we
  // never hijack a real selection in the chat or Monaco editor. #2091.
  createEffect(() => {
    const sel = selection();
    const g = grid();
    const mirror = selectionMirrorRef;
    if (!mirror) return;
    const docSel = window.getSelection();
    if (!docSel) return;
    const clearMirroredDomSelection = () => {
      const mirroredNode = mirror.firstChild;
      const ownsSelection =
        !!mirroredNode && docSel.containsNode(mirroredNode, true);
      mirror.textContent = "";
      if (ownsSelection) {
        docSel.removeAllRanges();
      }
    };
    if (!sel || !g) {
      clearMirroredDomSelection();
      return;
    }
    const text = selectionText(g, sel);
    if (!text) {
      clearMirroredDomSelection();
      return;
    }
    mirror.textContent = text;
    const node = mirror.firstChild;
    if (!node) return;
    docSel.removeAllRanges();
    docSel.setBaseAndExtent(node, 0, node, text.length);
  });

  /**
   * Right-click handler for the terminal surface. Replaces the default
   * Tauri webview menu (which only ships "Reload" / "Inspect Element" in
   * dev and nothing in production) with a discoverable Copy / Paste /
   * Select All popover. Cmd+C still owns the keyboard path; this is the
   * mouse-driven complement. #2091.
   */
  const openContextMenu = (event: MouseEvent) => {
    event.preventDefault();
    stopTerminalSelectionDrag();
    setCtxMenu({ x: event.clientX, y: event.clientY });
  };

  const closeContextMenu = () => setCtxMenu(null);

  const contextMenuItems = (): ContextMenuItem[] => {
    const current = buffer();
    const running = current?.status === "running";
    const sel = selection();
    const g = grid();
    const hasSelection = !!sel && !!g && selectionText(g, sel).length > 0;
    return [
      {
        label: "Copy",
        shortcut: copyShortcutLabel(),
        disabled: !hasSelection,
        onClick: () => {
          if (!sel || !g) return;
          void writeClipboard(selectionText(g, sel));
        },
      },
      {
        label: "Paste",
        shortcut: pasteShortcutLabel(),
        disabled: !running,
        onClick: () => {
          void (async () => {
            try {
              const text = await navigator.clipboard.readText();
              if (text) {
                surfaceRef?.focus();
                await writePromptText(text);
              }
            } catch {
              // Clipboard read may be denied; nothing to do.
            }
          })();
        },
      },
      { label: "", separator: true, onClick: () => {} },
      {
        label: "Select All",
        disabled: !g,
        onClick: () => {
          const snapshot = grid();
          if (!snapshot) return;
          // The selection signal is watched by the repaint createEffect
          // above, so updating it triggers a full canvas redraw on the
          // next animation frame — no manual repaint needed.
          setSelection(selectAllGrid(snapshot));
        },
      },
    ];
  };

  const handleCopy = (event: ClipboardEvent) => {
    const sel = selection();
    const g = grid();
    if (!sel || !g) return;
    const text = selectionText(g, sel);
    if (!text) return;
    event.clipboardData?.setData("text/plain", text);
    event.preventDefault();
  };

  /**
   * Translate a KeyboardEvent into the byte sequence the PTY expects
   * and write it to the active buffer. The pure encoding table lives
   * in `./keyEncoding`; this handler only owns the two side-effecting
   * branches the encoder can't express — the clipboard copy chord and
   * the Ctrl+C SIGINT path — and the snapToBottom + write plumbing.
   *
   * Modifier+arrow combinations (Shift+Up, Ctrl+Up, etc.) still fall
   * through to plain arrow encoding — the xterm modifyOtherKeys / CSI
   * 1;mod sequences are not yet generated. Apps that key off
   * modifier+arrow (tmux pane resize, some vim plugins) will see only
   * the bare arrow.
   */
  const handleKeyDown = async (event: KeyboardEvent) => {
    const current = buffer();
    if (!current || current.status !== "running") return;
    const id = current.id;
    const k = event.key;

    // Cmd+C (macOS) or Ctrl+Shift+C (Linux/Windows) is a copy chord.
    // Always swallow it so it never falls through to the SIGINT or
    // Ctrl-letter chord branches below - critical for Ctrl+Shift+C
    // without a selection, which would otherwise match the
    // Ctrl-letter chord at the bottom and erroneously send 0x03 to
    // the PTY. With no selection there's nothing to write; we simply
    // preventDefault and return.
    const isCopyChord =
      (k === "c" || k === "C") &&
      (event.metaKey || (event.ctrlKey && event.shiftKey));
    if (isCopyChord) {
      event.preventDefault();
      const sel = selection();
      const g = grid();
      if (sel && g) {
        await writeClipboard(selectionText(g, sel));
      }
      return;
    }

    // Windows/Linux terminal paste chord. macOS Cmd+V is intentionally
    // left to the browser paste event so denied async-clipboard reads can
    // still fall back to the platform paste path.
    const isTerminalPasteChord =
      !isMacPlatform() &&
      (k === "v" || k === "V") &&
      event.ctrlKey &&
      event.shiftKey;
    if (isTerminalPasteChord) {
      event.preventDefault();
      try {
        const text = await navigator.clipboard.readText();
        if (text) {
          await writePromptText(text);
        }
      } catch {
        // Clipboard read may be denied; the context-menu paste path still works.
      }
      return;
    }

    // Ctrl+C (no shift) goes through the signal path so raw-mode TUIs
    // receive SIGINT rather than just a 0x03 byte the line discipline
    // ignores.
    if (event.ctrlKey && !event.shiftKey && (k === "c" || k === "C")) {
      event.preventDefault();
      snapToBottom();
      await terminalStore.signal(id, "interrupt");
      return;
    }

    const bytes = encodeKeyEventToBytes(event, {
      cursorKeysApp: grid()?.cursorKeysApp ?? false,
    });
    if (bytes !== null) {
      event.preventDefault();
      snapToBottom();
      await terminalStore.write(id, bytes);
    }
  };

  /**
   * Handle a paste from the clipboard. When DECSET 2004 (bracketed
   * paste) is on, wrap the pasted text in `\x1b[200~ ... \x1b[201~`
   * so the receiving app can distinguish pasted content from typed
   * input. Otherwise send the raw text. Strips embedded `\x1b[201~`
   * markers from pasted content as a safety measure - apps would
   * otherwise see a paste-end marker mid-content and treat the
   * trailing bytes as typed.
   */
  // Strip embedded paste-end markers so a malicious or malformed source
  // cannot inject \x1b[201~ mid-content and have the receiving app treat
  // the trailing bytes as typed input. split+join instead of a regex literal
  // so biome's no-control-char-in-regex lint stays happy.
  const writePromptText = async (text: string): Promise<boolean> => {
    const current = buffer();
    if (!current || current.status !== "running") return false;
    snapToBottom();
    const safe = text.split("\x1b[201~").join("");
    const bracketed = grid()?.bracketedPaste ?? false;
    const payload = bracketed ? `\x1b[200~${safe}\x1b[201~` : safe;
    await terminalStore.write(current.id, payload);
    return true;
  };

  const handlePaste = async (event: ClipboardEvent) => {
    const text = event.clipboardData?.getData("text") ?? "";
    if (!text) return;
    event.preventDefault();
    await writePromptText(text);
  };

  const handleExternalPasteRequest = (event: Event) => {
    const detail = (event as CustomEvent).detail as
      | {
          bufferId?: string;
          respond?: (result: Promise<boolean>) => void;
          text?: string;
        }
      | undefined;
    const id = activeBufferId();
    if (!detail) return;
    if (!id || detail.bufferId !== id || !detail.text) return;
    surfaceRef?.focus();
    detail.respond?.(writePromptText(detail.text));
  };
  const onExternalPasteRequest = (event: Event) => {
    void handleExternalPasteRequest(event);
  };

  const handleSkillDragOver = (event: DragEvent) => {
    const current = buffer();
    if (!current || current.status !== "running") return;
    if (!canAcceptSkillDrop(event)) return;
    event.preventDefault();
    event.stopPropagation();
    if (event.dataTransfer) {
      event.dataTransfer.dropEffect = "copy";
    }
  };

  const handleSkillDrop = async (event: DragEvent) => {
    const payload = skillDragPayload(event);
    if (!payload) return;
    // Always preventDefault once we recognise a skill drop so the browser's
    // native textarea-style drop never inserts the raw `seren-skill:{...}`
    // text into anything below us.
    event.preventDefault();
    event.stopPropagation();
    setCurrentSkillDragPayload(null);
    const before = buffer();
    if (!before || before.status !== "running") {
      console.warn(
        "[TerminalBuffer] skill drop rejected - buffer not running",
        { id: before?.id, status: before?.status },
      );
      return;
    }
    const text = await skillPromptTextFromDrag(payload);
    if (!text) {
      console.warn(
        "[TerminalBuffer] skill drop rejected - empty SKILL.md content",
        { id: payload.id, slug: payload.slug },
      );
      return;
    }
    // Drop the write if the user navigated to another pane mid-fetch;
    // otherwise the SKILL.md ends up in whatever PTY happens to be
    // active now.
    const after = buffer();
    if (!after || after.status !== "running" || after.id !== before.id) return;
    surfaceRef?.focus();
    await writePromptText(text);
  };

  createEffect(
    on(
      () => terminalStore.getFocusRequest(activeBufferId()),
      (request, previous) => {
        if (request === 0 || request === previous) return;
        if (focusFrame !== null) {
          cancelAnimationFrame(focusFrame);
        }
        focusFrame = requestAnimationFrame(() => {
          focusFrame = null;
          const current = buffer();
          if (
            !current ||
            current.status !== "running" ||
            !surfaceRef ||
            surfaceRef.closest("[aria-hidden='true']")
          ) {
            return;
          }
          surfaceRef.focus({ preventScroll: true });
        });
      },
    ),
  );

  onMount(async () => {
    window.addEventListener(
      "seren:terminal-paste-text",
      onExternalPasteRequest,
    );
    await terminalStore.init();
    measureCell();
    pushResize();
    void fetchGridSnapshot();

    // Subscribe to incremental grid diffs. Filter by active buffer
    // (a background terminal's diffs are still emitted but the
    // foreground canvas doesn't render them). The handler is hot under
    // torrent loads; do NOTHING expensive here - just enqueue and let
    // rAF drain on the next frame. This is the load-bearing piece of
    // the coalescing fix: without it, every IPC event triggered a full
    // canvas repaint synchronously, the JS thread fell behind the emit
    // rate, the webview event queue grew without bound, and throughput
    // collapsed.
    unlistenDiff = await listen<GridDiffEvent>(
      "terminal://grid-diff",
      (event) => {
        const diff = event.payload;
        if (diff.bufferId !== activeBufferId()) return;
        // Overflow guard: if rAF is starved (background tab, devtools
        // paused, etc) drop everything and fall back to snapshot resync
        // when the tab becomes active again.
        if (pendingDiffs.length >= MAX_PENDING_DIFFS) {
          pendingDiffs = [];
          pendingRepaintRows.clear();
          pendingCanvasScrolls = [];
          needsFullRepaint = true;
          void fetchGridSnapshot();
          return;
        }
        pendingDiffs.push(diff);
        scheduleFrame();
      },
    );

    if (surfaceRef) {
      resizeObserver = new ResizeObserver(() => {
        // Re-measure cell dims in case zoom or DPR changed; then push.
        measureCell();
        pushResize();
      });
      resizeObserver.observe(surfaceRef);
    }
  });

  onCleanup(() => {
    window.removeEventListener(
      "seren:terminal-paste-text",
      onExternalPasteRequest,
    );
    unlistenDiff?.();
    resizeObserver?.disconnect();
    if (rafHandle !== null) {
      cancelAnimationFrame(rafHandle);
      rafHandle = null;
    }
    if (focusFrame !== null) {
      cancelAnimationFrame(focusFrame);
      focusFrame = null;
    }
    pendingDiffs = [];
    pendingRepaintRows.clear();
    pendingCanvasScrolls = [];
    // If the user unmounts mid-drag, the window-level mouse listeners
    // would otherwise leak. Removing them is a no-op when not bound.
    window.removeEventListener("mousemove", onWindowMouseMove);
    window.removeEventListener("mouseup", onWindowMouseUp);
    detachMouseTracking();
  });

  return (
    <div
      class="flex flex-col h-full min-h-0 bg-surface-0"
      onDragOver={handleSkillDragOver}
      onDrop={(e) => void handleSkillDrop(e)}
    >
      <Show
        when={buffer()}
        fallback={
          <div class="flex items-center justify-center h-full text-sm text-muted-foreground">
            Terminal buffer not found
          </div>
        }
      >
        {(current) => {
          // Trust signal for the themed Claude Code CLI chrome (#2004, #2006).
          // Mirrors the launcher entry at ThreadSidebar.tsx:702, which is
          // locked by launcher-redesign.test.ts to spawn `claude` with no
          // flags. Per Anthropic's June 15, 2026 split, interactive
          // `claude` in a terminal draws from the Pro/Max subscription
          // pool — the pill below makes that visible to the user.
          // Mirrors the component-scoped `isClaudeCli` so paint code +
          // header chrome share the exact same predicate.
          const isClaudeCli = () => current().command === "claude";
          return (
            <>
              <div class="flex items-center gap-3 px-4 py-2.5 border-b border-border bg-card">
                <div class="flex-1 min-w-0 flex items-center gap-2.5">
                  <Show when={isClaudeCli()}>
                    <span
                      aria-hidden="true"
                      class="inline-block w-1.5 h-1.5 rounded-full bg-success shadow-[0_0_0_3px_rgba(52,211,153,0.12)]"
                    />
                  </Show>
                  <div class="text-[13px] font-medium text-foreground truncate">
                    {current().title}
                  </div>
                  <Show when={current().cwd}>
                    <span class="text-[12px] font-mono text-secondary-foreground bg-surface-3 px-2 py-0.5 rounded border border-border truncate">
                      {current().cwd}
                    </span>
                  </Show>
                </div>
                <Show when={isClaudeCli()}>
                  <div class="flex items-center gap-2 shrink-0">
                    <Show when={claudeCliVersion()}>
                      {(version) => (
                        <span
                          data-testid="claude-cli-version-pill"
                          class="text-[11px] font-mono tracking-tight text-secondary-foreground bg-surface-3 px-2 py-0.5 rounded-full border border-border"
                        >
                          {`claude ${version()}`}
                        </span>
                      )}
                    </Show>
                    <span class="text-[11px] font-medium tracking-tight text-primary bg-primary-muted px-2.5 py-1 rounded-full border border-[color:rgba(56,189,248,0.22)]">
                      Subscription · Pro/Max
                    </span>
                  </div>
                </Show>
              </div>

              {/* Canvas surface. role="application" + tabIndex=0 lets the
                  div receive focus + keyboard events; the application role
                  tells screen readers this is a widget with its own
                  keyboard model (matches xterm.js, vscode terminal, etc.).
                  Focus on mousedown so users can type without Tab-ing in. */}
              <div
                ref={surfaceRef}
                class="flex-1 min-h-0 overflow-hidden bg-[#090b0f] outline-none cursor-text select-none"
                classList={{
                  // Themed frame: drop-shadow + sky-400 glow only on the
                  // claude CLI surface so plain Terminal threads keep their
                  // existing chrome.
                  "ring-1 ring-[color:var(--border-medium)] shadow-[var(--shadow-lg),var(--glow-primary)]":
                    isClaudeCli(),
                }}
                // #2006 — subtle sky-400 radial wash at the top of the
                // claude CLI canvas. The wash sits on background-IMAGE so
                // it composites over the existing background-color (the
                // bg-[#090b0f] class), keeping the canvas readable while
                // bonding the surface to the rest of the themed chrome.
                style={
                  isClaudeCli()
                    ? {
                        "background-image":
                          "radial-gradient(ellipse at top, rgba(56, 189, 248, 0.08) 0%, transparent 60%)",
                      }
                    : undefined
                }
                role="application"
                aria-label="Terminal"
                data-workspace-default-focus={
                  current().status === "running" ? "true" : undefined
                }
                // biome-ignore lint/a11y/noNoninteractiveTabindex: terminal surfaces are interactive widgets that own their keyboard model (matches xterm.js, vscode terminal pattern)
                tabIndex={0}
                onKeyDown={(e) => void handleKeyDown(e)}
                onCopy={(e) => handleCopy(e)}
                onPaste={(e) => void handlePaste(e)}
                onMouseDown={onSurfaceMouseDown}
                onMouseMove={onSurfaceMouseMoveTracking}
                onWheel={onSurfaceWheel}
                onContextMenu={openContextMenu}
                onSelectStart={preventNativeSelection}
              >
                <canvas ref={canvasRef} class="block pointer-events-none" />
                {/* Hidden DOM mirror of the canvas selection. The system
                    Edit > Copy menu (Tauri PredefinedMenuItem::copy) only
                    fires a `copy` event when window.getSelection() is
                    non-empty; pointing the document selection at this
                    off-screen span makes that path produce the right text
                    without changing any menu-bar wiring. #2091. */}
                <span
                  ref={selectionMirrorRef}
                  data-testid="terminal-selection-mirror"
                  aria-hidden="true"
                  style={{
                    position: "absolute",
                    left: "-10000px",
                    top: "0",
                    "white-space": "pre",
                    "user-select": "text",
                    "-webkit-user-select": "text",
                  }}
                />
              </div>

              <Show when={isClaudeCli()}>
                <div class="flex items-center justify-end gap-3 px-4 py-2 text-[11px] text-muted-foreground border-t border-border bg-surface-0">
                  <span>
                    <kbd class="font-mono text-[10.5px] text-secondary-foreground bg-surface-3 border border-border rounded px-1.5 py-px">
                      ⌃C
                    </kbd>{" "}
                    interrupt
                  </span>
                  <span>
                    <kbd class="font-mono text-[10.5px] text-secondary-foreground bg-surface-3 border border-border rounded px-1.5 py-px">
                      ⌃D
                    </kbd>{" "}
                    end session
                  </span>
                  <span>
                    type{" "}
                    <kbd class="font-mono text-[10.5px] text-secondary-foreground bg-surface-3 border border-border rounded px-1.5 py-px">
                      /help
                    </kbd>{" "}
                    for slash commands
                  </span>
                </div>
              </Show>
            </>
          );
        }}
      </Show>
      <Show when={ctxMenu()}>
        {(pos) => (
          <ContextMenu
            x={pos().x}
            y={pos().y}
            items={contextMenuItems()}
            onClose={closeContextMenu}
          />
        )}
      </Show>
    </div>
  );
};
