원문 캡처 · getpullrequest.com
원문 캡처 · getpullrequest.com

GetPullRequest는 사용자의 컴퓨터에서 실행되는 코딩 에이전트의 응답과 도구 실행, 코드 변경 사항을 Flutter 앱에 실시간으로 표시한다. 이를 위해 구조화된 메시지를 문자열로 바꾸는 방식을 택했고, Flutter의 GenUI 대신 streaming_gen_ui를 수정해 긴 세션과 도구 업데이트를 처리했다.

GetPullRequest는 Claude Code, Codex, Cursor 같은 코딩 에이전트를 사용자 컴퓨터에서 실행하고, 휴대전화에서 진행 상황을 보고 대화하는 서비스다. 에이전트는 Agent Client Protocol(ACP)에 따라 JSON-RPC 메시지를 보낸다. 메시지는 응답 일부, 추론 일부, 도구 시작, 도구 상태 변경처럼 유형이 구분돼 있다. 그런데 앱은 이 구조화된 메시지를 하나의 문자열로 바꿔 화면에 그린다.

각 에이전트 작업에는 별도의 Stream<String>이 있다. 에이전트의 응답 텍스트는 그대로 스트림에 들어간다. 도구 카드, 코드 차이, 터미널 출력, 권한 요청, 에이전트의 생각은 앱의 AcpChatService가 JSON이 든 <interface> 태그로 감싼다. 화면을 그릴 때는 태그 파서가 문자열을 텍스트와 태그로 나누고, 스트리밍 JSON 파서가 태그 안의 속성을 읽는다. 등록된 위젯은 namespace를 보고 해당 UI를 표시한다.

즉, 구조화된 데이터를 문자열로 바꿨다가 다시 구조화된 데이터로 파싱한다. 이 방식은 어색해 보이지만, 실시간 화면과 저장된 기록이 같은 마크업과 위젯을 사용한다는 이점이 있다. 세션을 다시 열어도 같은 렌더러가 기록을 그리며, 작업이 끝나면 저장된 기록으로 실시간 화면을 교체해 네트워크가 끊겼을 때 누락된 메시지도 보완한다.

Flutter의 GenUI를 먼저 시험했지만, 전체 모델 응답을 받은 뒤에야 UI를 그려 실시간 업데이트가 필요한 코딩 에이전트에 맞지 않았다. 에이전트가 도구를 시작할 때 즉시 카드를 보여줘야 하기 때문이다. GenUI 저장소의 개발 검토 항목에도 스트리밍 UI 지원이 포함돼 있다. 부분적으로 도착한 JSON을 파싱하고, 아직 도착하지 않은 위젯을 참조하는 요소를 처리하는 등 해결할 과제가 있다. 다른 Flutter 개발자도 Reddit 논의에서 불완전한 JSON을 실시간으로 파싱하는 일을 어려움으로 꼽았다.

<interface> 방식은 streaming_gen_ui에서 가져왔다. 이 패키지는 텍스트와 태그를 분리하는 llm_tag_parser, 태그가 완성되기 전에 JSON을 읽는 llm_json_stream을 사용한다. namespace가 도착하면 해당 위젯을 먼저 표시하고 나머지 필드를 이어서 채운다. 처음에는 언어 모델이 직접 UI 태그를 작성하게 했지만, ACP 에이전트로 전환한 뒤에는 앱이 에이전트의 메시지를 같은 형식으로 변환하도록 했다. 에이전트 자체는 태그를 보지 않는다.

GetPullRequest 측은 코딩 에이전트의 긴 작업에 맞게 패키지 사본을 저장소에 두고 수정했다. 원래 패키지는 짧은 챗봇 응답을 가정하지만, 에이전트 작업은 한 시간 이어지며 도구 카드 수백 개, 긴 터미널 출력, 큰 코드 차이를 만들 수 있다. 패키지는 대화나 도구 관리를 맡지 않으므로 이런 부분을 앱에서 처리했다.

원래 구현은 텍스트가 들어올 때마다 notifyListeners()를 호출했다. 수정본은 한 마이크로태스크 안에 들어온 여러 청크를 모아 한 번만 알린다. 또 전체 응답을 하나의 Column에 넣는 대신 각 블록을 목록의 개별 행으로 만들어 화면에 보이는 항목만 빌드한다. 패키지에 포함된 날씨 카드나 상품 결과 위젯은 제거하고 gpr: 위젯을 등록했다.

텍스트 스트리밍에서는 청크가 단어 한가운데나 마크다운 코드 블록 경계에서 끊길 수 있다. 코드 블록을 완성된 뒤에야 표시하면 긴 코드가 도착하는 동안 일반 텍스트처럼 보였다가 갑자기 코드 상자로 바뀐다. 이를 줄이기 위해 AgentMessageTextAssembler가 완성된 여는 코드 펜스를 감지하면 코드 블록 태그를 만들고, 이어지는 코드를 그 안에 보낸다. 청크 끝에 백틱 한두 개만 있으면 다음 청크가 올 때까지 보류한다. JSON 코드 블록은 에이전트가 닫는 펜스를 빠뜨리는 경우가 있어 괄호와 따옴표를 세고, 뒤 문장이 일반 텍스트처럼 보이면 닫는 펜스를 기다리지 않는다. 다음 텍스트가 문장처럼 보이는지는 문자, #, *로 시작하는지 정규식으로 확인한다.

줄 단위 렌더링은 마크다운 표와 코드 블록, 중첩 목록도 깨뜨렸다. splitMarkdownBlocks는 줄을 문단, 표, 코드 블록으로 묶는다. 코드 펜스는 닫힐 때까지 코드 블록으로 확정하지 않으며, 들여쓰기는 파싱 전에 제거하고 패딩으로 복원해 중첩 목록을 유지한다.

스트림이 새 텍스트만 보내는 경우와 지금까지의 전체 텍스트를 반복해서 보내는 경우가 섞이면 같은 내용이 중복될 수 있다. mergeStreamedText는 새 청크가 전체 텍스트인지, 반복된 내용인지, 기존 텍스트와 겹치는 새 내용인지 판별해 불필요한 덧붙이기를 막는다.

도구 호출은 시작 뒤에도 상태와 출력이 바뀐다. 열린 태그 안에 업데이트를 계속 넣으려 했지만, 도구 두 개가 동시에 실행되면 태그가 섞여 원시 JSON이 화면에 나타났다. 대신 도구마다 ID를 포함한 완성된 카드를 문자열에 한 번 넣고, 후속 업데이트는 ToolCallLiveBus로 전달한다. 이 버스는 브로드캐스트 StreamController와 도구별 최신 상태를 기억하는 맵으로 구성된다. 각 카드는 자신의 ID에 해당하는 업데이트만 듣는다. 화면에 없던 카드도 다시 만들어질 때 최신 상태를 먼저 읽으므로, 실행 중인 도구가 완료된 뒤에도 상태가 오래된 채로 남지 않는다.

이 버스는 앱 전체에서 공유하는 정적 전역 객체라 테스트하기 어렵다는 단점이 있다. 다만 Dart의 단일 이벤트 루프에서 업데이트가 동시에 실행되지 않아 잠금은 필요하지 않다. 이 구조에서는 모든 상태가 문자열 하나에 담기는 것도 아니다. 문자열에는 구조가 있고, 실시간 상태는 별도 경로로 전달된다.

큰 코드 차이나 출력은 JSON 파서가 문자마다 처리하면 UI 스레드에 부담을 준다. 큰 차이 하나 때문에 새 메시지가 들어와도 화면이 멈춘 듯 보이는 문제가 발생했다. 수정본은 코드나 출력처럼 무거운 필드의 긴 문자열을 별도의 메모리 저장소에 두고, 태그에는 이를 가리키는 짧은 ID만 넣는다. 기준은 4096자다.

Flutter에서 위젯을 실행하는 isolate는 Android 앱의 메인 스레드이기도 하며 터치 입력에도 응답해야 한다. 긴 작업으로 이 스레드가 막히면 Android가 앱이 응답하지 않는다고 판단할 수 있다. 실제로 두 차례 ‘앱이 응답하지 않음’ 대화상자가 나타났다.

첫 번째 원인은 모든 목록 행에 켜 둔 keep-alive였다. 화면에서 벗어난 행도 메모리에 남겨 확장 상태를 유지했지만, 긴 세션에서는 수백 개 행이 쌓였다. 화면 회전 때 Flutter가 모든 행의 크기를 다시 계산하고 배치하면서 메인 스레드가 오래 막혔다. keep-alive를 끄고 화면 주변의 작은 범위만 빌드하도록 바꿨다. 행은 화면 밖으로 나가면 제거되고, 다시 보일 때 새로 만들어진다.

두 번째 문제는 저장된 긴 세션을 열 때였다. 수백 개 태그를 분리하고 jsonDecode하는 작업을 메인 스레드에서 했다. 긴 기록은 Isolate.run으로 별도 isolate에서 파싱하도록 바꿨다. 짧은 기록은 isolate를 시작하는 비용이 더 클 수 있어 메인 스레드에서 처리한다. 텍스트가 12,000자를 넘거나 태그가 24개보다 많으면 isolate로 보낸다. 이 기준도 정밀한 계산이 아니라 실제로 동작하는 값을 택했다.

한 응답을 하나의 목록 항목으로 다루는 방식도 바꿨다. 이제 작업의 각 블록이 뒤집힌 ListView의 개별 행이며, 안정적인 키를 사용해 새 행이 추가돼도 기존 행을 불필요하게 다시 만들지 않는다. 행 유형은 Dart 3의 sealed class로 정의했다. 렌더링 엔진의 모든 변경이 목록 전체를 다시 빌드하지 않도록, 페이지는 엔진을 구독하는 대신 읽고 블록 수가 달라질 때만 목록을 다시 만든다. 내용이 늘어나는 문단은 해당 위젯만 setState를 호출한다. 핵심 원칙은 스트림을 소유한 위젯을 그 스트림을 보여주는 가장 작은 위젯으로 만드는 것이다.

스크롤 동작은 여전히 고민거리다. 카드를 펼칠 때 제목이 손가락에서 멀어지는 문제를 줄이기 위해 변경 전후 높이 차이를 측정해 프레임 뒤 스크롤 위치를 조정한다. 접을 때는 Flutter가 먼저 스크롤 위치를 바꾸므로 변경 전 위치를 기억해야 한다. 새 콘텐츠로 자동 스크롤하는 기준은 화면 아래에서 200픽셀 이내인지 여부지만, 이 값은 휴대전화에서 맞아 보이는 추정치이며 태블릿에는 맞지 않을 수 있다.

streaming_gen_ui 사본을 많이 수정할수록 업그레이드가 어려워지는 점도 남은 과제다. 일부 변경을 원본 프로젝트에 반영하거나 필요한 기능만 갖춘 더 작은 구현을 만드는 방안을 고려하고 있다. GenUI가 제대로 된 스트리밍 지원을 제공하면 다시 옮기는 것도 검토할 수 있다.

Flutter를 다시 선택하겠느냐는 질문에는 그렇다고 답한다. 처음에는 작은 팀이 Android와 iOS 앱 하나를 함께 출시할 수 있다는 이유로 선택했지만, Dart의 스트림, 브로드캐스트 컨트롤러, 늦게 연결된 리스너에 과거 데이터를 보내는 Stream.multi, async가 스트리밍 작업에 잘 맞았다. 단일 이벤트 루프 덕분에 전역 버스에 잠금이 필요하지 않았고, 병렬 작업에는 Isolate.run을 사용할 수 있었다. 반면 메인 스레드에 대한 잘못된 가정을 막아주지는 못했다.

GetPullRequest는 무료로 체험할 수 있다.

부록: 원문 예시

Here's
It doesn't arrive in one piece. It might arrive as three chunks like these:
```

```"
If you render the text after every chunk, this is what the user sees:

After chunk 1, a stray backtick at the end of the sentence.
After chunk 2, `dart final total = pri` as plain text, because there is no complete code block yet.
After chunk 3, the text suddenly turns into a proper code block with syntax highlighting.

So for a moment the screen shows something that was never in the reply. With short replies it's a flicker. With a long code block streaming for several seconds, it looks broken.
I knew this in theory, but I only really understood it after watching it happen on my own phone, in a long code block, over and over.
We fixed this with a small class called `AgentMessageTextAssembler`. It watches the reply as it streams in. As soon as it sees a complete opening fence, like
```

```dart
The tricky part is the end of each chunk. If a chunk ends with one or two backticks, they might be the start of a fence. So the assembler holds back just those characters until the next chunk arrives and makes it clear.
`final ticks = _trailingBacktickCount(text); if (ticks == 1 || ticks == 2) return ticks; // not sure yet, keep these back `
JSON code blocks are harder, because agents often forget to close them. For those, the assembler counts brackets and quotes. If the JSON looks complete and the next text looks like a normal sentence, it stops waiting for a closing fence that will never come.
How does it decide that the next text "looks like a sentence"? A regex checks if it starts with a letter, `#` or `*`. It's a rough rule, but it works.
Plain markdown had its own version of this. Our chat renders the agent's text one line at a time, so you can tap any line and comment on it. Most markdown doesn't care. But some of it only works when the lines are read together. Here are the three cases that bit us.
A table. The agent writes:
`| File        | Change   | |-------------|----------| | auth.dart   | +12 -3   | | login.dart  | +40 -18  | `
A table only works when all its rows are read together. We were reading one row at a time, so instead of a neat table, the user saw four lines of plain text with `|` symbols scattered through them.
A code block. The agent writes:
```