스트리밍 출력은 터미널 두 구역 사이에서 확정됩니다
모델은 token을 밀어 내지만, 터미널은 한 번 쓰면 고칠 수 없는 문자 격자예요. 더 이상 안 바뀌는 줄은 scrollback에 넘기고, 아직 변할 꼬리는 활동 cell에 두며, 표가 닫히기 전에는 통째로 붙잡아 둡니다.
- 줄바꿈 없는 delta는 보이는 꼬리를 바꾸지 않아요streaming.rs L489
- 수집기는 줄바꿈을 기다린 뒤에야 source를 제출해요markdown_stream.rs L87
- 스캐너는 윗줄이 머리글인지 봐요table_holdback.rs L23
- 표가 확정되면 header부터 통째로 꼬리에 붙잡아요controller.rs L384
- 산문 줄은 줄에 서서 tick이 scrollback에 쓰기를 기다려요controller.rs L343
- tick이 안정 줄을 HistoryCell로 써요streaming.rs L399
- insert_history가 터미널 scrollback에 써요insert_history.rs L3
- 스트림이 끝난 뒤에야 표 전체를 한 번에 확정해요controller.rs L160
터미널을 보며 모델이 답을 쓰는 걸 봐요. 산문은 괜찮아요. 한 줄씩 아래로 자라거든요. 그러다 표를 뱉기 시작해요. 머리글, 구분 줄, 첫 데이터 줄. 열이 한 줄 올 때마다 너비가 바뀌어요. 방금 맞춘 Description이 다음 열로 밀리고, 이전 프레임의 세로선이 화면에 그대로 남아요.
방금 그 결론을 보려 위로 굴리면, 휠이 움직이고 역사와 아직 쓰는 꼬리가 겹쳐요. 웹이 DOM을 바꿀 때 브라우저는 스크롤 위치를 지켜 줘요. 터미널은 stdout에 글자 하나를 쓰면 커서가 한 칸 가요. 옛 답을 남기면서도 표가 새 줄에 맞춰 열 너비를 바꾸게 하려면, 어떤 칸이 과거이고 어떤 칸이 아직 현재인지부터 정해야 해요.
Codex는 렌더 결과를 두 구역으로 잘라요. 더 이상 안 바뀔 줄은 애니메이션 큐에 들어가, commit tick을 기다려 HistoryCell이 되고, 이스케이프 시퀀스로 터미널 자신의 scrollback에 들어가요. 아직 변할 줄은 활동 cell에만 살아요. 다음 프레임이 통째로 바꿔도 돼요.
출처:codex-rs/tui/src/streaming/controller.rs 1–36행
컨트롤러는 길이를 두 벌 동시에 기억해요. enqueued_stable_len은 큐에 넘긴 줄 수, emitted_stable_len은 scrollback에 쓴 줄 수예요. 꼬리는 enqueue 경계에서 시작해요. emit으로 자르면, 줄에 섰지만 아직 안 쓴 줄이 활동 cell에 한 번 더 나와요. 포인터 셋이 같은 방향으로 가고, 이미 emit된 조각은 되돌아가 고치면 안 돼요.
줄바꿈 없는 token은 꼬리조차 갱신하지 않아요. 사용자가 보는 가장 작은 시간 단위는 markdown source 한 줄이에요. 표의 반줄을 먼저 그리면, 다음 초에 구조가 맞아떨어지며 열이 사라졌다가 다시 자라요. 끝나지 않은 source는 버퍼로 가고, 보이는 꼬리를 바꾸면 안 돼요.
출처:codex-rs/tui/src/chatwidget/streaming.rs 489–492행
이건 칸 소유권 계약이에요. 이미 터미널 scrollback에 넘긴 글자는, 프로세스 안에 쓸 수 있는 사본이 없어요. 휠, 검색, 복사는 터미널 자신의 능력이에요. TUI가 완전한 역사 뷰포트를 또 만들 필요는 없어요. 대가는 제출한 뒤엔 고칠 수 없다는 점이에요. 다른 언어로 다시 써도, 표면이 터미널 문자 격자인 한 이 문제는 남아요.
markdown 표는 한 줄만 더해도 모든 열 너비가 바뀌어요. 머리글이 이미 좁은 열로 scrollback에 얼어 있으면, 뒤의 긴 칸이 돌아가 고치지 못해요. 세로선이 어긋나고, 잔상이 제자리에 남아요.
평범한 산문의 세로선도 다칠 수 있어요. status | owner | note 한 줄은 머리글처럼 보이지만 그냥 문장이에요. 너무 세게 붙잡으면 이 산문이 막혀, 스트림이 끝나야 풀려요.
스캐너는 header 더하기 delimiter만 인정해요. 윗줄이 머리글처럼 보이고 다음 줄이 아직 없으면, 일단 낙관적으로 붙잡고 상태는 PendingHeader예요. 뒤에 오는 게 평범한 산문이면 상태는 None으로 돌아가고, 그 줄은 안정 큐로 가요. 두 줄이 맞으면 Confirmed로 들어가, header부터 표 전체가 꼬리에 남아 finalize까지 기다려요. 표 앞의 산문은 계속 제출할 수 있어요.
출처:codex-rs/tui/src/streaming/table_holdback.rs 21–32행
꼬리 예산은 스캔 상태가 정해요. None이면 예산은 0이고, 줄은 바로 안정 큐로 가요. PendingHeader와 Confirmed는 header 시작부터 통째로 붙잡아요. Raw 모드도 예산 0이에요. 표는 순수 텍스트로 흘러가고, 열 너비는 사용자 터미널 선택 영역 몫이에요.
출처:codex-rs/tui/src/streaming/controller.rs 384–412행
sh 울타리 안의 세로선은 코드로 보고 붙잡기를 켜지 않아요. 표가 여러 장 이어질 때, 첫 장이 안 끝났으면 뒤 표도 일찍 확정하지 않아요. 아주 긴 다중 표 답은 활동 구역에 쌓여 스트림이 끝날 때까지 가요.
표를 증분으로 그리는 어떤 화면도 이 문제를 가져요. 열 너비는 전역량이에요. 국소 추가는 이미 그린 줄을 바꿔요. 닫히지 않은 표 전체를 가변 구역에 두는 것이 이 제약의 최소 해예요. 웹에서 대응하는 방법은, 표 노드가 닫히기 전에 불변 DOM 조각으로 쪼개지 않는 것이에요.
안정 줄이 큐에 들어간 뒤, 바로 전부 터미널에 쓰는 것도 틀려요. 느린 스트림은 한 줄씩 자라길 원해요. 타이핑처럼요. 빠른 스트림은 큐가 모델을 따라가길 원해요. 속도가 한 단이면 양쪽이 다 힘들어요.
한 프레임 안의 쓰기 두 곳도 있어요. 역사 줄은 이스케이프 시퀀스로 viewport 위에 쓰고, ratatui가 입력칸과 활동 꼬리를 그려요. 새로고침을 두 번으로 나누면, 사용자는 먼저 역사가 한 칸 위로 뛰는 걸 보고, 입력칸은 옛 자리에 그대로 있어요.
배수는 두 단이에요. 스무스은 각 tick에 한 줄, 추격은 현재 큐를 한 번에 비워요. 깊이가 8줄이거나, 가장 오래된 줄이 120밀리초를 넘으면 추격에 들어갈 수 있어요. 나가려면 깊이가 2, 나이가 40밀리초까지 내려가 250밀리초를 유지해야 해요. 나간 뒤 250밀리초를 더 막아요. 64줄이나 300밀리초로 쌓이지 않는 한요. 전략은 이 텍스트가 제목인지 표인지 보지 않아요. 큐 깊이와 나이만 봐요.
출처:codex-rs/tui/src/streaming/chunking.rs 82–125행
타이머 스레드는 프레임 간격으로 CommitTick만 보내요. 몇 줄을 뽑을지는 전략 몫이에요. 그 간격은 120 FPS 하한이라, 스무스의 상한은 초당 120줄이에요. 화면을 쓸 때 scrollback 삽입과 viewport 그리기는 같은 sync_update에 싸여요. 이중 버퍼는 바뀐 칸만 써요. 그리기 콜백은 프레임을 가득 그려야 해요. 한 덩이를 빼먹으면 터미널에 이전 프레임의 잔글자가 남아요.
출처:codex-rs/tui/src/tui.rs 954–973행
두 단 기어는 같은 큐의 압력 두 가지를 상대해요. 깊이는 한꺼번에 많은 줄을, 나이는 줄은 적지만 너무 오래 기다린 줄을 잡아요. 한 프레임의 여러 쓰기는 원자적으로 제출해야 해요. 터미널은 동기 출력 프로토콜을, 웹은 DOM 한 번 교체를 써요. 먼저 지우고 그리면, 중간 프레임은 반드시 눈에 보여요.
Claude Code: 메시지 나무 전체를 아직 고칠 수 있고, 동기 출력은 터미널에 먼저 물어요
Claude Code의 TUI는 Ink예요. 매 프레임은 먼저 React 나무를 조화하고, 전후 화면에 cell diff를 한 뒤, DEC 2026 BSU/ESU로 한 겹 쌀지 정해요. 탐지 함수는 직설적이에요. 지원되면 다시 그릴 때 깜빡임을 피해요. tmux는 패킷을 쪼개서 원자성이 이미 없고, 그 16바이트를 또 보내면 바깥 터미널만 부담이니 그냥 건너뛰어요.
여기엔 안정 구역, 가변 꼬리, 표 holdback 세트가 없어요. 이미 그린 노드도 다음 프레임에서 고칠 수 있어요. 대가는 조화가 JS에서 일어난다는 점이에요. Codex는 제출된 줄을 터미널 scrollback에 넘기고, 활동 구역이 원래 작아서 매번 draw가 sync_update를 타고, 터미널 화이트리스트를 따로 유지하지 않아요.
소스 대조restored-src/src/ink/terminal.ts 66–74행 · 2026-08-22
Grok Build: 역사는 프로세스에 남고, chunk마다 캐시를 무효화해요
Grok의 pager도 ratatui TUI지만, 역사는 터미널 scrollback에 넘기지 않아요. Agent 블록은 EntryId로 프로세스 안 ScrollbackState에 살아요. chunk가 오면 같은 블록에 텍스트를 덧붙이고, 바로 invalidate_cache를 하며 높이를 dirty로 표시해요. 다음 레이아웃은 새 너비로 다시 계산해요.
너비가 바뀌면, 프로세스 안 블록은 source로 다시 그릴 수 있어요. Codex가 이미 emit한 줄은 터미널 것이라, finalize 뒤에 완전한 source로 다시 그릴 수 있는 cell을 만들어야 해요. Grok이 내는 값은 메모리에 모든 블록을 걸어 두고, chunk마다 레이아웃 캐시를 버리는 것이에요.
소스 대조xai-grok-pager/src/scrollback/state/mod.rs 915–926행 · 2026-08-22
아주 긴 문단이 줄바꿈을 안 하면, 화면은 어디에 멈추나요
모델이 아주 긴 문단 한가운데에서 \n을 계속 안 뱉어요. push_delta가 줄바꿈을 봐야 렌더를 제출한다는 점, 그리고 “Unterminated source is buffered by the controller and cannot change the visible tail” 주석을 대조해, 보이는 꼬리가 언제 갱신되고 이 글이 언제 scrollback에 들어가는지 쓰세요.
한 걸음 더: 이 글이 사실 표의 반줄이라면, 붙잡기를 켰을 때와 껐을 때 사용자는 각각 무엇을 보나요.