OpenAI Codex · 터미널 UI

스트리밍 출력은 터미널 두 구역 사이에서 확정됩니다

모델은 token을 밀어 내지만, 터미널은 한 번 쓰면 고칠 수 없는 문자 격자예요. 더 이상 안 바뀌는 줄은 scrollback에 넘기고, 아직 변할 꼬리는 활동 cell에 두며, 표가 닫히기 전에는 통째로 붙잡아 둡니다.

강의 목표읽고 나면 두 가지를 말할 수 있어요. 어떤 줄이 터미널 scrollback에 들어가면 고칠 수 없는지. 그리고 아직 안 끝난 markdown 표가 왜 활동 구역에 붙잡혀, 스트림이 끝난 뒤에야 한 번에 확정되는지.
먼저 해보기 · 어떤 줄이 이미 잠겼고, 표는 왜 아직 흔들리는가
표가 든 같은 짧은 글이 두 구역 사이에서 어떻게 확정되는지 보세요
표 붙잡기
끄면 머리글이 좁은 열에 먼저 잠기고, 뒤의 긴 칸이 그것을 고치지 못해요.
가짜 터미널 · 위는 안정 구역, 아래는 꼬리 확정 0 꼬리 0 스캔 None
안정 구역제출하면 굳음
활동 꼬리가변
시작을 기다려요.
입력칸은 제자리에 있어요.
논리 궤적 · 애니메이션 각 단계가 소스의 어느 구간에 대응하는지
  1. 줄바꿈 없는 delta는 보이는 꼬리를 바꾸지 않아요streaming.rs L489
  2. 수집기는 줄바꿈을 기다린 뒤에야 source를 제출해요markdown_stream.rs L87
  3. 스캐너는 윗줄이 머리글인지 봐요table_holdback.rs L23
  4. 표가 확정되면 header부터 통째로 꼬리에 붙잡아요controller.rs L384
  5. 산문 줄은 줄에 서서 tick이 scrollback에 쓰기를 기다려요controller.rs L343
  6. tick이 안정 줄을 HistoryCell로 써요streaming.rs L399
  7. insert_history가 터미널 scrollback에 써요insert_history.rs L3
  8. 스트림이 끝난 뒤에야 표 전체를 한 번에 확정해요controller.rs L160
재생을 눌러, 표가 든 짧은 글이 안정 구역과 활동 꼬리 사이에서 어떻게 확정되는지 보세요.
잠긴 줄산문이 안정 구역에 들어가면, 열 너비가 다시 변해도 닿지 않아요.
붙잡힌 표붙잡기가 켜지면 표 전체가 꼬리에서 같이 다시 섭니다. 끄면 머리글이 좁은 열에 잠기고, 긴 칸은 따로 서야 해요.
언제 확정되나스트림이 아직 가면 표는 scrollback에 못 들어가요. finalize 뒤에 통째로 한 번 제출합니다.
수업용 스케치: 짧은 글과 열 너비는 수업용 설정이에요. 궤적 행 번호는 openai/codex commit 4f39251a01에 대응합니다.
아이디어 1 · 더 이상 안 바뀌는 줄은 터미널이 스스로 보관하게 하세요
어떤 문제를 푸는가

터미널을 보며 모델이 답을 쓰는 걸 봐요. 산문은 괜찮아요. 한 줄씩 아래로 자라거든요. 그러다 표를 뱉기 시작해요. 머리글, 구분 줄, 첫 데이터 줄. 열이 한 줄 올 때마다 너비가 바뀌어요. 방금 맞춘 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행

모델 delta token 단위로 도착 줄바꿈 문 반줄은 버퍼에 두 구역 나눔 안정 줄 / 가변 꼬리 안정 구역 tick 뒤 터미널 scrollback 활동 꼬리 다음 프레임이 통째로 교체 emit된 줄은 회수하지 않아요. 꼬리는 입대 경계에서 시작해요
수업용 구조도: 같은 스트림이 먼저 줄바꿈 문을 지나, 고칠 수 없는 역사와 아직 고칠 수 있는 꼬리로 갈라져요.
왜 오래가는가

이건 칸 소유권 계약이에요. 이미 터미널 scrollback에 넘긴 글자는, 프로세스 안에 쓸 수 있는 사본이 없어요. 휠, 검색, 복사는 터미널 자신의 능력이에요. TUI가 완전한 역사 뷰포트를 또 만들 필요는 없어요. 대가는 제출한 뒤엔 고칠 수 없다는 점이에요. 다른 언어로 다시 써도, 표면이 터미널 문자 격자인 한 이 문제는 남아요.

아이디어 2 · 표가 닫히기 전에는 통째로 꼬리에 붙잡아요
어떤 문제를 푸는가

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 울타리 안의 세로선은 코드로 보고 붙잡기를 켜지 않아요. 표가 여러 장 이어질 때, 첫 장이 안 끝났으면 뒤 표도 일찍 확정하지 않아요. 아주 긴 다중 표 답은 활동 구역에 쌓여 스트림이 끝날 때까지 가요.

None 줄은 바로 안정 큐로 머리글처럼 PendingHeader 먼저 붙잡고 다음 줄 구분 줄 Confirmed 머리글부터 꼬리에 남김 산문이 도착 None으로, 통과 finalize 뒤 한 번 확정 header와 delimiter만 인정. 평범한 세로선 산문은 끝까지 붙잡지 않아요
수업용 상태도: 낙관적 붙잡기는 잠깐만 더 멈춰요. 확정된 뒤에는 표 전체가 스트림 끝을 기다려요.
왜 오래가는가

표를 증분으로 그리는 어떤 화면도 이 문제를 가져요. 열 너비는 전역량이에요. 국소 추가는 이미 그린 줄을 바꿔요. 닫히지 않은 표 전체를 가변 구역에 두는 것이 이 제약의 최소 해예요. 웹에서 대응하는 방법은, 표 노드가 닫히기 전에 불변 DOM 조각으로 쪼개지 않는 것이에요.

제출하면 굳어요. 닫히지 않은 표는 먼저 붙잡아요.
아이디어 3 · 큐는 속도가 두 단이고, 한 프레임의 쓰기는 같이 가야 해요
어떤 문제를 푸는가

안정 줄이 큐에 들어간 뒤, 바로 전부 터미널에 쓰는 것도 틀려요. 느린 스트림은 한 줄씩 자라길 원해요. 타이핑처럼요. 빠른 스트림은 큐가 모델을 따라가길 원해요. 속도가 한 단이면 양쪽이 다 힘들어요.

한 프레임 안의 쓰기 두 곳도 있어요. 역사 줄은 이스케이프 시퀀스로 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행

안정 큐 도착 시간으로 줄 섬 Smooth tick마다 한 줄 CatchUp 현재 큐를 한 번에 비움 한 번 sync_update 역사 삽입과 viewport 그리기를 같이 진입 문턱이 탈출보다 높아, 8줄 근처에서 톱니치지 않아요
수업용 시퀀스도: 몇 줄을 뽑을지는 큐 압력이 정하고, 나가는 쓰기 두 곳은 같은 프레임에 싸여야 해요.
왜 오래가는가

두 단 기어는 같은 큐의 압력 두 가지를 상대해요. 깊이는 한꺼번에 많은 줄을, 나이는 줄은 적지만 너무 오래 기다린 줄을 잡아요. 한 프레임의 여러 쓰기는 원자적으로 제출해야 해요. 터미널은 동기 출력 프로토콜을, 웹은 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
수업 실습
01

아주 긴 문단이 줄바꿈을 안 하면, 화면은 어디에 멈추나요

모델이 아주 긴 문단 한가운데에서 \n을 계속 안 뱉어요. push_delta가 줄바꿈을 봐야 렌더를 제출한다는 점, 그리고 “Unterminated source is buffered by the controller and cannot change the visible tail” 주석을 대조해, 보이는 꼬리가 언제 갱신되고 이 글이 언제 scrollback에 들어가는지 쓰세요.

한 걸음 더: 이 글이 사실 표의 반줄이라면, 붙잡기를 켰을 때와 껐을 때 사용자는 각각 무엇을 보나요.

Takeaway: 줄바꿈 없는 token은 보이지 않아요. 더 이상 안 바뀔 줄은 터미널 scrollback으로 가고, 제출하면 굳어요. 아직 변할 줄, 특히 닫히지 않은 표는 활동 꼬리에만 살며, 스트림이 끝난 뒤에 한 번에 확정해요.