숨김 모드 원리: 왜 너비 계산이 중요한가
학습 후 할 수 있는 것
- OpenCode 숨김 모드의 작동 원리 이해
- 일반 포맷팅 도구가 숨김 모드에서 정렬이 어긋나는 이유 파악
- 플러그인의 너비 계산 알고리즘 마스터(3단계)
Bun.stringWidth의 역할 이해
현재 겪고 있는 문제
OpenCode로 코드를 작성 중, AI가 멋진 표를 생성했습니다:
| 필드 | 타입 | 설명 |
|--- | --- | ---|
| **name** | string | 사용자명 |
| age | number | 나이 |소스 뷰에서는 깔끔하게 정렬되어 보입니다. 하지만 미리보기 모드로 전환하면 표가 어긋납니다:
| 필드 | 타입 | 설명 |
|--- | --- | ---|
| name | string | 사용자명 | ← 왜 짧아졌지?
| age | number | 나이 |문제가 어디에 있나요? 숨김 모드입니다.
숨김 모드란 무엇인가
OpenCode는 기본적으로 **숨김 모드(Concealment Mode)**가 활성화되어 있으며, 렌더링 시 Markdown 구문 기호를 숨깁니다:
| 소스 | 숨김 모드에서 표시 |
|---|---|
**굵게** | 굵게(4개 문자) |
*기울임* | 기울임(4개 문자) |
~~취소선~~ | 취소선(6개 문자) |
`코드` | 코드(4개 문자 + 배경색) |
숨김 모드의 장점
내용 자체에 집중할 수 있게 해주며, **, * 같은 기호로 인해 시선이 분산되는 것을 방지합니다.
왜 일반 포맷팅 도구는 문제가 발생하는가
일반 표 포맷팅 도구는 너비를 계산할 때 **name**을 8개 문자로 계산합니다:
** n a m e ** = 8 문자하지만 숨김 모드에서 사용자가 보는 것은 name으로, 4개 문자뿐입니다.
결과: 포맷팅 도구는 8 문자로 정렬하지만, 사용자는 4 문자를 보게 되어 표가 자연스럽게 어긋납니다.
핵심 아이디어: "문자 길이"가 아닌 "표시 너비" 계산
이 플러그인의 핵심 아이디어는: 사용자가 실제로 보는 너비를 계산하는 것이지, 소스 코드의 문자 수가 아닙니다.
알고리즘은 3단계로 나뉩니다:
1단계: 코드 블록 보호(코드 블록 내 기호는 제거하지 않음)
2단계: Markdown 기호 제거(**, *, ~~ 등)
3단계: Bun.stringWidth로 최종 너비 계산함께 해보기: 3단계 알고리즘 이해
1단계: 코드 블록 보호
왜 필요한가
인라인 코드(백틱으로 감싸진) 내의 Markdown 기호는 "리터럴"이므로, 사용자는 bold라는 4개 문자가 아닌 **bold**라는 8개 문자를 봅니다.
따라서 Markdown 기호를 제거하기 전에 먼저 코드 블록 내용을 "숨겨야" 합니다.
소스 구현
// 1단계: 인라인 코드 추출 및 보호
const codeBlocks: string[] = []
let textWithPlaceholders = text.replace(/`(.+?)`/g, (match, content) => {
codeBlocks.push(content)
return `\x00CODE${codeBlocks.length - 1}\x00`
})작동 원리
| 입력 | 처리 후 | codeBlocks 배열 |
|---|---|---|
`**bold**` | \x00CODE0\x00 | ["**bold**"] |
`a` and `b` | \x00CODE0\x00 and \x00CODE1\x00 | ["a", "b"] |
\x00CODE0\x00 같은 특수 플레이스홀더로 코드 블록을 대체하면, 나중에 Markdown 기호를 제거할 때 실수로 제거되지 않습니다.
2단계: Markdown 기호 제거
왜 필요한가
숨김 모드에서 **굵게**는 굵게로 표시되고, *기울임*은 기울임으로 표시됩니다. 너비를 계산할 때 이 기호들을 제거해야 합니다.
소스 구현
// 2단계: 비코드 부분의 Markdown 기호 제거
let visualText = textWithPlaceholders
let previousText = ""
while (visualText !== previousText) {
previousText = visualText
visualText = visualText
.replace(/\*\*\*(.+?)\*\*\*/g, "$1") // ***굵은기울임*** → 텍스트
.replace(/\*\*(.+?)\*\*/g, "$1") // **굵게** → 굵게
.replace(/\*(.+?)\*/g, "$1") // *기울임* → 기울임
.replace(/~~(.+?)~~/g, "$1") // ~~취소선~~ → 취소선
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "$1") //  → alt
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)") // [text](url) → text (url)
}왜 while 루프를 사용하는가?
중첩 구문을 처리하기 위해서입니다. 예를 들어 ***굵은기울임***:
1라운드: ***굵은기울임*** → **굵은기울임** (가장 바깥쪽 *** 제거)
2라운드: **굵은기울임** → *굵은기울임* (** 제거)
3라운드: *굵은기울임* → 굵은기울임 (* 제거)
4라운드: 굵은기울임 = 굵은기울임 (변화 없음, 루프 종료)이미지와 링크 처리
- 이미지
: OpenCode는 alt 텍스트만 표시하므로alt로 대체 - 링크
[text](url):text (url)로 표시되며, URL 정보 유지
3단계: 코드 블록 복원 + 너비 계산
왜 필요한가
코드 블록 내용을 다시 넣은 후, Bun.stringWidth로 최종 표시 너비를 계산해야 합니다.
소스 구현
// 3단계: 코드 블록 내용 복원
visualText = visualText.replace(/\x00CODE(\d+)\x00/g, (match, index) => {
return codeBlocks[parseInt(index)]
})
return Bun.stringWidth(visualText)왜 Bun.stringWidth를 사용하는가?
Bun.stringWidth는 다음을 올바르게 계산할 수 있습니다:
| 문자 타입 | 예시 | 문자 수 | 표시 너비 |
|---|---|---|---|
| ASCII | abc | 3 | 3 |
| 한글 | 안녕 | 2 | 4(각각 2칸 차지) |
| Emoji | 😀 | 1 | 2(2칸 차지) |
| 제로폭 문자 | a\u200Bb | 3 | 2(제로폭 문자는 공간 차지 안 함) |
일반적인 text.length는 문자 개수만 셀 수 있으며, 이러한 특수 경우를 처리할 수 없습니다.
완전한 예시
셀 내용이 **`code`** and *text* 라고 가정해 봅시다.
1단계: 코드 블록 보호
입력: **`code`** and *text*
출력: **\x00CODE0\x00** and *text*
codeBlocks = ["code"]2단계: Markdown 기호 제거
1라운드: **\x00CODE0\x00** and *text* → \x00CODE0\x00 and text
2라운드: 변화 없음, 종료3단계: 코드 블록 복원 + 너비 계산
복원 후: code and text
너비: Bun.stringWidth("code and text") = 13최종적으로 플러그인은 소스 코드의 22 문자가 아닌 13 문자 너비로 이 셀을 정렬합니다.
확인 포인트
이 수업을 완료한 후 다음 질문에 답할 수 있어야 합니다:
- [ ] 숨김 모드는 어떤 기호를 숨기나요?(답:
**,*,~~등 Markdown 구문 기호) - [ ] 왜 먼저 코드 블록을 보호해야 하나요?(답: 코드 블록 내 기호는 리터럴이므로 제거하면 안 됨)
- [ ] 왜 while 루프로 기호를 제거하나요?(답:
***굵은기울임***같은 중첩 구문 처리) - [ ]
Bun.stringWidth가text.length보다 좋은 점은?(답: 한글, Emoji, 제로폭 문자의 표시 너비를 올바르게 계산)
주의사항
일반적인 오해
오해: 코드 블록 내의 **도 제거됩니다
사실: 아닙니다. 플러그인은 먼저 플레이스홀더로 코드 블록 내용을 보호하고, 다른 부분의 기호를 제거한 후 복원합니다.
따라서 `**bold**`의 너비는 8(**bold**)이지 4(bold)가 아닙니다.
이 수업 요약
| 단계 | 역할 | 핵심 코드 |
|---|---|---|
| 코드 블록 보호 | 코드 블록 내 기호가 실수로 제거되는 것 방지 | text.replace(/\(.+?)`/g, ...)` |
| Markdown 제거 | 숨김 모드에서의 실제 표시 내용 계산 | 다중 정규식 치환 |
| 너비 계산 | 한글, Emoji 등 특수 문자 처리 | Bun.stringWidth() |
다음 수업 예고
다음 수업에서는 **표 규격**을 학습합니다.
배우게 될 내용:
- 어떤 표가 포맷팅될 수 있는지
- 표 검증의 4가지 규칙
- "유효하지 않은 표" 오류를 피하는 방법
부록: 소스 코드 참조
클릭하여 소스 코드 위치 확인
업데이트 시간: 2026-01-26
| 기능 | 파일 경로 | 행 번호 |
|---|---|---|
| 표시 너비 계산 진입점 | index.ts | 151-159 |
| 코드 블록 보호 | index.ts | 168-173 |
| Markdown 기호 제거 | index.ts | 175-188 |
| 코드 블록 복원 | index.ts | 190-193 |
| Bun.stringWidth 호출 | index.ts | 195 |
핵심 함수:
calculateDisplayWidth(): 캐시가 있는 너비 계산 진입점getStringWidth(): 핵심 알고리즘, Markdown 기호 제거 및 표시 너비 계산
핵심 상수:
\x00CODE{n}\x00: 코드 블록 플레이스홀더 형식