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.
utils/fullscreen.ts → ink/termio/dec.ts →
ink/components/AlternateScreen.tsx →
components/FullscreenLayout.tsx →
components/OffscreenFreeze.tsx
This lesson covers three nested layers of the fullscreen story:
Detection
Should fullscreen even activate? Env vars, tmux probe, interactive flag.
DEC Sequences
The raw terminal escape codes that enter/exit the alternate screen and enable mouse tracking.
React Integration
AlternateScreen component, FullscreenLayout slots, OffscreenFreeze optimisation.
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 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.
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.
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.
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.
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.
components/FullscreenLayout.tsx is the highest-level layout component.
It has two completely different rendering paths depending on isFullscreenEnvEnabled():
Slot-based layout
ScrollBox (grows), sticky bottom strip (shrinks), absolute modal overlay. Everything is viewport-constrained via AlternateScreen.
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
})
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.
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.
'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).
Putting it all together: from process start to first render and back to shell.
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. useInsertionEffectwas chosen overuseLayoutEffectto ensure ENTER_ALT_SCREEN reaches the terminal before Ink's first rendered frame — a subtle timing requirement thatuseLayoutEffectgets 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 varCLAUDE_CODE_NO_FLICKER=1opts in from outside. OffscreenFreezeexploits 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) andCLAUDE_CODE_NO_FLICKER=0(kill everything including alt-screen).
Check Your Understanding
useInsertionEffect instead of useLayoutEffect?probeTmuxControlModeSync) is synchronous. What would happen if it were async?OffscreenFreeze's 'use no memo' directive?CLAUDE_CODE_DISABLE_MOUSE=1. Which behavior best describes the result?