레슨 42

풀스크린 및 대체 화면 모드

Claude Code가 터미널의 대체 버퍼를 장악하고, 마우스 트래킹을 다루고, tmux 안에서도 버티고, 깜빡임을 피하는 방법을 살펴봅니다. DEC 이스케이프 시퀀스부터 React 훅까지 이어집니다.

01 개요

Claude Code의 풍부한 대화형 UI는 일반 터미널 스크롤백에 렌더링되지 않습니다. 대체 화면 버퍼를 차지합니다. 이것은 vim, less, htop이 쓰는 것과 같은 DEC private mode입니다. 종료하면 셸 히스토리는 그대로 남고, 그 안에 있는 동안에는 마우스 휠 이벤트가 터미널 히스토리가 아니라 메시지 목록을 스크롤합니다.

다루는 소스 파일
utils/fullscreen.tsink/termio/dec.tsink/components/AlternateScreen.tsxcomponents/FullscreenLayout.tsxcomponents/OffscreenFreeze.tsx

이 레슨에서는 풀스크린 동작을 이루는 세 가지 중첩 레이어를 다룹니다.

레이어 1

감지

풀스크린을 정말 켜야 하는가? 환경 변수, tmux 확인, 대화형 플래그를 봅니다.

레이어 2

DEC 시퀀스

대체 화면에 들어가고 나가며, 마우스 트래킹을 켜는 원시 터미널 이스케이프 코드입니다.

레이어 3

React 통합

AlternateScreen 컴포넌트, FullscreenLayout 슬롯, OffscreenFreeze 최적화를 봅니다.

02 DEC Private Mode 시퀀스

모든 것은 ink/termio/dec.ts에서 시작합니다. 이 작은 상수 파일은 DEC private mode 번호를 담고, 그 번호로부터 이스케이프 시퀀스를 만들어 냅니다. 이 코드를 이해해야 뒤에 나오는 내용도 따라갈 수 있습니다.

// ink/termio/dec.ts, 전체 DEC 모드 테이블
export const DEC = {
  CURSOR_VISIBLE:      25,
  ALT_SCREEN:         47,    // 구형, 커서를 저장하거나 복원하지 않음
  ALT_SCREEN_CLEAR:   1049,  // 최신 방식, 커서 저장 + 전환 + 화면 비우기
  MOUSE_NORMAL:       1000,  // 버튼 누름/놓기 + 휠
  MOUSE_BUTTON:       1002,  // 드래그 추가 (button-motion)
  MOUSE_ANY:          1003,  // 모든 움직임 추가 (hover)
  MOUSE_SGR:          1006,  // SGR 형식: CSI < btn;col;row M/m
  FOCUS_EVENTS:       1004,
  BRACKETED_PASTE:    2004,
  SYNCHRONIZED_UPDATE:2026,
} as const

헬퍼 함수 decset(mode)decreset(mode)는 각 숫자를 표준 CSI ? N h (set) / CSI ? N l (reset) 형식으로 감쌉니다. 실제 시퀀스는 모두 모듈 레벨 상수로 미리 만들어 두기 때문에, 렌더링 시점에 문자열 포매팅은 일어나지 않습니다.

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

// 네 가지 마우스 모드를 모두 겹쳐 켬, 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)    +  // 역순으로 끔, 바깥쪽 모드부터 먼저 해제
  decreset(DEC.MOUSE_ANY)    +
  decreset(DEC.MOUSE_BUTTON) +
  decreset(DEC.MOUSE_NORMAL)
왜 47이 아니라 1049일까?
여기서 쓰는 DEC 모드 1049는 전환 전에 커서 위치를 저장하고, 종료 시 그 위치를 복원합니다. 더 오래된 47 모드는 커서 저장과 복원 없이 버퍼만 전환합니다. Claude Code가 1049만 쓰는 이유는, 풀스크린을 빠져나올 때 사용자의 셸 커서 위치를 그대로 보존하기 위해서입니다.
왜 마우스 모드를 역순으로 비활성화할까?

이것은 방어적인 계층화입니다. 모드들은 포함 관계를 이룹니다. 1003(any-motion)는 1002(button-motion)가 하는 일을 모두 포함하고, 1002는 다시 1000(normal)이 하는 일을 모두 포함합니다. 켤 때 순서대로 적용하면 추적 범위가 점점 넓어집니다. 끌 때는 역순으로 적용해야, 즉 바깥에서 안쪽으로 1006 → 1003 → 1002 → 1000 순으로 내려가야, 두 번의 decreset 호출 사이에 프로세스가 죽더라도 일부 모드만 남는 일을 막고 터미널이 깔끔하게 정리 과정을 보게 됩니다. SGR 형식(1006)을 먼저 끄는 이유는 이것이 추적 모드 자체가 아니라 보고 형식에 붙는 수정자이기 때문입니다. 추적 모드보다 먼저 꺼 두면, 정리 중에 혹시 들어오는 이벤트는 레거시 X10 형식으로 들어오고, 파서는 그것을 안전하게 무시할 수 있습니다.

03 풀스크린 감지 로직

utils/fullscreen.ts는 순수한 결정 모듈입니다. 내보낸 네 개의 predicate를 통해, 지금 당장 풀스크린을 활성화해야 하는가를 답합니다. 이 함수들은 터미널을 직접 건드리지 않고, 오직 환경 상태만 검사합니다.

flowchart TD A["isFullscreenActive()"] --> B{"getIsInteractive()"} B -->|"false, headless/SDK/--print"| Z["false 반환"] B -->|"true"| C["isFullscreenEnvEnabled()"] C --> D{"CLAUDE_CODE_NO_FLICKER\n명시적으로 false인가?"} D -->|"예 (=0)"| Z D -->|"아니오"| E{"CLAUDE_CODE_NO_FLICKER\n명시적으로 true인가?"} E -->|"예 (=1)"| Y["true 반환"] E -->|"아니오"| F{"isTmuxControlMode()?"} F -->|"예, iTerm2 -CC"| Z F -->|"아니오"| G{"USER_TYPE === 'ant'?"} G -->|"예"| Y G -->|"아니오"| Z style Y fill:#1e251b,stroke:#6e9468,color:#b8b0a4 style Z fill:#2c1d18,stroke:#c47a50,color:#b8b0a4

tmux -CC probe, 왜 동기식일까

가장 흥미로운 감지 코드는 probeTmuxControlModeSync()입니다. 여기서는 spawnSync('tmux', ['display-message', '-p', '#{client_control_mode}'])를 호출합니다. 이벤트 루프를 약 5ms 동안 일부러 막는 동기식 서브프로세스입니다. 코드 주석은 왜 async가 졌는지 아주 정확히 설명합니다.

// Sync (spawnSync)를 쓰는 이유는, 이 답이 풀스크린 진입 여부를 가르기 때문임
// async probe는 React 렌더와 경합하다가 졌다. coder-tmux
// (원격 박스에서 ssh → tmux -CC) 환경은 TERM_PROGRAM을 전파하지 않아서
// env 휴리스틱이 놓쳤고, async probe가 끝날 때쯤이면 우리는 이미
// 마우스 트래킹이 켜진 alt-screen에 들어간 뒤였다.
// iTerm2의 -CC 통합에서는 마우스 휠이 죽기 때문에, 사용자는 전혀 스크롤할 수 없었다.

이것은 의도적인 정확성 대 성능 트레이드오프입니다. 시작 시 한 번 5ms를 치르고, 복구 불가능한 UX 버그를 피합니다. 그 버그는 iTerm2 tmux -CC 사용자의 마우스 휠이 죽는 문제입니다. 비용은 두 조건으로 꽉 묶여 있습니다. $TMUX가 설정되어 있고 $TERM_PROGRAM이 없을 때만 실행됩니다. 즉, SSH로 tmux에 들어간 경우입니다. 직접 실행한 iTerm2나 non-tmux 경로는 빠른 휴리스틱으로 서브프로세스를 아예 건너뜁니다.

캐싱 트릭
tmuxControlModeProbed는 모듈 레벨의 boolean | undefined입니다. spawn를 하기 에 환경 휴리스틱 결과로 먼저 채워집니다. 그래서 spawn가 예외를 던지거나 0이 아닌 값을 반환해도, 캐시는 이미 채워져 있습니다. 이게 없으면 이후의 isTmuxControlMode() 호출마다, 즉 렌더 프레임당 15회 이상 불릴 수 있는 그 호출마다, probe 함수에 다시 들어가 서브프로세스를 또 띄울 수도 있습니다.

마우스 제어 스위치, 서로 독립인 두 축

풀스크린이 켜져 있어도 마우스 동작에는 서로 독립적인 kill-switch가 두 개 있습니다.

환경 변수 비활성화되는 것 여전히 동작하는 것
CLAUDE_CODE_NO_FLICKER=0 Alt-screen 전체 + 모든 마우스 트래킹 일반 터미널 스크롤백, 가상화 스크롤 없음
CLAUDE_CODE_DISABLE_MOUSE=1 마우스 캡처(휠 + 클릭/드래그) Alt-screen은 유지, 키보드 PgUp/PgDn/Ctrl+Home/End는 계속 동작
CLAUDE_CODE_DISABLE_MOUSE_CLICKS=1 클릭과 드래그 이벤트만 Alt-screen + 휠 스크롤은 계속 동작

CLAUDE_CODE_DISABLE_MOUSE는 alt-screen은 원하지만, tmux/kitty의 copy-on-select도 꼭 필요로 하는 사용자를 위해 존재합니다. 이런 터미널 멀티플렉서는 애플리케이션이 마우스 캡처를 켜면 마우스 이벤트를 가로챕니다. 캡처를 끄면 기본 터미널 텍스트 선택이 돌아오고, 풀스크린 레이아웃은 그대로 유지됩니다.

04 AlternateScreen React 컴포넌트

ink/components/AlternateScreen.tsx는 실제로 DEC 시퀀스를 터미널에 써 넣는 React 경계입니다. 이 컴포넌트는 REPL 트리 전체를 감싸며, 아주 중요한 제약이 하나 있습니다. 이 이스케이프 시퀀스가 첫 렌더 프레임보다 먼저 터미널에 도달해야 합니다. 그렇지 않으면 첫 프레임이 메인 화면에 그려지고, 그 다음 alt-screen 전환이 일어나며, 종료했을 때 그 프레임이 깨진 화면처럼 남아 버립니다.

왜 useLayoutEffect 대신 useInsertionEffect를 쓸까

// useInsertionEffect를 쓰는 이유는 useLayoutEffect가 아니기 때문임
// react-reconciler는 mutation 단계와 layout commit 단계 사이에서
// resetAfterCommit를 호출하고, Ink의 resetAfterCommit는 onRender를 트리거한다.
// useLayoutEffect를 쓰면 이 effect보다 BEFORE에 첫 onRender가 실행되어,
// altScreen=false 상태로 메인 화면에 전체 프레임을 써 버린다.
// 그 프레임은 alt screen 진입 뒤에도 남고, 종료 시 깨진 화면으로 드러난다.
// insertion effect는 mutation 단계에서, resetAfterCommit보다 먼저 실행되므로
// ENTER_ALT_SCREEN이 첫 프레임보다 먼저 터미널에 도달한다.
useInsertionEffect(() => {
  const ink = instances.get(process.stdout)
  if (!writeRaw) return

  writeRaw(
    ENTER_ALT_SCREEN
    + '\x1b[2J\x1b[H'           // 화면 비우기 + 커서를 홈 위치로
    + (mouseTracking ? ENABLE_MOUSE_TRACKING : '')
  )
  ink?.setAltScreenActive(true, mouseTracking)

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

이 effect는 ink.setAltScreenActive(true, mouseTracking)도 호출합니다. 이것은 Ink 렌더러에게 매 paint마다 커서를 viewport 안에 가둬 달라고 알리는 신호입니다. 덕분에 커서 복원 시의 줄바꿈 때문에 alt-screen 내용이 위로 밀려 올라가는 일을 막습니다. 또한 signal-exit 정리 핸들러도 등록해서, React unmount가 실행되지 않더라도, 예를 들어 SIGKILL 같은 경우에도, alt-screen이 깔끔하게 종료되게 합니다.

높이 제약

// 높이를 터미널 행 수에 맞춤, alt-screen에는 기본 스크롤백이 없음
return (
  <Box
    flexDirection="column"
    height={size?.rows ?? 24}  // TerminalSizeContext에서 옴
    width="100%"
    flexShrink={0}
  >
    {children}
  </Box>
) 

대체 화면에는 스크롤백이 없기 때문에, 터미널보다 더 높은 콘텐츠는 그대로 아래로 사라집니다. AlternateScreen은 자신의 높이를 size.rows(TerminalSizeContext에서 옴)에 고정하고, 넘치는 내용은 터미널이 아니라 Ink의 overflow: scroll과 flexbox 레이아웃이 처리하게 만듭니다.

05 FullscreenLayout, 슬롯 기반 합성

components/FullscreenLayout.tsx는 가장 상위의 레이아웃 컴포넌트입니다. isFullscreenEnvEnabled() 값에 따라 렌더링 경로가 완전히 둘로 갈립니다.

풀스크린 ON

슬롯 기반 레이아웃

ScrollBox(확장), sticky bottom strip(축소), absolute modal overlay. 모든 것은 AlternateScreen을 통해 viewport 안에 제한됩니다.

풀스크린 OFF

순차 렌더링

<>{scrollable}{bottom}{overlay}{modal}</>, 콘텐츠가 일반 스크롤백에 세로로 차곡차곡 쌓입니다.

풀스크린 모드에서 이 레이아웃은 이름이 붙은 다섯 개의 슬롯을 가집니다.

type Props = {
  scrollable:    ReactNode  // 메시지 목록, stickyScroll이 있는 ScrollBox 안에 들어감
  bottom:        ReactNode  // 아래쪽 고정 스트립, 프롬프트 입력, 스피너, 권한 UI
  overlay?:     ReactNode  // 메시지 뒤에 ScrollBox 안에서 렌더링됨 (PermissionRequest)
  bottomFloat?: ReactNode  // 스크롤 영역 오른쪽 아래 절대 위치 (companion 말풍선)
  modal?:       ReactNode  // slash-command 대화상자, 아래 고정 절대 위치, 풀스크린 전용
}

모달 패널 크기 계산

// MODAL_TRANSCRIPT_PEEK = 2, 모달 구분선 위로 보이는 transcript 행 수
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}
    >
      /* ▔▔▔ 구분선 다음에 paddingX=2를 준 모달 콘텐츠 */
    </Box>
  </ModalContext>
) 

이 모달은 viewport 높이 대부분을 차지하지만, 항상 정확히 2줄의 transcript를 구분선 위에 남겨 둡니다. ModalContext는 계산된 내부 치수인 rows - 3, columns - 4를 전달하므로, 모달 안의 자식 scroll box가 어디까지 커질 수 있는지 알 수 있습니다.

"새 메시지 N개" pill

사용자가 위로 스크롤하면, 스크롤 영역 아래에 새로 도착한 메시지 수를 보여 주는 pill이 떠 있습니다. 이 가시성 상태는 useSyncExternalStoreScrollBox의 스크롤 위치를 구독해 계산합니다. 덕분에 pill은 부모 REPL 컴포넌트를 다시 렌더링하지 않고도 나타났다 사라집니다.

// pillVisible은 ScrollBox를 직접 구독함, 스크롤 프레임마다 REPL 재렌더링 없음
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, 오프스크린 재그리기 제거

components/OffscreenFreeze.tsx는 non-fullscreen, 즉 main-screen 렌더링 경로에서 생기는 특정 성능 문제를 해결합니다. 콘텐츠가 터미널 viewport 위쪽으로 스크롤되어 scrollback 버퍼로 밀려난 뒤에 그 콘텐츠가 바뀌면, Ink 렌더러는 전체 터미널을 리셋할 수밖에 없습니다. 이미 화면 밖으로 스크롤된 행만 부분 갱신할 수 없기 때문입니다. 매 tick마다 갱신되는 spinner나 경과 시간 카운터라면, 애니메이션 프레임마다 눈에 보이는 리셋이 생깁니다.

export function OffscreenFreeze({ children }: Props): React.ReactNode {
  'use no memo'  // React Compiler opt-out, freeze 자체가 memo 메커니즘이기 때문

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

  if (isVisible || inVirtualList) {
    cached.current = children  // 보이는 동안에만 캐시를 갱신
  }
  // 화면 밖에서는 오래된 ref를 반환, React reconciler가 멈추고 diff가 0이 됨
  return <Box ref={ref}>{cached.current}</Box>
}

이 메커니즘이 통하는 이유는, 반환된 element가 이전 렌더와 같은 객체 정체성을 가질 때 React가 reconciliation을 중단하기 때문입니다. 화면 밖에 있는 동안 캐시된 ref를 그대로 반환하면, 전체 서브트리에서 diff도 0이고 터미널 출력도 0이 됩니다.

가상 목록 예외
OffscreenFreeze는 inVirtualList가 true일 때 이 최적화를 명시적으로 건너뜁니다. ScrollBox의 가상 목록은 모든 콘텐츠를 viewport 안에서 잘라 냅니다. 그래서 터미널 scrollback을 걱정할 일이 없습니다. 더 중요한 점은, 가상 목록 안에서 freeze를 걸면 클릭해서 펼치기 동작이 깨질 수 있다는 것입니다. useTerminalViewport의 가시성 계산이 ScrollBox의 가상 스크롤 위치와 어긋날 수 있기 때문입니다.
React Compiler와의 상호작용
이 컴포넌트는 'use no memo'를 사용합니다. React Compiler의 자동 memoization에서 명시적으로 빠지겠다는 뜻입니다. 컴파일러가 이 컴포넌트를 memoize해 버리면 반환 element 자체를 캐시하게 되고, freeze 메커니즘이 망가집니다. freeze는 컴포넌트 출력을 memoize해서 동작하는 것이 아니라, 일부러 오래된 cached ref를 반환해서 동작하기 때문입니다.
07 수명 주기, 진입부터 종료까지

전체 흐름을 한데 모아 보면, 프로세스 시작에서 첫 렌더를 거쳐 다시 셸로 돌아오기까지는 다음과 같습니다.

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: 마운트 (useInsertionEffect) AlternateScreen->>Terminal: ENTER_ALT_SCREEN + clear + ENABLE_MOUSE_TRACKING AlternateScreen->>Ink: setAltScreenActive(true, mouseTracking) Note over Ink: 렌더러가 커서를 viewport 안으로 제한함 loop 모든 렌더 프레임 Ink->>Terminal: alt-screen 경계 안에서 diff 출력 end Startup->>AlternateScreen: 언마운트 (cleanup) AlternateScreen->>Ink: setAltScreenActive(false) AlternateScreen->>Ink: clearTextSelection() AlternateScreen->>Terminal: DISABLE_MOUSE_TRACKING + EXIT_ALT_SCREEN Note over Terminal: 메인 화면과 커서 위치가 복원됨

tmux와 마우스 스크롤 힌트

tmux 안에서 실행할 때, 단 tmux -CC 모드는 제외하고, 마우스 휠 이벤트는 tmux의 mouse 옵션이 켜져 있을 때만 애플리케이션으로 전달됩니다. Claude Code는 tmux set mouse on을 코드로 실행하지 않습니다. 이전 구현에서 tmux 마우스 상태가 다른 pane들, 예를 들면 vim, less, shell로 새어 나갔기 때문입니다. 대신 maybeGetTmuxMouseHint()가 시작 시 한 번 실행되고, tmux mouse 옵션이 꺼져 있으면 힌트 문자열을 반환합니다.

"tmux가 감지되었습니다 · PgUp/PgDn으로 스크롤하거나 · 휠 스크롤을 쓰려면 ~/.tmux.conf에 'set -g mouse on'을 추가하세요"

핵심 정리

  • 대체 화면은 구형 모드 47이 아니라 DEC 모드 1049(\x1b[?1049h)입니다. 1049는 커서 위치를 저장하고 복원합니다.
  • useInsertionEffectuseLayoutEffect 대신 선택된 이유는, ENTER_ALT_SCREEN이 Ink의 첫 렌더 프레임보다 먼저 터미널에 도달하게 하려는 것입니다. 이 미묘한 타이밍 요구 사항은 useLayoutEffect로는 맞지 않습니다.
  • tmux -CC 감지 probe가 동기식인 것은 의도입니다. async probe는 React 렌더 주기와 경합했고, SSH+tmux 사용자에게 복구 불가능한 깨진 상태, 즉 죽은 마우스 휠을 만들었습니다.
  • Anthropic 내부 사용자(USER_TYPE=ant)에게는 풀스크린이 기본적으로 켜짐이고, 외부 사용자에게는 꺼짐입니다. 외부에서는 CLAUDE_CODE_NO_FLICKER=1로 opt-in합니다.
  • OffscreenFreeze는 React의 객체 정체성 bail-out을 이용해, 터미널 scrollback으로 밀려난 콘텐츠에 대해 diff 출력 0을 만들어, tick마다 일어나는 전체 리셋을 없앱니다.
  • 마우스 트래킹에는 서로 독립적인 kill-switch가 두 개 있습니다. CLAUDE_CODE_DISABLE_MOUSE는 캡처만 죽이고 alt-screen은 유지하고, CLAUDE_CODE_NO_FLICKER=0은 alt-screen을 포함해 모든 것을 끕니다.

이해도 점검

1. DEC private mode 1049는 47과 비교해 무엇을 추가로 할까요?
2. AlternateScreen은 왜 useLayoutEffect 대신 useInsertionEffect를 쓸까요?
3. tmux control-mode probe(probeTmuxControlModeSync)는 동기식입니다. 이것이 async였다면 어떻게 됐을까요?
4. OffscreenFreeze'use no memo' 지시문의 목적은 무엇일까요?
5. 사용자가 CLAUDE_CODE_DISABLE_MOUSE=1을 설정했습니다. 결과를 가장 잘 설명하는 것은 무엇일까요?
0/5