풀스크린 & 대체 화면 모드

대체 버퍼 하이재킹, 마우스 추적, tmux 생존, 깜빡임 방지 — DEC 이스케이프부터 React 훅까지.

01

개요

Claude Code의 풀스크린 모드는 터미널의 대체 화면(alternate screen) 버퍼를 사용하여 전체 화면을 독점합니다. Vim, less, htop과 같은 터미널 프로그램이 사용하는 것과 동일한 메커니즘이지만, React 컴포넌트 시스템과 통합되어 있다는 점이 차별화됩니다. 이 시스템은 3개의 레이어로 구성됩니다.

다루는 소스 파일: fullscreen/detection.tsfullscreen/decSequences.tsfullscreen/AlternateScreen.tsxfullscreen/FullscreenLayout.tsxfullscreen/OffscreenFreeze.tsx

3개 레이어:

02

DEC 프라이빗 모드 상수

터미널의 대체 화면과 관련 기능은 DEC(Digital Equipment Corporation) 프라이빗 모드 이스케이프 시퀀스로 제어됩니다. 각 모드는 고유한 번호를 가지며, CSI ? {mode} h로 활성화하고 CSI ? {mode} l로 비활성화합니다.

// DEC 프라이빗 모드 상수
const DEC = {
  ALT_SCREEN:      1049,  // 대체 화면 버퍼 + 커서 저장/복원
  CURSOR_VISIBLE:  25,    // 커서 표시/숨기기
  MOUSE_TRACKING:  1000,  // 기본 마우스 추적
  MOUSE_SGR:       1006,  // SGR 확장 마우스 모드
  MOUSE_URXVT:     1015,  // urxvt 마우스 모드
  BRACKETED_PASTE: 2004,  // 브래킷 붙여넣기 모드
  LINE_WRAP:       7,     // 자동 줄 바꿈
} as const

// 대체 화면 진입/퇴장
function enterAltScreen(): string {
  return `\x1b[?${DEC.ALT_SCREEN}h`  // 진입
}
function exitAltScreen(): string {
  return `\x1b[?${DEC.ALT_SCREEN}l`  // 퇴장
}

ALT_SCREEN 모드 1049는 대체 버퍼를 활성화하면서 동시에 현재 커서 위치를 저장합니다. 퇴장 시 원래 화면과 커서 위치가 복원됩니다. 이 덕분에 풀스크린 모드를 종료하면 사용자의 이전 터미널 내용이 그대로 돌아옵니다.

딥 다이브 — 모드 1049 vs 1047

모드 1047은 대체 버퍼만 전환하고 커서를 저장하지 않습니다. 모드 1049는 1047 + 커서 저장/복원을 결합한 것입니다. Claude Code는 항상 1049를 사용하여 풀스크린 퇴장 후 커서 위치가 복원되도록 보장합니다. 일부 오래된 터미널에서 1049를 지원하지 않는 경우 감지 레이어에서 풀스크린을 비활성화합니다.

03

풀스크린 감지 로직

모든 환경이 풀스크린을 지원하는 것은 아닙니다. 감지 레이어는 현재 환경이 안전하게 풀스크린을 사용할 수 있는지 확인합니다.

// 풀스크린 지원 감지
function canUseFullscreen(): boolean {
  if (!process.stdout.isTTY) return false   // 파이프 환경 불가
  if (isCI())              return false   // CI 환경 불가
  if (isDumbTerminal())    return false   // TERM=dumb 불가
  return true
}

동기 tmux 프로브

tmux 내부에서 실행 중인 경우, 추가적인 검증이 필요합니다. tmux는 자체적으로 대체 화면을 관리하므로, Claude Code가 대체 화면을 사용하면 tmux의 상태와 충돌할 수 있습니다. 동기 프로브로 tmux 버전과 설정을 확인합니다.

// 동기 tmux 프로브 — tmux 환경 검증
function probeTmux(): TmuxInfo | null {
  if (!process.env.TMUX) return null

  try {
    // 동기 실행 — 부트 시퀀스에서 한 번만 호출
    const version = execSync('tmux -V').toString().trim()
    const altScreen = execSync('tmux show -gv alternate-screen').toString().trim()

    return {
      version: parseTmuxVersion(version),
      alternateScreenEnabled: altScreen !== 'off'
    }
  } catch {
    return null  // tmux 명령 실패 → 풀스크린 비활성화
  }
}
04

AlternateScreen 컴포넌트

AlternateScreen은 대체 화면을 관리하는 React 컴포넌트입니다. 마운트 시 대체 화면에 진입하고, 언마운트 시 원래 화면으로 복귀합니다. useInsertionEffect를 사용하여 DOM 커밋 전에 터미널 상태를 변경합니다.

// AlternateScreen — useInsertionEffect로 깜빡임 방지
function AlternateScreen({ children }: { children: ReactNode }) {
  useInsertionEffect(() => {
    // DOM 커밋 전에 대체 화면 진입 → 깜빡임 없음
    process.stdout.write(enterAltScreen())
    process.stdout.write('\x1b[?25l') // 커서 숨기기

    return () => {
      // 클린업: 원래 화면 복원
      process.stdout.write('\x1b[?25h') // 커서 복원
      process.stdout.write(exitAltScreen())
    }
  }, [])

  return <>{children}</>
}
딥 다이브 — 왜 useInsertionEffect인가?

useEffect는 브라우저 페인트 후에 실행되고, useLayoutEffect는 DOM 커밋 후 페인트 전에 실행됩니다. useInsertionEffect는 DOM 커밋 전에 실행되어 가장 빠른 타이밍을 보장합니다. 대체 화면 전환을 가장 이른 시점에 수행함으로써, 원래 화면 내용이 잠깐이라도 보이는 깜빡임(flash)을 방지합니다.

05

FullscreenLayout과 OffscreenFreeze

FullscreenLayout (슬롯 기반)

FullscreenLayout은 전체 화면을 미리 정의된 슬롯으로 분할합니다. 헤더, 메인 콘텐츠, 사이드바, 상태 표시줄 등의 영역에 컴포넌트를 배치합니다.

// FullscreenLayout — 슬롯 기반 레이아웃
function FullscreenLayout({
  header,
  main,
  sidebar,
  statusBar,
}: LayoutSlots) {
  const { rows, columns } = useTerminalSize()

  return (
    <Box flexDirection="column" height={rows} width={columns}>
      <Box height={1}>{header}</Box>
      <Box flexGrow={1} flexDirection="row">
        <Box flexGrow={1}>{main}</Box>
        {sidebar && <Box width={30}>{sidebar}</Box>}
      </Box>
      <Box height={1}>{statusBar}</Box>
    </Box>
  )
}

OffscreenFreeze ('use no memo')

OffscreenFreeze는 화면에 보이지 않는 컴포넌트의 렌더링을 중지합니다. 풀스크린 모드에서 다른 탭이나 숨겨진 패널의 컴포넌트가 불필요하게 렌더링되는 것을 방지합니다.

// OffscreenFreeze — 보이지 않는 컴포넌트 렌더링 중지
function OffscreenFreeze({ visible, children }: FreezeProps) {
  'use no memo'  // React 컴파일러에게 메모이제이션 비활성화 지시

  const lastRendered = useRef<ReactNode>(null)

  if (visible) {
    lastRendered.current = children
  }

  // 보이지 않으면 마지막 렌더링 결과를 재사용 (재렌더링 없음)
  return <>{lastRendered.current}</>
}
딥 다이브 — 'use no memo' 디렉티브

'use no memo'는 React 컴파일러(React Forget)에게 이 컴포넌트를 자동 메모이제이션하지 말라고 지시하는 디렉티브입니다. OffscreenFreeze는 의도적으로 visiblefalse일 때 자식을 렌더링하지 않는데, 자동 메모이제이션이 적용되면 이 최적화가 무효화됩니다. 메모이제이션을 비활성화하여 수동 최적화가 올바르게 동작하도록 보장합니다.

06

풀스크린 생명주기

flowchart TD A["canUseFullscreen() 감지"] --> B{"지원 여부"} B -->|"불가"| C["일반 모드 유지"] B -->|"가능"| D["AlternateScreen 마운트"] D --> E["useInsertionEffect: 대체 화면 진입"] E --> F["FullscreenLayout 렌더링"] F --> G["OffscreenFreeze: 비활성 탭 동결"] G --> H{"풀스크린 종료?"} H -->|"아니오"| F H -->|"예"| I["클린업: 대체 화면 퇴장"] I --> J["원래 화면 복원"] style A fill:#c47a50,color:#1a1816 style J fill:#6e9468,color:#1a1816
  1. 감지 단계: canUseFullscreen()이 현재 환경을 검사합니다. tmux에서는 동기 프로브로 추가 검증합니다.
  2. 진입 단계: AlternateScreen이 마운트되면 useInsertionEffect로 대체 화면에 진입합니다.
  3. 렌더링 단계: FullscreenLayout이 슬롯 기반으로 UI를 배치하고, OffscreenFreeze가 비활성 영역을 동결합니다.
  4. 퇴장 단계: AlternateScreen이 언마운트되면 클린업 함수가 실행되어 원래 화면으로 복원됩니다.
주의사항

프로세스가 비정상 종료(SIGKILL)되면 클린업 함수가 실행되지 않아 터미널이 대체 화면에 갇힐 수 있습니다. 이 경우 사용자가 수동으로 reset 또는 tput rmcup을 실행해야 합니다. SIGTERM과 SIGINT는 process.on('exit')에서 클린업을 수행합니다.

07

핵심 요약

핵심 포인트

  • 풀스크린 시스템은 3레이어로 구성됩니다: Detection → DEC Sequences → React Integration
  • DEC 프라이빗 모드 1049는 대체 버퍼 + 커서 저장/복원을 결합합니다
  • 풀스크린 감지는 TTY, CI 환경, TERM 변수를 확인하고, tmux에서는 동기 프로브로 추가 검증합니다
  • AlternateScreenuseInsertionEffect로 DOM 커밋 전에 화면을 전환하여 깜빡임을 방지합니다
  • FullscreenLayout은 슬롯 기반으로 헤더, 메인, 사이드바, 상태 표시줄 영역을 관리합니다
  • OffscreenFreeze'use no memo' 디렉티브와 함께 비활성 영역의 렌더링을 중지합니다
  • SIGTERM/SIGINT는 클린업을 수행하지만, SIGKILL은 대체 화면에 갇힐 수 있습니다
08

지식 확인

퀴즈 — 5문제

Q1. DEC 프라이빗 모드 1049가 1047보다 선호되는 이유는?

  • A) 1049가 더 많은 터미널에서 지원되기 때문
  • B) 1049가 더 빠르기 때문
  • C) 1049는 대체 버퍼 전환과 커서 저장/복원을 동시에 수행하여 풀스크린 퇴장 후 커서 위치가 복원되기 때문
  • D) 1049가 마우스 추적을 지원하기 때문
모드 1049는 대체 버퍼 전환(1047)과 커서 저장/복원(1048)을 결합한 것입니다. 풀스크린 모드를 종료하면 원래 화면 내용과 커서 위치가 모두 복원됩니다. 1047만 사용하면 커서 위치가 유실됩니다.

Q2. AlternateScreen에서 useInsertionEffect를 사용하는 이유는?

  • A) DOM 커밋 전에 대체 화면에 진입하여 원래 화면 내용이 잠깐이라도 보이는 깜빡임을 방지하기 위해
  • B) useEffect가 터미널 환경에서 동작하지 않기 때문
  • C) 비동기 I/O를 지원하기 위해
  • D) 컴포넌트 마운트 순서를 제어하기 위해
useInsertionEffect는 React의 이펙트 중 가장 이른 타이밍에 실행됩니다. 대체 화면 전환을 DOM 커밋 전에 수행하면, 원래 화면의 내용이 렌더링되기 전에 화면이 전환되어 깜빡임(flash)이 발생하지 않습니다.

Q3. OffscreenFreeze에서 'use no memo' 디렉티브가 필요한 이유는?

  • A) 메모리 사용량을 줄이기 위해
  • B) React 컴파일러의 자동 메모이제이션이 수동 최적화(보이지 않을 때 렌더링 중지)를 무효화하는 것을 방지하기 위해
  • C) 컴포넌트의 props 비교를 비활성화하기 위해
  • D) 서버 사이드 렌더링과의 호환성을 위해
OffscreenFreezevisiblefalse일 때 의도적으로 마지막 렌더링 결과를 재사용합니다. React 컴파일러가 자동으로 메모이제이션을 적용하면 이 로직이 깨질 수 있어, 'use no memo'로 자동 최적화를 비활성화합니다.

Q4. tmux 동기 프로브가 필요한 이유는 무엇인가요?

  • A) tmux 버전을 표시하기 위해
  • B) tmux 세션을 자동으로 생성하기 위해
  • C) tmux의 키바인딩을 Claude Code에 복사하기 위해
  • D) tmux가 자체적으로 대체 화면을 관리하므로, Claude Code의 대체 화면 사용이 충돌하지 않는지 검증하기 위해
tmux는 자체적으로 각 pane에 대한 대체 화면 상태를 관리합니다. Claude Code가 추가로 대체 화면을 사용하면 tmux의 상태와 충돌할 수 있습니다. 동기 프로브로 tmux의 alternate-screen 설정을 확인하여 안전하게 사용할 수 있는지 판단합니다.

Q5. 프로세스가 SIGKILL로 종료될 때 발생하는 문제는 무엇인가요?

  • A) 클린업 함수가 실행되지 않아 터미널이 대체 화면에 갇힐 수 있다
  • B) 터미널이 자동으로 원래 화면으로 복원된다
  • C) tmux가 대체 화면을 정리해준다
  • D) 다음 프로세스 시작 시 자동으로 복원된다
SIGKILL은 프로세스를 즉시 종료하며 어떤 핸들러도 실행할 수 없습니다. useInsertionEffect의 클린업 함수와 process.on('exit') 핸들러가 모두 실행되지 않아 터미널이 대체 화면에 갇힙니다. 사용자가 reset 또는 tput rmcup을 수동으로 실행해야 합니다.
0 / 5