// blog post
Claude Code 화면에 잔상이 남는 이유 — VibeShell이 Unicode width를 다루는 방법
Claude Code 화면에 잔상이 남았습니다
VibeShell 개발 초기, Mac mini의 tmux 세션에서 Claude Code를 띄워놓고 VibeShell로 접속해서 테스트하던 때였습니다.
Claude Code가 응답을 출력하고 나면, 화면에 이전 출력의 잔상이 남는 겁니다. TUI 패널 경계선이 밀리고, 텍스트가 겹쳐 보이고, 스크롤하면 레이아웃이 무너졌습니다. 처음엔 VibeShell의 렌더링 버그인 줄 알았어요.
폰트를 바꿔봤습니다. locale 설정을 확인했습니다. TERM 환경변수도 건드려봤고요. 한참 삽질한 끝에 알게 된 진짜 이유는 훨씬 근본적인 거였습니다.
바로 Unicode width 불일치 문제입니다.
터미널은 셀 기반입니다
터미널 에뮬레이터는 문자를 그리드 셀(grid cell) 단위로 배치합니다. 문자 하나가 차지하는 셀 수가 곧 그 문자의 width입니다.
| Width | 의미 | 예시 |
|---|---|---|
| 0 | Combining character | 결합 문자 |
| 1 | Narrow (일반) | A, 가, 1 |
| 2 | Wide (전각) | 한, 日, A |
bash, zsh, tmux, 그리고 Claude Code 같은 TUI 프로그램들은 다음 함수로 커서 위치를 계산합니다.
wcwidth() // 문자 하나의 width
wcswidth() // 문자열 전체의 width
이 함수는 서버의 glibc가 제공합니다. 즉, 서버 기준으로 커서 위치를 계산한다는 뜻이에요.
문제의 정체
Claude Code의 출력에는 emoji와 특수문자가 자주 등장합니다. ✅, ❌, 📦 같은 상태 표시, 박스 드로잉 문자, 각종 심볼들.
예를 들어 ⏸ (U+23F8)를 볼게요.
이 문자의 Unicode East Asian Width 속성은 Neutral입니다. glibc의 wcwidth()가 반환하는 값은 1 — 즉 한 칸짜리 문자로 취급됩니다.
근데 실제 터미널 화면에서는 이 문자가 emoji처럼 두 칸으로 그려지는 경우가 많습니다.
서버 계산: ⏸ = width 1 → 커서 위치 +1
터미널 렌더: ⏸ = width 2 → 실제 표시 +2
이 1칸 차이가 쌓이면:
- TUI 화면에 이전 출력의 잔상이 남음
- 커서가 엉뚱한 위치로 이동
- 줄 정렬이 무너지고 텍스트가 겹침
- tmux 패널 레이아웃 붕괴
- progress bar가 줄넘김
Claude Code처럼 복잡한 TUI에서는 이 차이가 치명적입니다. 한 줄에서 1칸 밀리면 그 아래 전체 레이아웃이 무너지고, 화면을 다시 그려도 잔상이 남습니다.
왜 서버에서 고칠 수 없나
처음엔 서버 설정을 바꾸면 되지 않을까 생각했습니다. 근데 현실은 다릅니다.
| 방법 | 결과 |
|---|---|
| glibc 업데이트 | 불가 — Unicode EAW가 Neutral이라 변경 없음 |
| locale 변경 | 무관 — width 계산과 별개 |
| TERM 환경변수 변경 | 무관 — terminfo는 문자 width를 정의하지 않음 |
| LD_PRELOAD로 wcwidth 오버라이드 | 비현실적 — 원격 서버마다 적용 불가 |
결론: 서버의 wcwidth() 결과는 변경 불가능한 상수입니다.
iTerm2, Kitty, WezTerm, Alacritty 같은 메이저 터미널들도 이 문제를 완전히 해결하지 못했습니다. 대부분은 "emoji를 wide로 렌더링하고, 약간의 깨짐은 감수한다"는 전략을 씁니다.
VibeShell은 다른 길을 택했습니다.
Unicode의 해답: Variation Selector
사실 Unicode 자체에 이미 해결책이 있습니다.
Variation Selector-16 (VS16, U+FE0F)
이 코드포인트를 문자 뒤에 붙이면 emoji presentation을 명시적으로 요청합니다.
⏸ → text presentation (width = 1)
⏸️ → emoji presentation (width = 2)
(⏸ + U+FE0F)
이걸 이용하면 width를 사용자가 명시적으로 선택할 수 있습니다.
VibeShell의 Width 정책
VibeShell의 렌더링 정책은 단순합니다.
Base character → width = 1 (서버 wcwidth 결과와 동일)
Character + VS16 → width = 2 (emoji presentation)
렌더링 알고리즘:
if (character + U+FE0F):
treat as emoji → width = 2
else:
use wcwidth(base_char) → width = 1
이렇게 하면 서버의 커서 계산과 터미널 렌더링이 항상 일치합니다.
실제 width 정책 예시:
| Sequence | Width | 설명 |
|---|---|---|
⏸ | 1 | 서버 wcwidth 기준 |
⏹ | 1 | 서버 wcwidth 기준 |
⏺ | 1 | 서버 wcwidth 기준 |
⏸️ | 2 | VS16 — emoji presentation |
⏹️ | 2 | VS16 — emoji presentation |
⏺️ | 2 | VS16 — emoji presentation |
내부 구조
VibeShell 터미널 코어는 이렇게 구성됩니다.
terminal-core
├─ unicode
│ ├─ wcwidth-table ← 서버 wcwidth() 결과 매핑
│ ├─ emoji-sequence ← emoji 시퀀스 감지
│ └─ variation-selector ← VS16 처리
└─ renderer
입력 스트림이 들어오면:
input stream
↓
grapheme parser ← grapheme cluster 단위로 분리
↓
variation selector 감지 ← U+FE0F 포함 여부 확인
↓
width 계산 ← VS16 있으면 2, 없으면 wcwidth()
↓
grid placement ← 계산된 width로 셀에 배치
정리
이 문제를 파고들면서 터미널 개발이 왜 어려운지 실감했습니다. 문자 하나의 width가 1이냐 2이냐 하는 문제가, Claude Code 같은 TUI 전체 화면이 깨지느냐 아니냐를 결정합니다.
VibeShell의 Unicode width 정책을 정리하면:
- Base character →
width = 1(서버 호환) - Character + VS16 →
width = 2(emoji presentation) - 서버 호환성 → 최우선
SSH 환경에서 bash, zsh, tmux, 그리고 Claude Code 같은 TUI 앱이 모두 안정적으로 동작하는 게 목표입니다. emoji를 wide로 쓰고 싶으면 VS16을 붙이면 되고, 그렇지 않으면 서버와 동일한 width로 안전하게 동작합니다.
Claude Code 잔상 버그를 잡으려고 며칠을 매달렸는데, 결국 그 경험이 VibeShell의 렌더링 정책을 만들었습니다. 직접 불편을 겪어야 제대로 된 해결책이 나오는 것 같아요.
더 자세한 내용은 VibeShell 공식 문서에서 확인할 수 있습니다.