markdown.engineering
Lesson 42

Fullscreen and Alternate Screen Modes

How Claude Code hijacks your terminal's alternate buffer, tames mouse tracking, survives tmux, and avoids flicker — from DEC escape sequences to React hooks.

01 Overview

Claude Code's rich interactive UI is not rendered on the normal terminal scrollback. It occupies the alternate screen buffer — the same DEC private mode that vim, less, and htop use. When you quit, your shell history is untouched. When you're inside it, mouse wheel events scroll the message list, not the terminal's history.

Source files covered
utils/fullscreen.tsink/termio/dec.tsink/components/AlternateScreen.tsxcomponents/FullscreenLayout.tsxcomponents/OffscreenFreeze.tsx

This lesson covers three nested layers of the fullscreen story:

Layer 1

Detection

Should fullscreen even activate? Env vars, tmux probe, interactive flag.

Layer 2

DEC Sequences

The raw terminal escape codes that enter/exit the alternate screen and enable mouse tracking.

Layer 3

React Integration

AlternateScreen component, FullscreenLayout slots, OffscreenFreeze optimisation.

02 The DEC Private Mode Sequences

Everything starts in ink/termio/dec.ts, a small constants file that encodes the DEC private mode numbers and generates the escape sequences from them. Understanding these codes is the prerequisite for everything else.

// ink/termio/dec.ts — the complete DEC mode table
export const DEC = {
  CURSOR_VISIBLE:      25,
  ALT_SCREEN:         47,    // older; does NOT save/restore cursor
  ALT_SCREEN_CLEAR:   1049,  // modern: save cursor + switch + clear
  MOUSE_NORMAL:       1000,  // button press/release + wheel
  MOUSE_BUTTON:       1002,  // adds drag (button-motion)
  MOUSE_ANY:          1003,  // adds all-motion (hover)
  MOUSE_SGR:          1006,  // SGR format: CSI < btn;col;row M/m
  FOCUS_EVENTS:       1004,
  BRACKETED_PASTE:    2004,
  SYNCHRONIZED_UPDATE:2026,
} as const

The helper functions decset(mode) and decreset(mode) wrap each number into the standard CSI ? N h (set) / CSI ? N l (reset) format. All concrete sequences are pre-generated as module-level constants so no string formatting happens at render time:

export const ENTER_ALT_SCREEN = decset(DEC.ALT_SCREEN_CLEAR)   // \x1b[?1049h
export const EXIT_ALT_SCREEN  = decreset(DEC.ALT_SCREEN_CLEAR)  // \x1b[?1049l

// All four mouse modes stacked — DEC 1000 + 1002 + 1003 + 1006
export const ENABLE_MOUSE_TRACKING =
  decset(DEC.MOUSE_NORMAL) +
  decset(DEC.MOUSE_BUTTON) +
  decset(DEC.MOUSE_ANY)    +
  decset(DEC.MOUSE_SGR)
export const DISABLE_MOUSE_TRACKING =
  decreset(DEC.MOUSE_SGR)    +  // reversed order — disable outer modes first
  decreset(DEC.MOUSE_ANY)    +
  decreset(DEC.MOUSE_BUTTON) +
  decreset(DEC.MOUSE_NORMAL)
Why 1049 not 47?
DEC mode 1049 (used here) saves the cursor position before switching and restores it on exit. The older mode 47 just switches buffers without cursor save/restore. Claude Code uses 1049 exclusively so the user's shell cursor position is preserved when fullscreen exits.
Why are the mouse modes disabled in reverse order?

It's defensive layering. The modes form a superset hierarchy: 1003 (any-motion) includes everything 1002 (button-motion) does, which includes everything 1000 (normal) does. Setting them in order means each one expands the tracking envelope. Disabling in reverse order (outer → inner, 1006 → 1003 → 1002 → 1000) ensures the terminal sees a clean teardown rather than leaving a partial mode active if the process crashes between two decreset calls. The SGR format (1006) is disabled first because it is a modifier on the reporting format, not a tracking mode — disabling it before the tracking modes means any spurious events during the teardown window arrive in legacy X10 format, which the parser safely ignores.

03 Fullscreen Detection Logic

utils/fullscreen.ts is a pure decision module: it answers "should fullscreen activate right now?" via four exported predicates. None of them touch the terminal directly — they only inspect environment state.

flowchart TD A["isFullscreenActive()"] --> B{"getIsInteractive()"} B -->|"false — headless/SDK/--print"| Z["return false"] B -->|"true"| C["isFullscreenEnvEnabled()"] C --> D{"CLAUDE_CODE_NO_FLICKER\nexplicitly falsy?"} D -->|"yes (=0)"| Z D -->|"no"| E{"CLAUDE_CODE_NO_FLICKER\nexplicitly truthy?"} E -->|"yes (=1)"| Y["return true"] E -->|"no"| F{"isTmuxControlMode()?"} F -->|"yes — iTerm2 -CC"| Z F -->|"no"| G{"USER_TYPE === 'ant'?"} G -->|"yes"| Y G -->|"no"| Z style Y fill:#1e251b,stroke:#6e9468,color:#b8b0a4 style Z fill:#2c1d18,stroke:#c47a50,color:#b8b0a4

The tmux -CC probe: why it's synchronous

The most interesting detection code is probeTmuxControlModeSync(). It calls spawnSync('tmux', ['display-message', '-p', '#{client_control_mode}']) — a synchronous subprocess that deliberately blocks the event loop for ~5ms. The code comment explains exactly why async lost:

// Sync (spawnSync) because the answer gates whether we enter fullscreen —
// an async probe raced against React render and lost: coder-tmux
// (ssh → tmux -CC on a remote box) doesn't propagate TERM_PROGRAM, so
// the env heuristic missed, and by the time the async probe resolved
// we'd already entered alt-screen with mouse tracking enabled.
// Mouse wheel is dead in iTerm2's -CC integration, so users couldn't scroll at all.

This is a deliberate correctness/performance trade-off: pay 5ms once at startup to avoid an unrecoverable UX bug (dead mouse wheel for iTerm2 tmux -CC users). The cost is tightly bounded by two conditions — it only fires when $TMUX is set AND $TERM_PROGRAM is absent (the SSH-into-tmux case). Direct iTerm2 and non-tmux paths skip the subprocess entirely via the fast heuristic.

The caching trick
tmuxControlModeProbed is a module-level boolean | undefined. It is seeded with the env heuristic result before spawning, so even if the spawn throws or returns non-zero, the cache is already populated. Without this, every subsequent call to isTmuxControlMode() (which fires 15+ times per render frame) would re-enter the probe function and potentially re-spawn a subprocess.

Mouse knobs: two orthogonal controls

Even when fullscreen is on, mouse behavior has two independent kill-switches:

Env var What it disables What still works
CLAUDE_CODE_NO_FLICKER=0 Alt-screen entirely + all mouse tracking Normal terminal scrollback, no virtualized scroll
CLAUDE_CODE_DISABLE_MOUSE=1 Mouse capture (wheel + click/drag) Alt-screen stays; keyboard PgUp/PgDn/Ctrl+Home/End still work
CLAUDE_CODE_DISABLE_MOUSE_CLICKS=1 Click and drag events only Alt-screen + wheel scroll still work

CLAUDE_CODE_DISABLE_MOUSE exists specifically for users who want alt-screen (no flicker) but also need tmux/kitty copy-on-select to work. Those terminal multiplexers intercept mouse events when the application has capture enabled; disabling capture restores native terminal text selection while preserving the fullscreen layout.

04 The AlternateScreen React Component

ink/components/AlternateScreen.tsx is the React boundary that actually writes the DEC sequences to the terminal. It wraps the entire REPL tree and has one critical constraint: the escape sequences must reach the terminal before the first rendered frame — otherwise the first frame paints on the main screen, then the alt-screen switch happens and that frame is preserved as a broken view when you exit.

Why useInsertionEffect instead of useLayoutEffect

// useInsertionEffect (not useLayoutEffect): react-reconciler calls
// resetAfterCommit between the mutation and layout commit phases, and
// Ink's resetAfterCommit triggers onRender. With useLayoutEffect, that
// first onRender fires BEFORE this effect — writing a full frame to the
// main screen with altScreen=false. That frame is preserved when we
// enter alt screen and revealed on exit as a broken view. Insertion
// effects fire during the mutation phase, before resetAfterCommit, so
// ENTER_ALT_SCREEN reaches the terminal before the first frame does.
useInsertionEffect(() => {
  const ink = instances.get(process.stdout)
  if (!writeRaw) return

  writeRaw(
    ENTER_ALT_SCREEN
    + '\x1b[2J\x1b[H'           // clear screen + home cursor
    + (mouseTracking ? ENABLE_MOUSE_TRACKING : '')
  )
  ink?.setAltScreenActive(true, mouseTracking)

  return () => {
    ink?.setAltScreenActive(false)
    ink?.clearTextSelection()
    writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN)
  }
}, [writeRaw, mouseTracking])

The effect also calls ink.setAltScreenActive(true, mouseTracking). This notifies the Ink renderer so it constrains the cursor inside the viewport on every paint — preventing the cursor-restore newline from scrolling the alt-screen content up. It also registers a signal-exit cleanup handler so the alt-screen exits cleanly even if the React unmount never runs (e.g. SIGKILL).

Height constraint

// Constrain height to terminal rows — no native scrollback in alt-screen
return (
  <Box
    flexDirection="column"
    height={size?.rows ?? 24}  // from TerminalSizeContext
    width="100%"
    flexShrink={0}
  >
    {children}
  </Box>
)

Because the alternate screen has no scrollback, any content taller than the terminal would simply vanish past the bottom. AlternateScreen pins its own height to size.rows (from TerminalSizeContext), which forces all overflow to be handled by Ink's overflow: scroll / flexbox layout — not by the terminal.

05 FullscreenLayout: Slot-Based Composition

components/FullscreenLayout.tsx is the highest-level layout component. It has two completely different rendering paths depending on isFullscreenEnvEnabled():

Fullscreen ON

Slot-based layout

ScrollBox (grows), sticky bottom strip (shrinks), absolute modal overlay. Everything is viewport-constrained via AlternateScreen.

Fullscreen OFF

Sequential render

<>{scrollable}{bottom}{overlay}{modal}</> — content just stacks vertically into the normal scrollback.

In fullscreen mode the layout has five named slots:

type Props = {
  scrollable:    ReactNode  // message list — lives in a ScrollBox with stickyScroll
  bottom:        ReactNode  // pinned bottom strip: prompt input, spinner, permissions
  overlay?:     ReactNode  // rendered inside ScrollBox after messages (PermissionRequest)
  bottomFloat?: ReactNode  // absolute bottom-right of scroll area (companion speech bubble)
  modal?:       ReactNode  // slash-command dialog: absolute bottom-anchored, fullscreen only
}

The modal pane sizing calculation

// MODAL_TRANSCRIPT_PEEK = 2 — rows of transcript visible above the modal divider
modal != null && (
  <ModalContext value={{
    rows:    terminalRows - MODAL_TRANSCRIPT_PEEK - 1,
    columns: columns - 4,
  }}>
    <Box
      position="absolute"
      bottom={0} left={0} right={0}
      maxHeight={terminalRows - MODAL_TRANSCRIPT_PEEK}
    >
      /* ▔▔▔ divider line, then modal content with paddingX=2 */
    </Box>
  </ModalContext>
)

The modal occupies almost the full viewport height, always leaving exactly 2 transcript rows visible above the divider line. The ModalContext carries the computed interior dimensions (rows - 3, columns - 4) so child scroll boxes inside the modal know how tall they can grow.

The "N new messages" pill

When the user scrolls up, a pill floats at the bottom of the scroll area showing how many new messages arrived. The visibility state is computed via useSyncExternalStore subscribing to the ScrollBox's scroll position — so the pill appears and disappears without triggering any re-render of the parent REPL component.

// pillVisible subscribes directly to ScrollBox — no REPL re-render per scroll frame
const pillVisible = useSyncExternalStore(subscribe, () => {
  const s = scrollRef?.current
  const dividerY = dividerYRef?.current
  if (!s || dividerY == null) return false
  return s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY
})
06 OffscreenFreeze: Eliminating Offscreen Redraws

components/OffscreenFreeze.tsx solves a specific performance problem in the non-fullscreen (main-screen) rendering path. When content has scrolled above the terminal viewport into the scrollback buffer, any change to that content forces Ink's renderer into a full terminal reset — it cannot partially update rows that have already scrolled out. For spinner components or elapsed-time counters that update every tick, this produces a visible reset per animation frame.

export function OffscreenFreeze({ children }: Props): React.ReactNode {
  'use no memo'  // React Compiler opt-out — the freeze IS the memo mechanism

  const inVirtualList = useContext(InVirtualListContext)
  const [ref, { isVisible }] = useTerminalViewport()
  const cached = useRef(children)

  if (isVisible || inVirtualList) {
    cached.current = children  // update cache only while visible
  }
  // while offscreen: return stale ref — React reconciler bails, zero diff
  return <Box ref={ref}>{cached.current}</Box>
}

The mechanism works because React bails out of reconciliation when the returned element has the same object identity as the previous render. By returning the cached ref unchanged while offscreen, the entire subtree produces zero diff and zero terminal output.

Virtual list exemption
OffscreenFreeze explicitly skips the optimization when inVirtualList is true. The ScrollBox's virtual list clips all content inside the viewport — there is no terminal scrollback to worry about. More importantly, freezing inside a virtual list would break click-to-expand, because useTerminalViewport's visibility calculation can disagree with the ScrollBox's virtual scroll position.
React Compiler interaction
The component uses 'use no memo' — an explicit opt-out from React Compiler's automatic memoization. If the compiler memoized the component, it would cache the returned element itself, which would defeat the freeze mechanism (the freeze works by intentionally returning a stale cached ref, not by memoizing the component's output).
07 Lifecycle: Enter to Exit

Putting it all together: from process start to first render and back to shell.

sequenceDiagram participant Startup participant fullscreen.ts participant AlternateScreen participant Ink participant Terminal Startup->>fullscreen.ts: isFullscreenActive()? fullscreen.ts->>fullscreen.ts: getIsInteractive() fullscreen.ts->>fullscreen.ts: isFullscreenEnvEnabled() fullscreen.ts-->>Startup: true Startup->>AlternateScreen: mount (useInsertionEffect) AlternateScreen->>Terminal: ENTER_ALT_SCREEN + clear + ENABLE_MOUSE_TRACKING AlternateScreen->>Ink: setAltScreenActive(true, mouseTracking) Note over Ink: renderer clamps cursor to viewport loop Every render frame Ink->>Terminal: diff output within alt-screen bounds end Startup->>AlternateScreen: unmount (cleanup) AlternateScreen->>Ink: setAltScreenActive(false) AlternateScreen->>Ink: clearTextSelection() AlternateScreen->>Terminal: DISABLE_MOUSE_TRACKING + EXIT_ALT_SCREEN Note over Terminal: main screen + cursor position restored

tmux and the mouse scroll hint

When running inside tmux (but NOT in tmux -CC mode), mouse wheel events are forwarded to the application only when tmux's mouse option is enabled. Claude Code does not programmatically set tmux set mouse on — a deliberate choice made after a previous implementation leaked tmux mouse state to sibling panes (vim, less, shell). Instead, maybeGetTmuxMouseHint() fires once at startup and returns a hint string if the tmux mouse option is currently off:

"tmux detected · scroll with PgUp/PgDn · or add 'set -g mouse on' to ~/.tmux.conf for wheel scroll"

Key Takeaways

  • The alternate screen is DEC mode 1049 (\x1b[?1049h), not the older mode 47 — 1049 saves and restores the cursor position.
  • useInsertionEffect was chosen over useLayoutEffect to ensure ENTER_ALT_SCREEN reaches the terminal before Ink's first rendered frame — a subtle timing requirement that useLayoutEffect gets wrong.
  • The tmux -CC detection probe is synchronous by design: an async probe raced against the React render cycle and caused an unrecoverable broken state (dead mouse wheel) for SSH+tmux users.
  • Fullscreen defaults on for Anthropic internal users (USER_TYPE=ant) and off for external users — the env var CLAUDE_CODE_NO_FLICKER=1 opts in from outside.
  • OffscreenFreeze exploits React object-identity bail-out to produce zero diff output for content that has scrolled into terminal scrollback, eliminating per-tick full resets.
  • Mouse tracking has two orthogonal kill-switches: CLAUDE_CODE_DISABLE_MOUSE (kill capture, keep alt-screen) and CLAUDE_CODE_NO_FLICKER=0 (kill everything including alt-screen).

Check Your Understanding

1. What does DEC private mode 1049 do that mode 47 does not?
2. Why does AlternateScreen use useInsertionEffect instead of useLayoutEffect?
3. The tmux control-mode probe (probeTmuxControlModeSync) is synchronous. What would happen if it were async?
4. What is the purpose of OffscreenFreeze's 'use no memo' directive?
5. A user sets CLAUDE_CODE_DISABLE_MOUSE=1. Which behavior best describes the result?
0/5