Skip to main content

Command Palette

Search for a command to run...

왜 Claude Code의 TUI는 망가졌는가

Updated
27 min read
G
Passionate software engineer with a focus on (Game Engine ->) C-like extended language development.

512,000줄의 코드가 말해주는 것 — 추상화는 왜 convention으로는 지켜지지 않는가.

Author: Gyeongtae Kim (김경태) · codingpelican@gmail.com · github.com/coding-pelican

Published: 2026-03-31

본 기사는 2026-03-31 유출된 Claude Code 소스코드[^leak]를 분석 대상으로 하며, TUI(Terminal User Interface) 구현 레이어를 중심으로 분석한다.

[^leak]: 2026년 3월 31일, @anthropic-ai/claude-code npm 패키지 v2.1.88에 59.8MB 크기의 source map 파일(cli.js.map)이 포함된 채 공개 레지스트리에 배포되었다. 이 source map에는 약 512,000줄, 1,906개 TypeScript 파일의 전체 미난독화 소스코드가 포함되어 있었다. ET 기준 오전 4:23, Solayer Labs 인턴 Chaofan Shou(@Fried_rice)가 X(구 Twitter)에 이 사실을 공개하였고, 수 시간 내에 GitHub에 미러링되어 1,100+ stars, 1,900+ forks를 기록하였다. 본 기사의 분석은 해당 소스의 미러 레포지터리(sanbuphy/claude-code-source-code)를 대상으로 수행하였다. 한편 같은 날, 별개의 npm 공급망 공격으로 axios 패키지(주간 다운로드 8,300만 건)의 메인테이너 계정이 탈취되어, 악성 버전(1.14.1 및 0.30.4)에 크로스플랫폼 RAT(Remote Access Trojan)가 삽입·배포되었다. 공격 윈도우는 2026-03-31 00:21~03:29 UTC이며, 해당 시간대에 npm을 통해 Claude Code를 설치·업데이트한 경우 lockfile에서 axios 1.14.1/0.30.4 또는 의존성 plain-crypto-js의 존재를 확인해야 한다. Anthropic은 이후 npm 기반 설치 대신 native installer(curl -fsSL https://claude.ai/install.sh | bash)를 권장 설치 방법으로 지정하였다.


Part I: 설계 의도의 재구성

1. 터미널에서 마주치는 것들

Claude Code를 Windows에서 쓴 적이 있다면, 또는 Claude Code 실행 중에 예기치 않게 종료한 적이 있다면, 아마 다음 중 하나를 경험했을 것이다.

lsgrep, && 같은 명령이 첫 번에 "Windows에서는 이 명령이 없습니다"라는 메시지와 함께 실패한다.[^cmd-fail] 다시 실행하면 된다. 특정 키 조합이 씹히거나 의도와 다르게 동작한다. Ctrl+C를 눌렀는데 복사가 아닌 프로세스 종료가 일어난다. 대화가 길어지면 화면이 찢기고, 박스의 테두리가 어긋나며, 스타일이 깨진다. 도구를 종료하고 나면 VSCode 통합 터미널에서 단축키가 먹히지 않는다.

[^cmd-fail]: 이 현상은 TUI 레이어의 구조적 문제라기보다, LLM의 tool use 동작에 기인할 가능성이 높다. Claude(모델)가 Windows 환경에서 처음에 Unix 명령어(ls, grep, &&)를 생성하고, bash tool이 에러를 반환하면 재시도 시 dir이나 PowerShell 명령으로 전환하는 패턴이다. 본 기사에서 분석하는 TUI 구조 문제와는 원인 계층이 다르지만, 사용자가 "터미널에서 겪는 불편함"으로 인식하는 경험의 일부이므로 도입부에 포함하였다.

이것들은 각각 다른 원인에서 비롯된 독립적인 버그처럼 보인다. 그러나 코드를 읽어보면 공통된 구조적 패턴이 드러난다. 이것이 이 분석의 핵심 주장이다: 이 현상들은 개별 버그가 아니라, 추상화가 설계되고도 일관성 있게 강제되지 못한 결과로 파생된 구조적 증상들이다.

그 구조를 이해하려면 먼저 Claude Code가 terminal을 어떻게 다루려 했는지를 봐야 한다.

2. 원래의 야심

코드베이스에서 읽히는 original architecture는 세 단계였다.

React Component Tree
      ↓  (react-reconciler)
Virtual DOM (ink nodes + Yoga Flexbox layout)
      ↓  (renderer)
Double-buffered cell diff → single stdout.write()

이것은 그 자체로 야심 있는 설계다. React component를 그대로 TUI로 쓸 수 있다면, 웹 출신 기여자들이 UI를 작성하는 데 별도의 학습 비용이 없다. Yoga(Facebook의 Flexbox 레이아웃 엔진)를 terminal에 올리겠다는 것도 용감한 시도다. Terminal을 일종의 그래픽 surface처럼 다루겠다는 의도가 screen.ts, renderer.ts, render-node-to-output.ts 등의 구조에서 드러난다.

그 의도대로 작동했다면 꽤 좋은 TUI 프레임워크였을 것이다. 문제는 그 의도가 일관성 있게 유지되지 않았다는 점이다. 이어지는 분석은 그 의도가 어디서, 어떻게 무너졌는지를 코드 수준에서 추적한다.


Part II: 비판 포인트별 실증 분석

3. Double Buffer의 invariant 붕괴

Double buffering은 TUI 렌더링의 표준 기법이다. 게임 엔진이나 GPU 렌더링에서도 사용되는 이 기법의 핵심 invariant는 하나다: front buffer = 현재 화면의 ground truth. 이것이 보장될 때만 diff 연산이 의미를 가진다. 현재 화면 상태를 정확히 알고 있으니, 다음 프레임과 비교해서 "바뀐 cell만 다시 쓰면 된다"는 것이다. Claude Code의 실제 구현(diffEach)은 damage rectangle — 변경이 발생한 영역만을 추적하는 사각형 — 을 활용하여, 정상 프레임에서는 변경된 영역 범위 내의 cell만 순회한다. Spinner 하나가 바뀌었다면 그 cell 주변만 스캔하면 된다.

renderer.tsink.tsx는 명확히 이 구조를 선언한다.

// ink.tsx
private frontFrame: Frame;
private backFrame: Frame;

writeDiffToTerminal(terminal.ts)도 구조적으로 올바르다. Diff[]를 단일 buffer string으로 누적해서 stdout.write() 한 번에 flush한다. Terminal에 대한 I/O syscall이 한 번만 발생하므로, 중간에 화면이 반쯤 그려진 상태가 사용자에게 보이지 않는다.

// terminal.ts
let buffer = useSync ? BSU : ''
// ... diff 항목들을 buffer에 누적 ...
terminal.stdout.write(buffer)  // 단 한 번의 syscall

여기까지는 교과서적으로 올바르다. 그러나 ink.tsx에서 alt-screen 전환 시 이 경로 밖에서 stdout에 직접 쓰는 코드가 있다.

// ink.tsx (alt-screen 진입)
this.altScreenActive ? '' : '\x1b[?1049h') +
'\x1b[?1004l' +
'\x1b[0m' +
'\x1b[?25h' +
'\x1b[2J' +

이것이 발생하면 무슨 일이 벌어지는가? 실제 화면은 완전히 지워졌지만(\x1b[2J), front buffer에는 이전 프레임의 데이터가 그대로 남아 있다. front buffer ≠ 화면. invariant가 깨졌다.

그 결과를 코드 작성자들도 인지하고 있었다는 증거가 renderer.ts에 있다.

// renderer.ts
// True when the previous frame's screen buffer was mutated post-render
// (selection overlay), reset to blank (alt-screen enter/resize/SIGCONT),
// or reset to 0×0 (forceRedraw). Blitting from such a prevScreen would
// copy stale inverted cells, blanks, or nothing.
private prevFrameContaminated = false;

prevFrameContaminated. 이 플래그의 존재 자체가 문제의 자기 고백이다. 주석이 정확히 세 가지 원인을 나열한다: selection overlay, alt-screen 진입/resize/SIGCONT, forceRedraw. 전부 front buffer와 실제 화면 사이의 동기가 깨지는 경우다.

더 주목할 것은 팀이 이 문제를 단순히 인지한 수준이 아니라 측정하고 있었다는 점이다. full redraw가 발생할 때 호출되는 함수의 이름은 fullResetSequence_CAUSES_FLICKER다. 이름 자체에 CAUSES_FLICKER를 포함시킨 것은 이것이 bad UX임을 의도적으로 표시한 것이다. 그리고 이 함수가 호출될 때마다 onFrame 이벤트의 flickers 배열에 기록된다.

// frame.ts
export type FlickerReason = 'resize' | 'offscreen' | 'clear'

export type FrameEvent = {
  flickers: Array<{
    desiredHeight: number
    availableHeight: number
    reason: FlickerReason
  }>
}

flicker가 언제, 왜, 어떤 크기 불일치로 발생하는지를 구조적으로 추적하는 인프라다. 이것은 "모르고 방치한" 것이 아니다. 문제를 인지하고, 측정하고, 그러나 근본 수정 없이 기록만 남긴 것이다.

이 플래그가 true일 때 시스템은 damage rectangle을 전체 화면 크기로 강제 확장한다. 변경된 cell에만 ANSI sequence를 emit하더라도, 전체 rows × cols검사하는 비용이 매 프레임 발생한다. Double buffer를 도입한 핵심 이점 — damage rectangle 범위에 국한된 순회 — 이 alt-screen 전환마다 증발한다. 비유하자면: 조심스럽게 설계한 캐시 시스템이 "어차피 캐시가 오염됐으니 전부 다시 로드합시다"를 반복하는 것과 같다.

이것이 사용자 경험에서 어떻게 나타나는가? 화면이 순간적으로 깜빡인다. 특히 터미널 창 크기를 조절할 때 렌더링된 박스의 테두리가 어긋나고 스타일이 일시적으로 깨지는 현상이 발생한다. 대화가 길어질수록 full redraw의 빈도가 누적되어 깜빡임이 잦아진다.

더 심한 증상도 있다. DOM flow 모델의 특성상 콘텐츠가 화면 높이를 초과할 때 terminal이 스크롤백 버퍼로 내용을 밀어내는 현상이 발생하는데, 대화가 매우 길어지면 alt-screen 경계가 무너지면서 무한 스크롤처럼 보이는 상태가 만들어지거나, 이전 출력이 스크롤백에 남는 문제가 생긴다. 이것은 버그라기보다 "스트림이 화면을 넘치면 스크롤한다"는 DOM 모델이 그대로 terminal에 반영된 결과다. 이 DOM 모델과의 근본적인 마찰은 Section 6에서 별도로 분석한다.


4. 이벤트 추상화 경계의 누설

parse-keypress.ts라는 전용 keypress 파서가 존재한다. 이것이 있다면, 코드베이스의 어느 곳도 raw escape sequence를 직접 볼 이유가 없어야 한다. 파서가 \x1b[H를 받아 "이것은 Home 키입니다"라고 알려주면, consumer는 Home 키라는 사실만 알면 된다. 이것이 파서의 존재 이유다.

그러나 useTextInput.ts에서:

case input === '\x1b[H' || input === '\x1b[1~':   // Home 키
case input === '\x1b[F' || input === '\x1b[4~':   // End 키

파서를 우회하여 raw escape string을 직접 비교하고 있다. 여기에 주목해야 하는 것은 ||의 존재다. \x1b[H\x1b[1~은 둘 다 Home 키를 의미한다. 전자는 xterm 계열 terminal이 보내는 sequence고, 후자는 VT220 계열이 보내는 sequence다. 같은 물리 키를 누르더라도 terminal emulator에 따라 다른 바이트열이 날아온다. 이 차이를 흡수하는 것이 정확히 파서의 역할이다.

그런데 왜 consumer가 이 차이를 직접 처리하고 있는가? 근본 원인은 parse-keypress.ts의 반환 타입에 있다. 파서가 escape sequence를 의미 있는 semantic type으로 변환하지 않고, 다른 string으로 변환하는 translator에 머물러 있기 때문이다. Consumer layer에서 raw literal 비교가 "필요해지는" 구조적 이유다.

비교를 위해, 올바른 추상화가 어떤 모습인지 보자. Rust의 crossterm 라이브러리가 이 구조를 구현하고 있다(크로스플랫폼 TTY 추상화에 대해서는 Section 8에서 별도로 다룬다).

// crossterm 기반 이벤트 처리 — consumer는 raw byte를 볼 수 없다
use crossterm::event::{self, Event, KeyCode, KeyEvent};

if let Event::Key(KeyEvent { code, .. }) = event::read()? {
    match code {
        KeyCode::Home => handle_home(),
        KeyCode::End  => handle_end(),
        _ => {}
    }
}

파서의 출력이 KeyCode::Home이라는 enum variant이면, consumer는 \x1b[H\x1b[1~이 사실 같은 키임을 알 필요조차 없다. Terminal마다 같은 키에 대해 다른 sequence를 보내는 문제가 파서 내부에서 완전히 흡수된다. Consumer가 raw byte를 볼 수 있는 경로 자체가 타입 시스템에 의해 차단된다. KeyCode enum에서 원래의 escape sequence 바이트열을 복원하는 방법은 없다.

정리하면: ANSI 파서(parse-keypress.ts)가 있음에도 consumer(useTextInput.ts)가 raw escape literal을 직접 비교하는 이유는, 파서의 출력이 semantic type이 아닌 또 다른 string이기 때문이다. 추상화가 타입 경계로 강제되지 않아, 계층을 건너뛰어도 컴파일러가 막지 않는다. TypeScript의 string 타입은 escape sequence와 일반 텍스트를 구분하지 않는다. '\x1b[?1004l'"hello"가 같은 타입이므로, 파서를 우회하는 것이 타입 오류가 아니다.

이 문제가 사용자 경험에서 가장 직접적으로 드러나는 것이 키 이벤트 충돌이다. Terminal raw mode에서는 키보드 입력의 해석을 application이 전적으로 담당한다. 이것이 어떤 의미인지 구체적으로 짚어야 한다.

일반적인 terminal 모드(cooked mode)에서 Ctrl+C를 누르면 OS가 SIGINT를 발생시켜 프로세스를 종료한다. 이것은 application이 아닌 terminal driver가 처리하는 것이다. 그러나 raw mode로 진입하면 — setRawMode(true) — terminal driver가 이 처리를 하지 않는다. Ctrl+C는 단순히 바이트 \x03으로 stdin에 도착한다. 이 바이트를 "프로세스 종료"로 해석할지, "텍스트 복사"로 해석할지, "현재 입력 취소"로 해석할지는 전적으로 application의 결정이다.

마찬가지로 Ctrl+Z는 Linux에서 SIGTSTP(프로세스 일시정지)를 발생시키지만, 텍스트 편집 맥락에서 사용자는 undo를 기대한다. 이런 재정의는 raw mode에서 충분히 구현 가능한 것이며, 실제로 vim이나 tmux 같은 TUI 도구들이 그렇게 한다. Raw mode를 쓰는 이상 이런 처리는 "가능한" 수준이 아니라 "해야 하는" 수준의 것이다.

그러나 이를 올바르게 처리하려면 이벤트 파이프라인이 키 코드와 modifier의 조합을 semantic action으로 변환하는 단계를 명확히 가져야 한다. 파서의 출력이 여전히 raw string이면, application 레이어가 '\x03'(Ctrl+C의 ASCII 코드)을 받아 "이것이 지금 맥락에서 복사인가, 인터럽트인가"를 판단해야 한다. 그 판단 로직이 일관성 있게 구현되지 않으면 — 그리고 useTextInput.ts의 raw literal 비교가 보여주듯 일관성이 없으면 — 어떤 맥락에서는 Ctrl+C가 의도대로 동작하고 어떤 맥락에서는 프로세스를 종료시킨다. 사용자 입장에서는 "같은 키를 눌렀는데 때에 따라 다르게 동작한다"는 불확정성을 경험한다.


5. TTY 복원의 분산된 책임

Terminal을 raw mode로 진입(setRawMode(true))한 프로세스가 종료될 때 반드시 원래 terminal 상태를 복원해야 한다. 이것은 선택이 아니다. 복원하지 않으면 이후 사용자의 모든 terminal 입력이 영향을 받는다. 타이핑한 글자가 에코되지 않거나, Enter 키가 줄바꿈을 만들지 않거나, Ctrl+C가 프로세스를 종료하지 않는다. VSCode 통합 터미널에서 Claude Code 종료 후 단축키가 인식되지 않는 현상이 정확히 이 케이스다.

이 문제의 해결 원칙은 단순하다: 누가 raw mode로 진입했든, 그 "누가"가 복원까지 책임진다. 리소스 관리의 기본 패턴이다. 열었으면 닫는다. 잠갔으면 풀어준다. 이것은 fclose(f) 호출을 잊지 않는 것과 본질적으로 같은 문제다.[^raii]

[^raii]: 이 원리는 C++에서 RAII(Resource Acquisition Is Initialization)라는 이름으로 체계화되었다. 리소스의 수명을 스코프의 수명에 묶어, 스코프가 끝나면 자동으로 정리되도록 하는 패턴이다. C에는 RAII가 언어 수준에서 없지만, 매크로 기법으로 Go의 defer와 유사한 의미론을 구현하여 컴파일러가 핸드코딩된 정리 코드와 동일한 어셈블리를 생성하게 만들 수 있다. 이 주제에 대해서는 동일 저자의 이전 글 "Zero-Cost defer in C: A Compiler Optimization Odyssey" — Duff's device 기반 상태 머신, GCC/Clang 최적화 분기 분석, LLVM DFAJumpThreading 패스 발견까지의 과정을 다룬 기술 기사 — 에서 상세히 분석하였다.

그러나 Claude Code에서는 복원 로직이 여러 곳에 분산되어 있다.

  • gracefulShutdown.ts: SHOW_CURSOR, EXIT_ALT_SCREEN, DISABLE_KITTY_KEYBOARD 등을 writeSync로 flush

  • Ink 인스턴스의 unmount(): signal-exit hook을 통해 추가 복원

  • earlyInput.ts: raw mode 진입 후 의도적으로 복원을 생략

마지막 항목이 특히 의미심장하다. 코드에 주석이 달려 있다.

// earlyInput.ts
// Don't reset stdin state - the REPL's Ink App will manage stdin state.
// If we call setRawMode(false) here, it can interfere with the REPL's
// own stdin setup which happens around the same time.

이 주석이 말하는 것: "나는 raw mode로 진입하지만, 복원은 내가 하지 않겠다. 다른 모듈(Ink App)이 해줄 것이다." 그 Ink App은 자신의 unmount()에서 복원을 시도한다. gracefulShutdown.ts도 별도로 복원을 시도한다. 각 모듈이 서로 다른 모듈이 처리해줄 것이라고 가정하고 있다.

정상 종료 경로에서는 gracefulShutdown이 충분히 처리한다. 그러나 비정상 종료(SIGKILL, process crash, bridge 서브시스템에서의 예외)나 onExit hook이 제때 발화하지 않는 경우에는? 복원이 보장되지 않는다. "누가 이 상태의 주인인가"라는 질문에 답할 수 없는 구조다.

올바른 설계는 복원 책임을 단일 소유자에게 귀속시키는 것이다.

// 단일 소유자 패턴의 핵심 구조
typedef struct TermCtx {
    struct termios old_termios;  // 진입 시 캡처
    bool is_raw;
} TermCtx;

// init: 현재 상태 저장 → raw mode 진입
TermCtx_init(...);

// fini: is_raw 확인 후 무조건 복원
// try/finally 또는 defer 패턴으로 보장
TermCtx_fini(...);

TermCtx_fini가 어떤 경로로 호출되든 is_rawtrue이면 복원이 실행된다는 계약이 단일 구현체에 존재한다. 진입과 복원이 같은 struct의 같은 두 함수(init/fini)에 캡슐화되므로, 한 모듈이 진입하고 다른 모듈이 복원을 맡는 상황이 구조적으로 불가능하다. 복원 책임을 여러 모듈이 분담할 때 발생하는 타이밍 레이스와 누락 경로가 제거된다.


6. React + DOM 모델의 impedance mismatch: 역사적 역설

여기까지의 분석은 개별 구현 결함들이었다. 추상화를 만들어놓고 우회하는 것, 복원 책임을 분산시키는 것 — 이런 것들은 원칙적으로 고칠 수 있는 문제다. 그러나 이 섹션에서 다루는 문제는 다른 종류다. 아키텍처의 토대에 놓인 모델 자체의 mismatch이기 때문이다.

이 문제를 이해하려면 TTY가 무엇인지, 그리고 왜 DOM이 "자연스러운 선택처럼 보이는지"부터 짚어야 한다.

TTY의 역사적 기원과 현대적 용도의 분열

TTY(Teletype)는 1960~70년대 실제 전신 타자기에서 유래한다. 텍스트를 한 줄씩 순서대로 출력하는 물리적 장치였다. 종이에 인쇄하는 장치이므로, 한번 출력된 것은 되돌릴 수 없었다. 출력은 한 방향으로만 흐른다.

VT100(1978)이 이것을 전자 화면으로 대체하면서 cursor 이동, 색상, 화면 지우기 등의 escape sequence가 추가되었다. 그러나 VT100도 근본적으로는 같은 모델 위에 만들어졌다. 문자가 순서대로 흘러나오고, escape sequence는 "이 흐름의 중간에 끼어들어 커서를 옮기거나 색을 바꾸는" 제어 명령이다. 이 역사적 흐름에서 TTY의 기본 모델은 스트림 — 문자가 순서대로 흘러나오는 것이다.

DOM(HTML)의 렌더링 모델도 스트림과 닮아 있다. 블록 요소는 위에서 아래로 쌓이고, 인라인 요소는 좌에서 우로 흐르다가 넘치면 줄바꿈된다. 문서가 "흘러내리는" 모델이다. 콘텐츠가 화면 높이를 넘기면? 스크롤바가 생긴다. 이것이 웹 문서의 자연스러운 동작이다.

따라서 React + DOM을 terminal에 얹겠다는 선택은 역사적 계보상 자연스럽게 보인다. TTY도 스트림, DOM도 스트림. 둘 다 "위에서 아래로 흐르는" 모델이니, 잘 맞을 것 같다.

그러나 이것이 역설의 핵심이다.

현대의 전체화면 interactive TUI — htop, vim, tmux, lazygit 같은 프로그램들 — 는 그 스트림 모델과 근본적으로 다른 무언가를 필요로 한다.

모델 특성 맞는 use case
스트림/DOM flow 위→아래, 좌→우 흐름, 넘치면 스크롤 로그 출력, 문서 렌더링
캔버스/셀 격자 임의 좌표에 cell 직접 쓰기, 고정 크기 화면 전체화면 TUI, 게임

htop이 동작하는 방식을 생각해보자. 화면은 rows × cols의 고정 격자다. 프로세스 목록이 갱신되면, htop은 정확히 "3번째 행, 15번째 열부터 이 문자열을 쓰겠다"는 식으로 동작한다. 화면 전체가 고정 크기 캔버스이고, 각 위치에 직접 cursor를 이동해서 문자를 쓴다. 콘텐츠가 화면을 넘기면? 스크롤바가 자동으로 생기는 것이 아니다. Application이 명시적으로 스크롤 영역을 관리한다.

이것은 DOM의 flow 모델이 아니다. Flutter의 렌더링 모델, 또는 게임 엔진의 sprite 시스템과 같다.

Flutter의 렌더링 파이프라인을 단순화하면:

Widget tree
  ↓ layout pass (각 widget의 size/position 결정)
  ↓ paint pass (고정 크기 canvas에 직접 그리기)
  ↓ diff (이전 프레임과 비교)
  ↓ emit (변경된 부분만 출력)

각 단계가 명확히 분리되어 있고, 최종 출력이 "좌표 (x,y)에 이 셀을 그려라"라는 명령의 집합이다. Terminal의 cursor 이동(\x1b[{row};{col}H) + 문자 출력 모델과 1:1로 대응된다. 한 프레임의 결과물은 "이전 프레임과 달라진 cell들의 좌표+내용 목록"이다. 이것이 double buffer diff의 자연스러운 입력이다.

반면 DOM/Flexbox 모델은 이 단계에 도달하기 위해 불필요한 개념들을 중간에 거쳐야 한다: inline/block 구분, margin collapse, wrapping 동작, 스크롤 오버플로우 처리 등. 이것들은 전부 "스트림이 화면을 넘칠 때 어떻게 할 것인가"라는 문제의 해결책인데, 전체화면 TUI에서는 그 문제 자체가 발생해서는 안 된다. 화면은 고정이고, 넘치는 것은 application이 관리한다.

Claude Code의 선택은 TTY의 역사적 기원(스트림)과 일치하지만, TUI의 현대적 요구사항(캔버스)과 충돌한다. Yoga를 도입한 것은 full DOM에서 한 발 물러선 선택이지만, Flexbox 자체도 여전히 "main axis를 따라 흐른다"는 flow 개념을 가지고 있어 terminal cell 격자와 마찰이 남는다.

코드에서의 실제 충돌 증거

이 마찰은 추상적인 이야기가 아니다. 코드에 증거가 있다.

React의 concurrent mode와 terminal의 render tick 사이의 마찰이 실제로 측정된 코드가 있다. 메인 render loop(ink.tsx)는 ConcurrentRoot를 사용한다. 그러나 검색 기능의 off-screen rendering(render-to-screen.ts)에서는 LegacyRoot로 강등시켜야 했다.

// ink.tsx — 메인 render loop
this.container = reconciler.createContainer(
  this.rootNode,
  ConcurrentRoot,  // 메인 pipeline은 ConcurrentRoot
  ...
)

// render-to-screen.ts — 검색용 off-screen rendering만 LegacyRoot
// LegacyRoot: all work sync, no scheduling —
// ConcurrentRoot's scheduler backlog leaks across roots via flushSyncWork.
container = reconciler.createContainer(
  root,
  LegacyRoot,  // on-demand single-message rendering에서만 문제가 됨
  ...
)

왜 이 구분이 필요했는지 설명이 필요하다. React concurrent mode의 scheduler는 browser의 requestAnimationFrame / MessageChannel을 기반으로 설계되었다. Browser에서는 16ms(60fps)마다 렌더링 기회가 오고, scheduler가 작업을 그 프레임에 맞춰 분배한다. 그러나 terminal의 render tick은 stdin event-driven이다. 사용자가 키를 누르면 그때 렌더링이 발생한다. 일정한 주기가 없다.

검색 기능이 개별 메시지를 off-screen에서 렌더링할 때, concurrent mode의 flushSyncWork가 메인 Ink root의 작업까지 끌어와서 처리하는 현상이 측정되었다(주석에 따르면 호출당 ~0.0003ms 성장). 메인 pipeline은 ConcurrentRoot를 유지하되, 이 특정 경로만 LegacyRoot로 분리해야 했다는 것은, React scheduler가 terminal 환경에서 cross-root 간섭을 일으킨다는 구조적 마찰의 증거다. 전면 강등이 아닌 부분적 분리로 해결한 것은 오히려 팀이 문제의 범위를 정확히 진단했음을 보여준다.

Yoga 레이아웃 엔진과 terminal buffer 사이의 마찰은 더 직접적이다.

// renderer.ts
// bug: MessageSelector was outside <FullscreenLayout>
// yogaHeight exceeds rows and every assumption below breaks
// Clamping here enforces the invariant:
// overflow writes land at y >= screen.height and setCellAt drops them.
// The sibling is invisible (obvious, easy to find) instead of
// corrupting the whole terminal.
const height = options.altScreen ? terminalRows : yogaHeight

이 주석을 한 줄씩 읽어보자. Yoga가 계산한 높이(yogaHeight)가 terminal의 실제 행 수(terminalRows)를 초과하는 현상이 발생했다. DOM flow 모델에서는 콘텐츠가 화면을 넘어가면 스크롤이 발생하는 것이 정상이다. 그러나 terminal cell buffer에는 "스크롤"이라는 개념이 없다. Terminal cell buffer는 rows × cols 크기의 고정 2차원 배열이다. y >= screen.height인 좌표에 쓰려고 하면? 유효하지 않은 메모리 접근이거나, 화면 밖의 보이지 않는 영역에 쓰는 것이다.

주석이 말하는 해결책: "overflow writes가 y >= screen.height에 도달하면 setCellAt가 그것을 drop한다." 즉 경계를 넘는 쓰기를 조용히 버린다. "The sibling is invisible (obvious, easy to find) instead of corrupting the whole terminal." — 화면이 깨지는 것보다는 일부가 안 보이는 것이 낫다는 실용적 판단이다.

그러나 이것은 invariant 재설계가 아닌 방어적 clamp다. 근본 원인 — Yoga가 terminal 크기를 넘는 높이를 계산하는 것 — 은 수정되지 않았다. "invariant 붕괴 → 방어적 clamp → 주석으로 기록"의 패턴이 코드 전체에서 반복된다.

사용자가 경험하는 형태로 번역하면: 터미널 창 크기를 바꾸거나 화면 분할을 바꿀 때 렌더링된 박스의 테두리가 어긋나고, 색상이나 bold 등의 스타일이 인접한 영역으로 번지거나 갑자기 초기화된다. Yoga가 계산한 layout과 실제 terminal 크기 사이의 불일치가 diff 계산에서 오차를 만들고, 그 오차가 화면에 시각적 찢김(tearing)으로 나타난다. 이 현상은 terminal 창이 좁을수록, 또는 UI 컴포넌트가 많아질수록 빈번해진다.


7. 추상화 레이어의 존재와 무시

이 섹션은 짧지만 중요하다. 앞서 다룬 모든 문제에 관통하는 하나의 패턴을 보여주기 때문이다.

termio/ 디렉터리에 csi.ts, dec.ts, osc.ts가 있다. ANSI escape sequence를 named constant로 관리하겠다는 의도였을 것이다. CSI(Control Sequence Introducer), DEC(DEC Private Mode), OSC(Operating System Command) — terminal 프로토콜의 세 가지 주요 카테고리를 각각 파일로 분리해 놓았다. 이것 자체는 좋은 설계다.

그러나 ink.tsx 자체에서:

// ink.tsx - termio 추상화가 있음에도 raw literal 직접 사용
'\x1b[?1004l' +  // DFE (disable focus events) — dec.ts에 상수가 있음
'\x1b[0m' +      // SGR reset
'\x1b[?25h' +    // SHOW_CURSOR — dec.ts에 상수가 있음
'\x1b[2J' +      // clear screen

dec.tsSHOW_CURSOR라는 상수가 정의되어 있다. 그런데 ink.tsx에서 그 상수를 import하는 대신 '\x1b[?25h'를 직접 쓰고 있다. 옆에 주석까지 달아놓았다 — "이것은 SHOW_CURSOR입니다" — 상수의 존재를 알고 있으면서 쓰지 않은 것이다.

screen.ts에서도:

// screen.ts - StylePool의 기본 style들이 raw literal로 정의됨
code: '\x1b[7m',    // inverse
endCode: '\x1b[27m',
code: '\x1b[1m',    // bold
endCode: '\x1b[22m',

파서 파일과 termio 디렉터리 자체를 제외하고, termio/ 레이어 외부에 산재한 hardcoded ANSI sequence의 수를 세면 49개에 달한다.

이것이 왜 문제인가? 만약 "SHOW_CURSOR" sequence를 바꿔야 하는 상황이 온다면 — 예를 들어 특정 terminal에서 다른 sequence를 사용해야 한다면 — dec.ts의 상수 하나를 바꾸는 대신 49개의 위치를 전부 찾아서 수정해야 한다. 이것이 추상화 레이어가 convention에만 의존할 때 벌어지는 일이다. 추상화를 쓸지 말지가 개발자의 "의지"에 달려 있으면, 납기에 쫓기거나 "이건 빨리 고쳐야 하니까"라는 판단 아래서 우회된다. 기여자가 늘어나면 로컬 최적화의 누적이 추상화를 침식한다.


8. 크로스플랫폼 UX 붕괴: Windows에서의 실증

앞서 다룬 이벤트 추상화 문제(Section 4)와 TTY 복원 문제(Section 5)가 가장 극적으로 드러나는 곳이 Windows다. Linux와 macOS에서는 POSIX termios라는 공통 인터페이스가 있어 어느 정도 동작이 수렴하지만, Windows의 terminal 입력 경로는 구조적으로 다르다.

이 차이를 이해하려면 세 플랫폼이 terminal 입력을 처리하는 경로를 비교해야 한다.

Linux (POSIX):
  /dev/tty → termios(cfmakeraw) → byte stream → ANSI parser

macOS (POSIX + 일부 확장):
  /dev/tty → termios → byte stream → ANSI parser
  Terminal.app / iTerm2의 추가 sequence 지원

Windows:
  ConPTY(Console Pseudo Terminal) → Win32 Console API ↔ ANSI 변환
  또는 WSL 내부에서 POSIX 경로 경유

Linux와 macOS는 같은 POSIX 경로를 탄다. termios API로 raw mode를 설정하면 stdin에서 raw byte stream이 나오고, ANSI parser가 이것을 해석한다. 이 경로는 1970년대부터 40년 이상 안정적으로 사용되어 온 것이다.

Windows는 사정이 다르다. ConPTY(Console Pseudo Terminal)는 Windows 10 1809(2018)에서 도입된 레이어로, ANSI sequence를 Win32 Console API로 변환하고 다시 역방향으로 변환한다. 이것은 "POSIX terminal을 흉내내는 호환 레이어"다. 변환 과정에서 일부 sequence가 손실되거나 다르게 처리될 수 있다. 이중 변환이 원본과 다른 결과를 만드는 것은 자연스러운 일이다.

Claude Code의 코드에는 이 플랫폼 차이를 인지하고 있었다는 증거가 있다.

// terminal.ts
// Windows Terminal은 OSC 9;4를 progress indicator가 아닌 notification으로 해석
if (process.env.WT_SESSION) {
  return false  // progress reporting 비활성화
}

Windows Terminal을 특수 케이스로 처리하는 코드가 있다. WT_SESSION 환경변수의 존재로 "이 프로세스가 Windows Terminal 안에서 돌고 있는지"를 감지한다. 그러나 주목해야 할 것은 이 대응이 output(화면에 쓰기) 레이어에 집중되어 있다는 점이다. Input(키 입력 읽기) 레이어에서의 대응은 "올바르게 처리하기"가 아니라 "활성화하지 않기"였다.

ConPTY 환경에서 실제로 어떤 입력이 어떻게 깨지는지 구체적으로 보자.

Kitty keyboard protocol의 3-param CSI u 문제. Kitty keyboard protocol을 활성화(CSI >1u)하면, 터미널이 키 이벤트를 ESC [ codepoint ; modifier ; event_type u 형태로 보낸다. 예를 들어 Ctrl+Shift+C를 누르면 ESC[67;6;1u(C=67, modifier=6, event_type=1=press)가 stdin에 도착한다. 그런데 Claude Code의 파서(CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/)는 1개 또는 2개 파라미터만 처리한다. 세 번째 파라미터 ;1u가 남으면 파서가 매치에 실패하여 null을 반환한다. 키 입력이 silently drop된다. 이것이 EXTENDED_KEYS_TERMINALS allowlist가 존재하는 직접적 이유다 — 특정 터미널에서만 Kitty protocol을 활성화하고, xterm.js 계열(VS Code 통합 터미널 포함)에서는 아예 활성화하지 않는다.[^kitty-issue]

[^kitty-issue]: terminal.ts 주석: "We previously enabled unconditionally (#23350), assuming terminals silently ignore unknown CSI — but some terminals honor the enable and emit codepoints our input parser doesn't handle (notably over SSH and in xterm.js-based terminals like VS Code)."

conhost의 cursor-up viewport yank bug. Main-screen 모드에서 diff engine이 CSI A(cursor up) sequence를 emit하면, conhost가 SetConsoleCursorPosition Win32 API로 변환하는 과정에서 viewport 위 scrollback 영역까지 cursor를 따라가버린다. 화면이 갑자기 scrollback 최상단으로 점프한다(microsoft/terminal#14774). 이 bug의 회피 방법은 기능 제거다:

// REPL.tsx
const showStreamingText = !reducedMotion && !hasCursorUpViewportYankBug()

Windows/ConPTY에서 showStreamingText = false는 사용자가 model 응답이 오는 동안 텍스트가 점진적으로 나타나는 경험을 아예 할 수 없다는 의미다. CSI A가 포함된 partial update sequence를 보내면 화면이 튀므로, "중간 상태를 보여주지 않는 것"이 유일한 회피책이었다.

output 레이어에서 WT_SESSION을 체크해 progress indicator를 비활성화하고, main rendering에서 hasCursorUpViewportYankBug()로 streaming preview를 비활성화했다. Input 레이어에서는 Kitty protocol의 3-param response를 파서가 처리하지 못하여 allowlist로 활성화 범위를 제한했다. 대응의 패턴이 일관된다: 문제를 올바르게 해결하는 것이 아니라, 문제가 발생하는 기능을 끄는 것이다.

올바른 TTY 추상화의 계층 구조

플랫폼 독립적인 TUI를 만들기 위해 TTY wrapping 라이브러리가 해결해야 하는 문제는 세 개의 명확한 계층으로 나뉜다.

Layer 1 — 플랫폼별 I/O
  POSIX:   read()/write() on /dev/tty, termios로 raw mode 설정
  Windows: ReadConsoleInput() 또는 ConPTY를 통한 ReadFile()

Layer 2 — 공통 ANSI 파서 (VT100 baseline)
  byte stream → Event { Key(KeyCode, Mods) | Mouse(...) | Resize(...) }
  플랫폼 차이가 여기서 완전히 흡수됨
  동일한 물리 키가 플랫폼마다 다른 sequence를 보낼 수 있음을 처리

Layer 3 — 확장 프로토콜 (capability 감지 후 선택적 활성화)
  Kitty keyboard protocol: 수식키 조합을 더 정확하게 전달
  SGR 마우스: 80열 이상에서의 마우스 좌표
  sixel / iTerm2 inline image

Application은 Layer 2의 Event 타입만 본다. 절대 raw bytes를 보지 않는다.

이 구조가 추상적인 이상에 불과한 것이 아니다. Rust의 crossterm 라이브러리가 이것을 구현하고 있다. crossterm::event::read()를 호출하면 Event::Key(KeyEvent { code: KeyCode::Home, ... })를 돌려준다. POSIX든 Windows ConPTY든 WSL이든 동일하다. Layer 1의 플랫폼 분기가 컴파일 타임에 결정되고, Layer 2의 파서가 그 위에서 동작하기 때문이다. Application 코드에서 "이것이 Windows인가?"를 판단할 필요가 없다.

Claude Code의 구조는 Layer 1 전체를 Node.js에 위임했다. process.stdin.setRawMode(true) 한 줄이 Layer 1의 전부다. 그러나 이 Node.js API가 POSIX cfmakeraw의 완전한 equivalent가 아니며, ConPTY 환경에서의 동작이 다를 수 있다는 것을 처리할 방법이 Node.js runtime 안에 없다. parse-keypress.ts가 Layer 2를 시도하지만, 그 입력이 이미 플랫폼 차이를 흡수한 normalized stream인지 보장되지 않는 상태에서 동작한다.

이것이 Windows에서 키 입력이 씹히거나 의도와 다르게 해석되는 현상의 구조적 원인이다. Input layer가 플랫폼 차이를 흡수하지 못한 채 동작하므로, ConPTY의 ANSI 변환 과정에서 손실되거나 다르게 처리된 sequence가 파서에 그대로 도달한다.


Part III: 왜 일관성이 붕괴되었는가

9. 세 가지 구조적 원인

여기까지 읽은 독자라면 자연스러운 의문이 생긴다. 추상화 레이어를 직접 만들어놓고 우회하는 것, raw mode를 켜고 복원하지 않는 것, 파서를 만들어놓고 raw string을 비교하는 것 — 이 문제들은 명백하다. 숙련된 엔지니어가 코드를 한 번 훑었다면 즉시 발견했을 것이다. 왜 고쳐지지 않았는가?

이 질문에 답하기 위해 앞서 나열한 문제들에서 공통적으로 파생되는 세 가지 원인을 짚는다.

9-1. TypeScript의 타입 시스템이 계약을 강제하지 않는다

Section 4에서 이미 언급했지만 여기서 더 명확히 짚어야 한다. string 타입은 escape sequence와 일반 텍스트를 구분하지 않는다. '\x1b[?1004l'"hello"가 같은 타입이다.

올바른 추상화에서는 각 레이어의 출력 타입이 달라야 한다.

raw bytes (u8[])
  ↓ parse
Event { Key(KeyCode, Mods) | Mouse(...) | Resize(...) }
  ↓ dispatch
application logic

각 단계의 출력이 서로 다른 타입이면, consumer가 상위 레이어로 올라가서 raw bytes를 직접 볼 방법이 없다. Event 타입에서 원래의 바이트열을 복원하는 것은 타입 시스템이 허용하지 않는다. TypeScript에서도 branded type이나 opaque type 패턴으로 이것을 구현할 수 있지만, 그렇게 하지 않았다. 추상화 레이어를 건너뛰는 것이 타입 오류가 아닌 이상, 시간 압박 하에서 건너뛰는 것은 시간 문제다.

9-2. Single ownership 없는 생명주기 관리

Section 5에서 다룬 TTY 복원 문제의 일반화다. 복원 책임이 gracefulShutdown, Ink instance, earlyInput, bridgeMain에 분산되어 있다. 어느 경로가 실행되고 어느 경로가 빠지는지가 런타임 조건에 따라 달라진다.

이것은 C에서 malloc한 메모리를 세 곳에서 free할 수 있는 구조와 같다. "누군가 해줄 것이다"는 가정이 세 곳 모두에 존재하면, 정상 경로에서는 한 곳이 실행되어 문제가 없지만, 비정상 경로에서는 세 곳 모두 빠지거나 두 곳이 중복 실행될 수 있다.

9-3. 배포 모델의 제약 하에서 그들이 한 것 — 그리고 하지 않은 것

npm install -g 방식의 크로스플랫폼 배포를 원했다면, native addon은 사실상 배제된다. Node.js의 process.stdin.setRawMode()가 POSIX cfmakeraw의 완전한 equivalent가 아니며, Windows ConPTY의 동작 차이를 Node.js가 전부 흡수해주지 않는다는 것을 알면서도 그 제약 안에서 작업해야 했을 것이다.

그 제약 안에서 이 팀이 아무것도 하지 않았다고 말하면 그것은 거짓이다. 코드를 더 깊이 읽으면 상당한 엔지니어링 노력이 드러난다.

TerminalQuerier — ANSI request/response를 실제로 구현했다. src/ink/terminal-querier.ts는 이 기사가 "했어야 한다"고 주장하는 방식 그대로 구현되어 있다. DA1(Primary Device Attributes)을 sentinel로 사용하여 timeout 없이 capability를 감지한다. decrqm(2026), decrqm(2027), cursorPosition(), oscColor(), kittyKeyboard(), xtversion() — 전부 구현되어 있다.

// terminal-querier.ts 도입부 주석의 예제
// const [sync, grapheme] = await Promise.all([
//     querier.send(decrqm(2026)),  // synchronized output 감지
//     querier.send(decrqm(2027)),  // grapheme cluster 지원 감지
//     querier.flush(),             // DA1 sentinel — timeout 없이 처리
// ])

DA1 sentinel 패턴은 정교하다. 모든 VT100 이후 terminal이 DA1에 응답한다는 사실을 이용하여, "DA1보다 먼저 응답이 오면 지원함, 아니면 미지원"으로 처리한다. Timeout이 필요 없으므로 느린 SSH 환경에서도 안정적이다.

XTVERSION — SSH 환경까지 고려했다. TERM_PROGRAM 환경변수가 SSH를 통해 포워딩되지 않는 문제를 인지하고, XTVERSION query로 우회하는 구현이 있다. App.tsx에서 setImmediate로 지연 실행하여 init sequence와의 interleave를 방지했다. Whitelist 방식의 한계를 알고 우회하려 한 시도다.

sleep/wake 감지도 있다. STDIN_RESUME_GAP_MS 임계값을 이용한 stdin gap 감지로 tmux detach/attach, SSH reconnect, laptop wake를 처리한다. SIGCONT 신호 없이도 reassertTerminalModes()로 terminal mode를 복원하는 경로다.

DECSTBM 하드웨어 scroll 최적화도 있다. ScrollBox의 스크롤을 terminal의 hardware scroll 명령(DECSTBM)으로 처리하여 전체 re-render 없이 shift하는 구현이 있다.

OffscreenFreeze도 있다. Viewport 위로 올라간 콘텐츠가 timer(spinner, elapsed counter)로 업데이트될 때마다 full reset이 발생하는 것을 알고 있었고, React element reference equality로 subtree re-render를 억제하는 workaround를 구현했다.

이것은 terminal을 깊이 이해한 팀의 작업이다. "native addon 없이 어떻게 terminal capability를 감지할 것인가"라는 질문에 대해, DA1 sentinel이라는 정확히 올바른 답을 구현했다.

그렇다면 왜 isSynchronizedOutputSupported()는 여전히 환경변수 whitelist인가?

TerminalQuerier의 도입부 주석은 decrqm(2026)을 예제로 보여준다. 그러나 codebase 전체를 검색하면, querier.send(decrqm(2026))을 실제로 호출하는 코드는 어디에도 없다. 주석에만 존재한다. 한편 SYNC_OUTPUT_SUPPORTED는 이렇게 정의되어 있다:

// terminal.ts
export const SYNC_OUTPUT_SUPPORTED = isSynchronizedOutputSupported()
// ↑ 모듈 로드 시점에 단 한 번 평가되는 const

DECRQM 응답은 비동기로, 첫 번째 render 이후에 도착한다. SYNC_OUTPUT_SUPPORTED는 모듈 로드 시점에 이미 const로 확정된다. 비동기 결과를 반영하려면 이 const를 mutable state로 바꿔야 한다.

그런데 그 패턴이 바로 옆에 이미 존재한다:

// terminal.ts — XTVERSION은 이미 이 방식으로 동작
let xtversionName: string | undefined
export function setXtversionName(name: string): void { xtversionName = name }

xtversionName은 비동기 ANSI query 결과를 mutable state에 저장하고, 이후 코드가 lazy하게 읽는다. SYNC_OUTPUT_SUPPORTED에 같은 패턴을 적용하는 것은 10줄짜리 변경이다. xtversionName을 구현한 같은 파일에서, 같은 패턴을 복제하면 된다. 구조적 제약이 없었다.

이것이 이 기사의 비판이 도달하는 진짜 지점이다. "인프라가 없었다"가 아니다. 인프라를 만들어 놓고 연결하지 않았다. TerminalQuerier라는 정교한 도구가 코드에 존재하고, decrqm(2026)이라는 정확한 query가 주석 예제에 있고, 결과를 저장하는 mutable state 패턴이 같은 파일에 있다. 그리고 isSynchronizedOutputSupported()는 여전히 환경변수 whitelist다.

이것은 인지의 실패가 아니다. 완성의 실패다. 새로운 인프라(TerminalQuerier)가 기존 코드(SYNC_OUTPUT_SUPPORTED const 패턴)를 교체하지 못하고 그 옆에 공존한 채로 남겨진 것이다. 도구를 만들되, 기존 코드를 그 도구로 교체하는 마지막 연결 작업이 완료되지 않았다. 이 패턴이 이 코드베이스 전체에서 반복된다.


10. 프로젝트의 무게 중심

왜 이런 대안들이 탐구되지 않았는가? 그 답을 이해하려면 코드베이스에서 TUI 레이어가 전체 프로젝트에서 차지하는 실제 비중을 봐야 한다. src/ 디렉터리에서 단순히 파일 크기로 무게 중심을 재면 다음과 같다.

파일 크기
bridgeMain.ts 115,571 bytes
replBridge.ts 100,537 bytes
remoteBridgeCore.ts 39,434 bytes
sessionRunner.ts 18,020 bytes
bridgeMessaging.ts 15,703 bytes
(비교) ink.tsx ~20,000 bytes
(비교) renderer.ts ~8,000 bytes

bridge/ 디렉터리만 해도 340,000 bytes가 넘는다. TUI 렌더링 전체(ink/ 하위)의 세 배 이상이다.

파일 이름들을 보면 이 프로젝트가 무엇을 핵심으로 보았는지가 드러난다. capacityWake.ts, flushGate.ts, sessionIdCompat.ts, sessionRunner.ts, createSession.ts, remoteBridgeCore.ts, replBridge.ts. 이것은 CLI 도구의 네이밍이 아니다. 세션을 원격에서 생성하고, 용량(capacity)을 관리하며, 여러 bridge 인스턴스를 조율하는 — 일종의 agent 실행 인프라에 어울리는 이름들이다.

npm install -g로 설치하는 단순한 터미널 도구가 왜 이런 원격 세션 관리 코드를 가지고 있는가?

여기서 하나의 가설이 설득력을 갖는다. Claude Code는 단순한 CLI 도구가 아니었다. 설계의 야망은 에이전트를 위한 운영체제 — 다수의 에이전트 세션을 관리하고 원격으로 조율하며 어느 플랫폼에서든 npm install -g 한 줄로 구동되는 무언가였을 것이다. React + Ink를 선택한 것도 이 관점에서 재해석된다. 웹 개발자들이 친숙한 React로 UI를 작성하면, 기여자 풀이 넓어진다. npm으로 배포하면, 설치 과정이 단순해진다. 이 두 목표가 기술적 선택을 강하게 제약했다.

그 야망의 비중이 곧 TUI 레이어 품질 문제의 원인이다. 프로젝트의 기술적 투자와 엔지니어링 에너지는 bridge, session management, remote agent coordination에 집중되어 있었다. Terminal 렌더링 레이어는 "일단 돌아가면 되는" 부분으로 취급되었고, 추상화 레이어는 만들어졌지만 그것을 일관성 있게 지킬 ownership과 리뷰 압력이 부족했다.

주석으로 버그를 기록해두고 넘어가는 코드들. Invariant 붕괴를 수정하지 않고 방어적 clamp를 추가하는 패턴들. prevFrameContaminated라는 플래그가 문제를 인지하면서도 근본 수정이 아닌 우회로를 선택한 것들. 이 모든 것이 우선순위의 흔적이다.


Part IV: 요약

11. 발견의 정리

비판 포인트 코드에서의 증거 UX 영향 해결 난이도
Double buffer invariant 붕괴 prevFrameContaminated 플래그 화면 깜빡임, 창 리사이즈 시 스타일 깨짐, 긴 대화에서 무한 스크롤 높음 (Ink 아키텍처 전반 영향)
이벤트 추상화 경계 누설 useTextInput.ts의 raw escape 비교 Ctrl+C/Ctrl+Z 등 키 조합이 맥락에 따라 다르게 동작, 키 씹힘 중간
TTY 복원 책임 분산 earlyInput.ts의 의도적 복원 생략 비정상 종료 시 VSCode 등에서 단축키 파괴 중간
React/Yoga mismatch off-screen rendering의 LegacyRoot 분리, yogaHeight clamp 렌더링 찢김, 박스 규격 어긋남, cross-root scheduler 간섭 높음 (React 모델 자체의 한계)
추상화 레이어 우회 ink.tsx 내 49개 raw literal 유지보수 불가, 터미널별 대응 어려움 낮음 (의지의 문제)
크로스플랫폼 ConPTY 대응 Kitty 3-param CSI u를 파서가 미처리, hasCursorUpViewportYankBug() 키 입력 silent drop, Windows에서 streaming preview 비활성화 중간 (파서 수정 + allowlist 해제)

12. 핵심 주장

Claude Code TUI 구현의 핵심 문제는 기술 선택 자체가 아니라, 좋은 추상화를 설계하고도 그것을 일관성 있게 강제하지 못했다는 점이다.

termio/ 디렉터리의 상수 정의, parse-keypress.ts의 파서 구현, renderer.ts의 double buffer 구조, TerminalQuerier의 DA1 sentinel 패턴 — 이것들은 올바른 추상화의 출발점이었을 뿐 아니라, 일부는 정교한 완성품이었다. 문제는 그 추상화들이 기존 코드를 교체하지 못하고 그 옆에 공존했다는 것이다. decrqm(2026)은 주석 예제에 있지만 호출되지 않는다. xtversionName의 mutable state 패턴은 같은 파일에 있지만 SYNC_OUTPUT_SUPPORTED에 적용되지 않았다. dec.tsSHOW_CURSOR 상수가 있지만 ink.tsx'\x1b[?25h'를 직접 쓴다. 도구는 만들되, 기존 코드를 그 도구로 교체하는 마지막 연결 작업이 완료되지 않는 패턴이 코드베이스 전체에서 반복된다.

추상화는 타입 시스템과 ownership 모델에 의해 구조적으로 강제될 때 의미를 가진다. Raw escape sequence를 string이 아닌 별도의 타입으로 감싸면, consumer가 그것을 직접 비교하는 것이 컴파일 오류가 된다. 복원 책임을 단일 struct의 init/fini 쌍에 귀속시키면, "누가 복원할 것인가"라는 질문이 발생하지 않는다. 이것들은 TypeScript에서도 구현할 수 있는 패턴이다. 언어의 한계가 아니라 설계 의지의 문제였다.

그리고 이 분석의 출발점으로 돌아가면, 이 모든 것이 추상적인 이야기가 아니다. 키가 씹히고, 화면이 찢기고, 도구를 닫으면 터미널이 이상해지는 그 경험들 — 이것이 우리가 Claude Code를 사용해오며 터미널에서 겪은 불편함과 괴리의 실체이다. 이 코드베이스는 그 침식의 고고학적 단면을 보여준다.