AgentCLIHub 개발 노트 1: ConPTY와 xterm.js로 여러 AI CLI를 한 창에 모으기
AgentCLIHub 개발 노트 · 01 / 10

AI CLI를 여러 개 쓰면 터미널 창과 작업 폴더도 함께 늘어납니다. 한 창에 모으려면 CLI마다 있는 승인 화면, 슬래시 명령, 커서 이동 같은 대화형 동작을 유지해야 합니다. Agent CLI Hub는 Windows의 ConPTY와 웹 터미널인 xterm.js 사이에 C# 호스트를 두는 구조를 사용합니다.
1. 대화형 화면을 유지하는 실행 환경
명령을 실행하고 표준 출력을 읽는 코드는 비교적 간단합니다. 하지만 대화형 CLI의 출력에는 문장 외에도 커서를 옮기거나 색을 바꾸고, 이미 그린 영역을 갱신하는 제어 시퀀스가 들어갑니다. 사용자가 누르는 방향키와 Ctrl 조합 역시 CLI에 전달되어야 합니다. 이 흐름을 줄 단위 로그로 바꾸면 터미널 화면의 의미가 사라집니다.
Agent CLI Hub의 ConPty는 Windows 가상 콘솔을 만들고 자식 프로세스를 그 콘솔에 연결합니다. CLI가 내보내는 바이트는 호스트가 읽고, xterm.js가 이를 터미널 화면으로 해석합니다. 앱은 각 CLI의 대화 화면을 다시 구현하는 부담을 줄이고, CLI가 가진 조작 방식을 그대로 연결할 수 있습니다.
이 선택에는 비용도 있습니다. 일반 웹 채팅처럼 메시지 하나를 독립된 카드로 다루기 어렵고, 화면 크기와 터미널 상태를 계속 맞춰야 합니다. 대신 CLI가 승인 질문이나 새로운 화면 요소를 추가할 때마다 앱 쪽에서 같은 기능을 다시 만들 필요가 줄어듭니다. ConPTY 구현
2. 네 부분으로 나눈 책임
실행과 화면 사이의 역할은 다음처럼 나뉩니다.
CliProfile: CLI별 실행 명령과 옵션 구성ConPty: 가상 콘솔, 파이프, 프로세스와 Windows 핸들 관리TerminalHost: 탭 하나의 시작·입력·출력·크기 변경 처리web/index.html: xterm.js 생성과 사용자에게 보이는 터미널 표시
WPF의 MainWindow는 WebView2에서 온 메시지를 받아 해당 탭의 호스트로 보냅니다. 반대 방향으로는 호스트가 JSON 메시지를 만들어 WebView2에 전달합니다. 웹 화면은 로컬 web 폴더를 가상 호스트에 연결해서 읽으며, xterm.js도 번들에 포함됩니다. 화면을 띄우기 위해 CDN에서 터미널 스크립트를 다시 받을 필요가 없는 구성입니다.
이 구분에서 중요한 기준은 변경되는 이유입니다. Windows 프로세스 처리 변경은 ConPty, 특정 CLI의 플래그 변경은 CliProfile에서 다룹니다. 화면의 탭 모양을 바꾼다고 실행 파일 탐색 로직까지 수정할 필요는 없습니다. WebView2 초기화와 메시지 전달
3. 탭 번호가 입력과 출력을 묶는다
여러 CLI를 동시에 띄울 때 가장 먼저 지켜야 할 것은 어느 입력과 출력이 어느 대화에 속하는지입니다. Agent CLI Hub는 탭마다 TerminalHost와 xterm 인스턴스를 하나씩 둡니다. 호스트는 생성할 때 받은 작업 폴더와 CLI 정보를 유지합니다.
웹 화면에서 사용자가 입력하면 term.in 메시지에 탭 ID와 입력 문자열을 담습니다. C#은 ID로 호스트를 찾은 뒤 그 호스트의 ConPTY에만 입력을 보냅니다. 출력은 term.out으로 돌아오고, 화면 역시 ID에 맞는 xterm에만 씁니다. 이미 닫혔거나 알 수 없는 탭의 메시지는 다른 탭으로 대신 보내지 않습니다.
호스트의 Send 메서드가 모든 터미널 메시지에 ID를 붙이므로, 출력·종료·오류 처리에서 각각 번호를 넣다가 빠뜨릴 여지를 줄입니다. 같은 창에서 두 프로젝트를 열어도 작업 폴더와 출력 대상이 탭 단위로 묶여 있는 이유입니다. 탭별 호스트와 송신 처리, ID 부착
4. 터미널 크기도 전달해야 하는 데이터다
터미널의 열과 행 수는 CLI의 화면 배치에 영향을 줍니다. xterm.js를 화면에 붙인 뒤 FitAddon으로 크기를 계산하고, 최초 term.start에 열·행을 함께 보냅니다. 이후 xterm의 크기가 바뀌면 term.resize가 전달되고, 최종적으로 Windows의 ResizePseudoConsole이 호출됩니다.
창을 드래그하는 동안 생기는 중간 크기는 200ms 지연으로 묶습니다. 매번 화면을 다시 배치하는 CLI에 불필요한 변경을 연속해서 보내는 일을 줄이기 위한 선택입니다. 시작 완료 후에도 크기를 다시 맞추는 경로가 있습니다. 처음 시작할 때와 실제 화면 배치가 끝났을 때의 크기가 다를 수 있기 때문입니다.
키 입력에서는 CLI의 해석을 최대한 유지합니다. 예를 들어 Ctrl+C는 선택한 글자가 있을 때 복사로 처리하고, 그 외에는 터미널 입력으로 남깁니다. 복사 기능이 작업 중단 키를 가로채지 않게 하는 작은 구분입니다. xterm 생성과 이벤트 연결, 크기 변경 처리
5. 바이트 경계와 자원 수명까지 연결하기
ConPTY의 읽기 결과는 문자열이 아닌 바이트 배열입니다. UTF-8 문자는 한 번의 읽기 경계에서 나뉠 수 있으므로, 청크마다 독립적으로 문자열로 바꾸면 문자가 깨질 수 있습니다. TerminalHost는 상태를 유지하는 UTF-8 Decoder를 사용해 앞 청크의 미완성 바이트를 다음 청크와 이어 해석합니다.
Windows API와 연결하는 부분에서도 자료형을 명확히 해야 합니다. CreateProcessW에는 유니코드 설정을 사용하고, 수정될 수 있는 명령행 인자는 StringBuilder로 전달합니다. 가상 콘솔 핸들은 프로세스 시작 속성에 넣습니다. 가상 콘솔을 만들었다는 사실과 자식 프로세스를 거기에 연결했다는 사실은 별개의 단계입니다.
종료할 때는 가상 콘솔을 닫고 프로세스 종료를 기다린 뒤, 필요하면 종료시키고 남은 핸들과 속성 목록을 해제합니다. 탭을 닫는 UI 동작이 프로세스와 파이프의 수명까지 이어져야 장시간 사용하는 앱의 기반이 됩니다. 출력 디코딩, ConPTY 종료
핵심 정리
- CLI가 사용하는 터미널 환경을 ConPTY로 제공하고, xterm.js가 화면을 해석합니다.
- 탭 ID가 작업 폴더, 프로세스, 입력과 출력을 연결합니다.
- 화면 크기, UTF-8 경계, 프로세스 종료는 통합 터미널의 기본 동작에 포함됩니다.
- CLI별 차이와 공통 터미널 처리를 나누면 수정 범위를 좁힐 수 있습니다.
확인한 고정 커밋 소스
기준: 46dfca2 전체 변경 시점
프로젝트: AgentCLIHub GitHub 저장소
댓글
댓글 쓰기