대체 버퍼 하이재킹, 마우스 추적, tmux 생존, 깜빡임 방지 — DEC 이스케이프부터 React 훅까지.
Claude Code의 풀스크린 모드는 터미널의 대체 화면(alternate screen) 버퍼를 사용하여 전체 화면을 독점합니다. Vim, less, htop과 같은 터미널 프로그램이 사용하는 것과 동일한 메커니즘이지만, React 컴포넌트 시스템과 통합되어 있다는 점이 차별화됩니다. 이 시스템은 3개의 레이어로 구성됩니다.
fullscreen/detection.ts → fullscreen/decSequences.ts → fullscreen/AlternateScreen.tsx → fullscreen/FullscreenLayout.tsx → fullscreen/OffscreenFreeze.tsx
3개 레이어:
터미널의 대체 화면과 관련 기능은 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는 대체 버퍼를 활성화하면서 동시에 현재 커서 위치를 저장합니다. 퇴장 시 원래 화면과 커서 위치가 복원됩니다. 이 덕분에 풀스크린 모드를 종료하면 사용자의 이전 터미널 내용이 그대로 돌아옵니다.
모드 1047은 대체 버퍼만 전환하고 커서를 저장하지 않습니다. 모드 1049는 1047 + 커서 저장/복원을 결합한 것입니다. Claude Code는 항상 1049를 사용하여 풀스크린 퇴장 후 커서 위치가 복원되도록 보장합니다. 일부 오래된 터미널에서 1049를 지원하지 않는 경우 감지 레이어에서 풀스크린을 비활성화합니다.
모든 환경이 풀스크린을 지원하는 것은 아닙니다. 감지 레이어는 현재 환경이 안전하게 풀스크린을 사용할 수 있는지 확인합니다.
// 풀스크린 지원 감지
function canUseFullscreen(): boolean {
if (!process.stdout.isTTY) return false // 파이프 환경 불가
if (isCI()) return false // CI 환경 불가
if (isDumbTerminal()) return false // TERM=dumb 불가
return true
}
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 명령 실패 → 풀스크린 비활성화
}
}
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}</>
}
useEffect는 브라우저 페인트 후에 실행되고, useLayoutEffect는 DOM 커밋 후 페인트 전에 실행됩니다. useInsertionEffect는 DOM 커밋 전에 실행되어 가장 빠른 타이밍을 보장합니다. 대체 화면 전환을 가장 이른 시점에 수행함으로써, 원래 화면 내용이 잠깐이라도 보이는 깜빡임(flash)을 방지합니다.
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>
)
}
'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는 의도적으로 visible이 false일 때 자식을 렌더링하지 않는데, 자동 메모이제이션이 적용되면 이 최적화가 무효화됩니다. 메모이제이션을 비활성화하여 수동 최적화가 올바르게 동작하도록 보장합니다.
canUseFullscreen()이 현재 환경을 검사합니다. tmux에서는 동기 프로브로 추가 검증합니다.AlternateScreen이 마운트되면 useInsertionEffect로 대체 화면에 진입합니다.FullscreenLayout이 슬롯 기반으로 UI를 배치하고, OffscreenFreeze가 비활성 영역을 동결합니다.AlternateScreen이 언마운트되면 클린업 함수가 실행되어 원래 화면으로 복원됩니다.프로세스가 비정상 종료(SIGKILL)되면 클린업 함수가 실행되지 않아 터미널이 대체 화면에 갇힐 수 있습니다. 이 경우 사용자가 수동으로 reset 또는 tput rmcup을 실행해야 합니다. SIGTERM과 SIGINT는 process.on('exit')에서 클린업을 수행합니다.
AlternateScreen은 useInsertionEffect로 DOM 커밋 전에 화면을 전환하여 깜빡임을 방지합니다FullscreenLayout은 슬롯 기반으로 헤더, 메인, 사이드바, 상태 표시줄 영역을 관리합니다OffscreenFreeze는 'use no memo' 디렉티브와 함께 비활성 영역의 렌더링을 중지합니다Q1. DEC 프라이빗 모드 1049가 1047보다 선호되는 이유는?
Q2. AlternateScreen에서 useInsertionEffect를 사용하는 이유는?
useInsertionEffect는 React의 이펙트 중 가장 이른 타이밍에 실행됩니다. 대체 화면 전환을 DOM 커밋 전에 수행하면, 원래 화면의 내용이 렌더링되기 전에 화면이 전환되어 깜빡임(flash)이 발생하지 않습니다.Q3. OffscreenFreeze에서 'use no memo' 디렉티브가 필요한 이유는?
OffscreenFreeze는 visible이 false일 때 의도적으로 마지막 렌더링 결과를 재사용합니다. React 컴파일러가 자동으로 메모이제이션을 적용하면 이 로직이 깨질 수 있어, 'use no memo'로 자동 최적화를 비활성화합니다.Q4. tmux 동기 프로브가 필요한 이유는 무엇인가요?
alternate-screen 설정을 확인하여 안전하게 사용할 수 있는지 판단합니다.Q5. 프로세스가 SIGKILL로 종료될 때 발생하는 문제는 무엇인가요?
useInsertionEffect의 클린업 함수와 process.on('exit') 핸들러가 모두 실행되지 않아 터미널이 대체 화면에 갇힙니다. 사용자가 reset 또는 tput rmcup을 수동으로 실행해야 합니다.