콘텐츠로 이동

TUI 런타임 내부 구조

이 문서는 대화형 모드에서 터미널 입력부터 렌더링된 출력까지의 테마 외 런타임 경로를 설명합니다. packages/tui의 동작과 packages/coding-agent 컨트롤러의 통합에 초점을 맞춥니다.

  • packages/tui 엔진: 터미널 생명주기, stdin 정규화, 포커스 라우팅, 렌더 스케줄링, 차등 페인팅, 오버레이 합성, 하드웨어 커서 배치.
  • packages/coding-agent 대화형 모드: 컴포넌트 트리 구성, 에디터 콜백 및 키맵 바인딩, 에이전트/세션 이벤트 반응, 도메인 상태(스트리밍, 도구 실행, 재시도, 플랜 모드)를 UI 구성 요소로 변환.

경계 규칙: TUI 엔진은 메시지에 독립적입니다. Component.render(width), handleInput(data), 포커스, 오버레이만 알고 있습니다. 에이전트 시맨틱은 대화형 컨트롤러에 유지됩니다.

InteractiveModeTUI(new ProcessTerminal(), showHardwareCursor)를 생성하고 다음과 같은 영구 컨테이너를 만듭니다:

  • chatContainer
  • pendingMessagesContainer
  • statusContainer
  • todoContainer
  • statusLine
  • editorContainer (CustomEditor 포함)

init()은 해당 순서로 트리를 연결하고, 에디터에 포커스를 설정하며, InputController를 통해 입력 핸들러를 등록하고, TUI를 시작한 후 강제 렌더를 요청합니다.

강제 렌더(requestRender(true))는 다시 페인팅하기 전에 이전 줄 캐시와 커서 북키핑을 초기화합니다.

터미널 생명주기 및 stdin 정규화

섹션 제목: “터미널 생명주기 및 stdin 정규화”

ProcessTerminal.start():

  1. 원시 모드 및 브래킷 붙여넣기를 활성화합니다.
  2. 리사이즈 핸들러를 연결합니다.
  3. 부분 이스케이프 청크를 완전한 시퀀스로 분리하기 위해 StdinBuffer를 생성합니다.
  4. Kitty 키보드 프로토콜 지원을 쿼리(CSI ? u)한 후, 지원되면 프로토콜 플래그를 활성화합니다.
  5. Windows에서는 kernel32 모드 플래그를 통해 VT 입력 활성화를 시도합니다.

StdinBuffer 동작:

  • 분열된 이스케이프 시퀀스(CSI/OSC/DCS/APC/SS3)를 버퍼링합니다.
  • 시퀀스가 완료되거나 타임아웃으로 플러시될 때만 data를 방출합니다.
  • 브래킷 붙여넣기를 감지하고 원시 붙여넣기 텍스트와 함께 paste 이벤트를 방출합니다.

이를 통해 부분 이스케이프 청크가 일반 키 입력으로 잘못 해석되는 것을 방지합니다.

입력 경로:

stdin -> ProcessTerminal -> StdinBuffer -> TUI.#handleInput -> focusedComponent.handleInput

라우팅 세부 사항:

  1. TUI는 등록된 입력 리스너를 먼저 실행하여(addInputListener) 소비/변환 동작을 허용합니다.
  2. TUI는 컴포넌트 디스패치 전에 전역 디버그 단축키(shift+ctrl+d)를 처리합니다.
  3. 포커스된 컴포넌트가 이제 숨겨지거나 보이지 않는 오버레이에 속하면, TUI는 포커스를 다음 보이는 오버레이 또는 저장된 오버레이 이전 포커스로 재할당합니다.
  4. 포커스된 컴포넌트가 wantsKeyRelease = true로 설정하지 않는 한 키 해제 이벤트는 필터링됩니다.
  5. 디스패치 후 TUI는 렌더를 스케줄링합니다.

setFocus()Focusable.focused도 전환하여 컴포넌트가 하드웨어 커서 배치를 위해 CURSOR_MARKER를 방출할지 여부를 제어합니다.

키 처리 분리: 에디터 vs 컨트롤러

섹션 제목: “키 처리 분리: 에디터 vs 컨트롤러”

CustomEditor는 우선순위가 높은 조합(escape, ctrl-c/d/z, ctrl-v, ctrl-p 변형, ctrl-t, alt-up, 확장 사용자 지정 키)을 먼저 가로채고, 나머지는 기본 Editor 동작(텍스트 편집, 히스토리, 자동 완성, 커서 이동)에 위임합니다.

InputController.setupKeyHandlers()는 에디터 콜백을 모드 액션에 바인딩합니다:

  • Escape에서 취소/모드 종료
  • 이중 Ctrl+C 또는 빈 에디터 Ctrl+D에서 종료
  • Ctrl+Z에서 일시 중단/재개
  • 슬래시 명령 및 선택기 단축키
  • 후속/대기열 제거 토글 및 확장 토글

이를 통해 키 파싱/에디터 메커니즘은 packages/tui에 유지되고 모드 시맨틱은 coding-agent 컨트롤러에 유지됩니다.

TUI.requestRender()process.nextTick을 사용하여 틱당 하나의 렌더로 디바운싱됩니다. 동일한 턴에서 여러 상태 변경이 병합됩니다.

#doRender() 파이프라인:

  1. 루트 컴포넌트 트리를 newLines로 렌더링합니다.
  2. 보이는 오버레이가 있으면 합성합니다.
  3. 보이는 뷰포트 줄에서 CURSOR_MARKER를 추출하고 제거합니다.
  4. 비이미지 줄에 세그먼트 리셋 접미사를 추가합니다.
  5. 전체 재페인트와 차등 패치 중에서 선택합니다:
    • 첫 번째 프레임
    • 너비 변경
    • clearOnShrink가 활성화되고 오버레이가 없는 상태에서 축소
    • 이전 뷰포트 위의 편집
  6. 차등 업데이트의 경우 변경된 줄 범위만 패치하고, 필요한 경우 오래된 후행 줄을 지웁니다.
  7. IME 지원을 위해 하드웨어 커서를 재배치합니다.

렌더 쓰기는 플리커/티어링을 줄이기 위해 동기화된 출력 모드(CSI ? 2026 h/l)를 사용합니다.

TUI의 중요 안전 검사:

  • 비이미지 렌더링 줄은 터미널 너비를 초과해서는 안 됩니다. 오버플로우 시 예외를 발생시키고 크래시 진단을 작성합니다.
  • 오버레이 합성에는 방어적 잘라내기와 합성 후 너비 검증이 포함됩니다.
  • 너비 변경은 줄 바꿈 시맨틱이 변경되기 때문에 전체 재그리기를 강제합니다.
  • 커서 위치는 이동 전에 클램핑됩니다.

이러한 제약은 단순한 관례가 아닌 런타임 적용 사항입니다.

리사이즈 이벤트는 ProcessTerminal에서 TUI.requestRender()로 이벤트 기반으로 처리됩니다.

효과:

  • 너비 변경 시 전체 재그리기가 트리거됩니다.
  • 뷰포트/상단 추적(#previousViewportTop, #maxLinesRendered)은 콘텐츠나 터미널 크기가 변경될 때 잘못된 상대 커서 계산을 방지합니다.
  • 오버레이 가시성은 터미널 크기에 의존할 수 있으며(OverlayOptions.visible), 리사이즈 후 오버레이가 보이지 않게 되면 포커스가 수정됩니다.

EventControllerAgentSessionEvent를 구독하고 UI를 증분 방식으로 업데이트합니다:

  • agent_start: statusContainer에서 로더를 시작합니다.
  • message_start 어시스턴트: streamingComponent를 생성하고 마운트합니다.
  • message_update: 스트리밍 어시스턴트 콘텐츠를 업데이트하고, 도구 호출이 나타날 때 도구 실행 구성 요소를 생성/업데이트합니다.
  • tool_execution_update/end: 도구 결과 구성 요소와 완료 상태를 업데이트합니다.
  • message_end: 어시스턴트 스트림을 완료하고, 중단/오류 주석을 처리하며, 정상 중지 시 보류 중인 도구 인수를 완료로 표시합니다.
  • agent_end: 로더를 중지하고, 임시 스트림 상태를 지우며, 지연된 모델 전환을 플러시하고, 백그라운드 상태이면 완료 알림을 발행합니다.

읽기 도구 그룹화는 의도적으로 상태를 유지하며(#lastReadGroup), 비읽기 중단이 발생할 때까지 연속적인 읽기 도구 호출을 하나의 시각적 블록으로 병합합니다.

상태 레인 소유권:

  • statusContainer는 임시 로더(loadingAnimation, autoCompactionLoader, retryLoader)를 보유합니다.
  • statusLine은 영구 상태/훅/플랜 표시기를 렌더링하고 에디터 상단 테두리 업데이트를 구동합니다.

로더 동작:

  • Loader는 인터벌을 통해 80ms마다 업데이트하고 각 프레임에서 렌더를 요청합니다.
  • 자동 압축 및 자동 재시도 중에는 이스케이프 핸들러가 해당 작업을 취소하기 위해 일시적으로 재정의됩니다.
  • 종료/취소 경로에서 컨트롤러는 이전 이스케이프 핸들러를 복원하고 로더 구성 요소를 중지/지웁니다.

입력 텍스트 접두사가 에디터 테두리 모드 플래그를 전환합니다:

  • ! -> bash 모드
  • $ (비템플릿 리터럴 접두사) -> python 모드

Escape는 에디터 텍스트를 지우고 테두리 색상을 복원하여 비활성 모드를 종료합니다. 실행이 활성 상태일 때 Escape는 실행 중인 작업을 대신 중단합니다.

InteractiveMode는 플랜 모드 플래그, 상태 줄 상태, 활성 도구, 모델 전환을 추적합니다. 진입/종료 시 세션 모드 항목과 상태/UI 상태를 업데이트하며, 스트리밍이 활성 상태이면 지연된 모델 전환을 포함합니다.

InputController.handleCtrlZ():

  1. TUI를 다시 시작하고 강제 렌더하기 위해 일회성 SIGCONT 핸들러를 등록합니다.
  2. 일시 중단 전에 TUI를 중지합니다.
  3. 프로세스 그룹에 SIGTSTP를 전송합니다.

백그라운드 모드 (/background 또는 /bg)

섹션 제목: “백그라운드 모드 (/background 또는 /bg)”

handleBackgroundCommand():

  • 유휴 상태일 때 거부합니다.
  • 도구 UI 컨텍스트를 비대화형(hasUI=false)으로 전환하여 대화형 UI 도구가 빠르게 실패하도록 합니다.
  • 로더/상태 줄을 중지하고 포그라운드 이벤트 핸들러의 구독을 취소합니다.
  • 백그라운드 이벤트 핸들러를 구독합니다(주로 agent_end를 기다림).
  • TUI를 중지하고 SIGTSTP를 전송합니다(POSIX 작업 제어 경로).

대기 중인 작업 없이 백그라운드에서 agent_end 발생 시, 컨트롤러는 완료 알림을 전송하고 종료합니다.

주요 취소 입력:

  • 활성 스트림 로더 중 Escape: 대기 중인 메시지를 에디터에 복원하고 에이전트를 중단합니다.
  • bash/python 실행 중 Escape: 실행 중인 명령을 중단합니다.
  • 자동 압축/재시도 중 Escape: 임시 이스케이프 핸들러를 통해 전용 중단 메서드를 호출합니다.
  • Ctrl+C 단일 누름: 에디터 지우기; 500ms 내 두 번 누름: 종료.

취소는 상태 조건부입니다. 동일한 키가 런타임 상태에 따라 중단, 모드 종료, 선택기 트리거, 또는 아무 동작 없음을 의미할 수 있습니다.

이벤트 기반 업데이트:

  • 에이전트 세션 이벤트(EventController)
  • 키 입력 콜백(InputController)
  • 터미널 리사이즈 콜백
  • InteractiveMode의 테마/브랜치 감시자

스로틀/디바운스 경로:

  • TUI 렌더링은 틱 디바운싱됩니다(requestRender 병합).
  • 로더 애니메이션은 고정 인터벌(80ms)로, 각 프레임에서 렌더를 요청합니다.
  • 에디터 자동 완성 업데이트(Editor 내부)는 디바운스 타이머를 사용하여 타이핑 중 재계산 오버헤드를 줄입니다.

따라서 런타임은 이벤트 기반 상태 전환과 제한된 렌더 주기를 혼합하여 재페인트 폭풍 없이 대화형 응답성을 유지합니다.