UI Render Pipeline — peta jalur provider stream → terminal
Dokumen ini hasil audit stabilitas UI/UX terminal 2026-09-18 (baseline v0.9.27). Sumber kebenaran perilaku tetap docs/TERMINAL_CONTRACT.md (FROZEN); dokumen ini memetakan IMPLEMENTASI aktual per tahap beserta kontrak event dan temuan audit. Angka test di sini diukur di Windows + Bun 1.4.2, suite penuh hijau.
Konvensi: file:line menunjuk simbol yang dimaksud. Aturan repo tetap berlaku: cli/ boleh impor src/ui/ dan src/; src/ non-ui DILARANG impor src/ui/; src/ui/ DILARANG impor cli/, src/ non-ui, #minicore (dijaga test/ui-boundary.test.ts).
1. Jalur pipeline (9 tahap)
Tahap A — provider stream mentah: src/providers/router.ts (fallback + provider:text + provider:extension), src/providers/guards.ts:32 (timeout + text-cap + stream-guard tunggal), adapter anthropic.ts, responses.ts, effort.ts. Dirakit di src/app/provider-layer.ts.
Tahap B — kernel emit ke session.events: vendor/minicore/src/core/loop.ts (turn:started, provider:text per chunk, provider:extension reasoning/usage/error, context:compacted reason pressure:*/recovery, step:started/completed), executor.ts (execution:started/completed), session.ts (turn:completed HANYA di jalur sukses).
Tahap C — raw runtime/content contract: src/ui/contract.ts (UiEvent) masih mencerminkan bentuk event kernel untuk streaming konten dan transport. Ledger, status, lifecycle, dan keputusan user-facing melalui PresentationEvent + PresentationPolicy dari cli/setup.ts; attachSimpleLogger dan attachTurnStatus tetap memasang listener runtime per turn dan melepasnya di finally agar event telat hening.
Tahap C2 — semantic presentation bridge: cli/setup.ts membuat PresentationAdapter, mengurangi event ke PresentationState lengkap (conversation, reasoning, system, plan, finding, result, diagnostic), menghitung summary dari evidence, dan memuat ulang presentation_events saat resume. Bridge memprojection seluruh DomainEventType melalui toPresentationEvent(); src/presentation/projection.ts menyediakan keputusan policy murni (deskripsi activity/turn, running pin, envelope machine) yang di-inject ke renderer sebagai PresentationPolicy. Lihat kontrak aktif di docs/OUTPUT_EVENT_MODEL.md.
Tahap D — logger utama src/ui/assistant/simple.ts (attachSimpleLogger): append-only scrollback, tanpa alternate screen. provider:text → sanitizeAnsi PER CHUNK → streamBuffer → flushBuf (line-buffered) → flushLine (fence-state + decorateMarkdown + formatWrapped) → stdout. Jawaban bisa minimize (buffer 1MB + marker, dibuka via /expand).
Tahap E — garis status transient src/ui/assistant/turn-status.ts: satu baris stderr untuk shell/linier, hidup hanya di fase sunyi (latch turnOn && !textOn), hilang saat teks mengalir, hidup lagi saat tool berikut. endTurn() deterministik dari driver finally (kernel tak emit completed saat gagal/abort). TUI fullscreen tidak memakai painter transient ini karena alternate screen dimiliki TuiApp.
Tahap F — arbitrator src/ui/runtime/statusline.ts: satu pemilik transient (turn vs spinner); tulis asing saat painter aktif dikomit (clear → tulis → repaint) sehingga diagnostik tak hilang; overlap = signal, bukan crash. paintWrite tak pernah melempar (fail-closed Bun Windows).
Tahap G — status bar src/ui/footer.ts dirender src/ui/tui/app.ts sebagai baris dasar frame fullscreen. TuiApp memiliki clock activity 200ms untuk menggambar ✦, timer HH.MM.SS di sebelah sparkle, dan dots reasoning ... → .. → . tanpa label Working/Thinking/Running. Timer redup saat idle dan putih saat aktif. Composer hanya menampilkan minicode › setelah Prompt tampil saat boot/idle, hilang saat busy, dan kembali otomatis setelah turn selesai; aksi edit/histori tetap dapat membuka composer. Bullet separator footer dihapus; field dipisahkan dengan spasi dan warna. Satu baris kosong memisahkan composer dari footer; layout normal dan popup memakai helper geometri yang sama. TuiApp mengaktifkan mouse tracking ?1002h + ?1006h selama TUI; wheel menggeser viewport transkrip, drag kiri membuat selection app-level, dan Ctrl+C menyalin selection melalui DI/OSC52. Popup menurunkan ke mode ?1000h; tracking dimatikan pada quit, release, dan sinyal fatal. Footer/chrome lama (src/ui/runtime/chrome.ts, DECSTBM scroll-region, MINICODE_FOOTER) DIHAPUS bersama lapisan TUI lama — alternate screen menggantikannya; nol byte di non-TTY. Footer baca session.contextTokens kernel (bukan spend kumulatif).
Tahap H — primitif render src/ui/render/: sanitize.ts (hanya SGR lolos), markdown.ts + highlight.ts (fence-state per baris, konten utuh), width.ts (kolom: CJK/emoji 2, potong aman-SGR), wrap.ts (lebar dibaca per baris → aman resize), theme.ts (getter warna/glyph, jangan di-const), errors.ts (pesan actionable + redact), collapse.ts (buffer /expand 200KB/entry, 500KB total), markdown-table.ts/table-grid.ts (pipe-table model), diff.ts/table.ts/money.ts/format.ts, activity-group.ts/activity-panel.ts (panel aktivitas: klasifikasi tool → keluarga explore|edit|execute|feed|other + satu baris ringkas per keluarga, maks 10 baris; murni, dipakai TUI + linear).
Tahap H2 — tabel Markdown model: src/ui/render/markdown-table.ts memvalidasi header/delimiter, escaped pipe, code span, alignment, dan fence; stream block di-buffer sampai batas tabel. table-grid.ts menghitung lebar kolom dan budget terminal, sedangkan linear/TUI me-render semantic block yang sama pada TTY dan mempertahankan source sanitized pada non-TTY. exec --json/ACP tidak melewati renderer ini. Tahap I — view murni vs controller: src/ui/screens/ (props + callback, tanpa IO) digerakkan cli/ (tui.ts, commands.ts, wizard.ts, model-manager.ts, provider-manager.ts, src/ui/approval/prompt.ts via DI ask, setupWhenEmpty, setSubAgentSessionFactory).
2. Kontrak Event → UI
Kolom: S = bisa di-stream, D = bisa duplikat, O = bisa out-of-order, I = bisa diinterupsi, L = bisa tiba setelah completion.
Event · Producer · Representasi UI · S · D · O · I · L
assistant text · provider stream · stdout wrap + fence 2-spasi; pipe-table-aligned di TTY, source sanitized di non-TTY · ya · tidak (E1) · tidak · ya (abort) · tidak (detach flush 1x, E6)
tool call · model → kernel · › nama target muted (compact: diam) · tidak · tidak · tidak · ya · tidak
tool result · tool → executor · ledger hijau/merah; minimize → + label + buffer; panel aktivitas ON → baris grup per keluarga tool (+ explore · N tools · N ok) · tidak · tidak · tidak · ya · tidak
reasoning · provider extension · TUI composer: dots clock; panel aktivitas ON → AREA thinking live maks 10 baris (+ thinking, ringkas 1 baris saat turn selesai, detail di /expand); panel OFF → alir per baris; minimized → buffer /expand · ya · tidak · tidak · ya · tidak
thinking effort · picker /model · badge + level per model · tidak · tidak · tidak · tidak · tidak
usage · provider extension · verbose saja; masuk budget watcher · ya · tidak · ya (tail pasca-finish) · ya · ya (diabaikan aman)
compaction · kernel loop · ── compacted: <reason> kuning, reason verbatim · tidak · tidak · tidak · tidak · tidak
permission request · executor → ask DI · header stdout + askLine y/a/n, fail-closed non-TTY · tidak · tidak · tidak · ya (deny) · tidak
permission denied · executor · error tool penuh, tak pernah minimize + reason · tidak · tidak · tidak · tidak · tidak
sub-agent · delegate_task → bus parent · ledger › biasa + usage; tanpa view khusus · tidak · tidak (jurnal skip forward) · tidak · ya · tidak
error provider · provider extension · pendingError consume-once → satu pesan error berwarna oleh driver · tidak · tidak (E15) · tidak · ya · tidak
warning · budget/recovery/verify · [budget]/[recovery]/[verify] kuning stderr · tidak · tidak (warned80 sekali) · tidak · tidak · tidak
completion · kernel loop · flush + finalizeAnswer + footer idle · tidak · tidak · tidak · tidak · tidak
abort · user/Esc · (stopped) + cleanup finally · tidak · tidak · tidak · — · tidak
timeout · kernel createTimeout · timeout (berwarna) + turn dibuang transaksional · tidak · tidak · tidak · — · ya (late work ke turnStore privat, dibuang)
budget exceeded · watcher/driver · [budget] … stopping turn + abort ber-kind · tidak · tidak (fire-once) · tidak · — · tidak
provider failure · recovery policy · retry/compact/throw deterministik; cap retryAfter 30 dtk · tidak · tidak · tidak · tidak · tidak
Mismatch yang dicari, hasil: raw EventBus tetap ada untuk konten/streaming, tetapi keputusan user-facing kini membaca policy yang sama (raw ledger TUI dihapus di Phase 8). Buffer jawaban live TUI dibatasi 1.000.000 karakter; layout paint memakai satu proyeksi transcript dan cache render jawaban. cli/setup.ts (toPresentationEvent()) memproyeksikan seluruh DomainEventType secara exhaustive; TUI memakai describeActivity/matchTurn/elapsedVisible via injeksi, linear memakai deskripsi + activityFor untuk label turn-status, dan machine memakai toMachineEnvelope. bus-debug.ts hanya debug subscription. Lihat kontrak aktif di docs/OUTPUT_EVENT_MODEL.md, docs/OUTPUT_PROTOCOL_SPEC.md, dan docs/OUTPUT_RENDERING_SPEC.md. State turunan yang disengaja tetap footer memakai contextTokens (estimasi jendela), sedangkan /status memisahkan Context/Input/Output/Total/Cost/Budget di docs/CONTROL-PLANE-MAP.md.
3. State machine TUI (tidak eksplisit)
Tidak ada enum state; fase disimpulkan: abort !== null (busy), turnOn/textOn (painter), regionOn/detached (chrome), plus ~14 flag (mode, escTimer, pending, nullStreak, warned80, activeSection, minimize toggles, buffer, pendingError, raw-mode listener). Invarian dijaga finally/detachUI/endTurn, bukan type-state. Transisi ilegal yang terbukti dicegah: painter saat teks mengalir, endTurn ganda, error ganda, +/- di pipe, decoder bocor antar prompt, Tab membajak search. Satu lubang nyata: listener approval askLine menempel DI ATAS onBusyKey selama busy (byte Ctrl+C diterima keduanya) — aman fail-closed (raceAbort → deny) tetapi observabilitas ganda. Lihat §5 temuan O1.
4. Temuan audit 2026-09-18 (eksperimen di /tmp, repo tak diubah)
F1 HIGH — SGR terbelah antar-chunk merusak output model. sanitizeAnsi diterapkan PER CHUNK (simple.ts:287): ["\x1b[", "32mHello"] tampil literal 32mHello. Bukti: harness E3 out="32mHellom\n". Kelas sama untuk OSC tak-terminasi (menelan sisa chunk). Perbaikan terkecil: tahan ekor escape tak-lengkap di buffer dan sambung ke chunk berikut sebelum sanitasi.
F2 MEDIUM — SGR model bocor mentah ke stdout non-TTY. "\x1b[31mRed\x1b[0m" satu chunk lolos sanitizeAnsi (by-design) lalu ditulis ke pipe apa adanya (out="\u001b[31mRed\u001b[0m\n"), melanggar kontrak non-TTY deterministik. Perbaikan terkecil: strip SGR (atau render via c.*) saat !isTTY.
F3 LOW — buffer thinking /expand membuang kepala diam-diam (thinkingBuf.slice(-200_000) tanpa marker seperti , capped 1MB pada answer). Informasi diagnostik hilang tanpa jejak.
O1 LOW — listener ganda approval vs busy-key (§3). Fail-closed, catat saja.
Status hardening (sesudah audit): F1 diperbaiki via createStreamSanitizer
splitTrailingEscapedisrc/ui/render/sanitize.ts(dipakai jalur teks
model/reasoning/bash di simple.ts, flush deterministik saat completed/detach); F2 via kebijakan stripSgr non-TTY di wOut/wErr + rememberTurn (cerminan colorLevel → stdout.isTTY); F3 via marker … (earlier thinking truncated); O1 didokumentasikan di src/ui/tui/app.ts (busy-freeze + abort, tanpa refactor). Snap kiri/kanan: render pertama sesudah geometri berubah me-reset jangkar relatif askLine (lastGeoCols/Rows di input.ts) — geometri lebar tak dilacak footer karena erase baris-absolut tak sound pasca-reflow (terbukti model + ConPTY: reflow milik konsumen). Dijaga test/ansi-fragmentation.test.ts (termasuk properti semua-titik-belah ≡ whole-string), test/non-tty-output.test.ts, test/thinking-truncation.test.ts, test/tui-format.test.ts (stdout/stderr + ledger tool), test/footer-render.test.ts (footer), test/input-resize.test.ts (gagal di kode lama: CUP ke anchor basi).
Batas yang TERVERIFIKASI BENAR (bukti harness + 178 test UI hijau): token-by-token tepat-1x (E1), chunk 50KB utuh (E2), fence terbelah benar termasuk yatim saat abort (E4/E4b), abort flush tepat-1x tanpa bocor ke turn berikut (E6), urutan teks>tool>hasil (E7), permission fail-closed (E8), kontrak budget murni vs gate (E9), tiga reason kompaksi tampil beda (E10), forward sub-agent sebagai ledger biasa (E11), tool 5000-baris dipotong + ANSI jahat dibuang (E12), decoder UTF-8 per-byte + ESC-split + paste 100KB (E13), soak 50 turn tepat-1x 38ms +4.6MB tanpa ESC mentah (E14), error consume-once + format actionable (E15), resize 120→20 mid-stream aman (E5b), 10k events 151k ev/s tepat-1x (E17), CJK/emoji/wrap/truncate/fence (E5), emphasis tak-seimbang lewat literal + tabel konten-tetap (fidelitas).
5. Residual risk (jujur, belum dibuktikan di audit ini)
Jalur TTY-live (painter denyut, footer sticky DECSTBM, overlay picker, tombol busy) hanya tercakup harness fake-TTY + 178 test; tidak ada sesi TTY nyata di audit ini. Chaos kombinasi (stream+resize+abort serentak di TTY) dan soak 100+ turn multitool belum dijalankan. Sub-agent tanpa view khusus: indukan dibedakan hanya dari nama tool (cukup untuk 1 level, belum diuji visual untuk delegasi bertingkat — yang memang dilarang: tanpa nesting).
6. Optimasi latensi ketik & biaya salin (fase 1–4, pasca-audit)
Ringkasan: TUI tetap penuh rows baris secara LOGIS; yang diperkecil hanyalah byte FISIK yang ditulis. Kontrak perilaku user-visible tidak berubah, jadi TERMINAL_CONTRACT.md I1 adalah sumber kebenaran.
- Fase 1 — satu
writeper frame.screen.paintmerakit seluruh frame
(masuk blok sinkronis ESC[?2026h/ESC[?2026l) menjadi SATU string lalu menulisnya sekali ke process.stdout. Menghapus sejumlah write kecil per frame yang membuat ConPTY membangun ulang potongan.
- Fase 2 — diff per sel.
src/ui/render/cells.tsmemecah baris menjadi sel
(grapheme + STATE SGR ternormalisasi) dan menghasilkan byte minimal: prefix identik dilewati (nol byte), lalu dari kolom berbeda PERTAMA sampai akhir baris ditulis ulang setelah EL ESC[0K. Start mundur ke sel pembawa glyph agar EL tidak membelah glyph lebar; kolom lanjutan ditandai cont. Frame pertama / resize / perubahan jumlah kolom tetap ESC[H+ESC[2J.
- Fase 2b — invalidasi drift. Tulis asing ke stderr selama alt-screen hidup
(mis. [hooks] skipped …, warning provider) mengotori buffer di balik pelacakan diff; statusline menandainya (consumeScreenDrift) dan layar memaksa repaint penuh sekali. beginInteractiveScreen memasang wrapper stderr saat depth 0→1 supaya sinyal ini terpasang bahkan tanpa owner transient. Tanpa ini baris yang kebetulan tak berubah akan mempertahankan fragmen asing.
- Fase 3 — hot path editor.
screen.moveCursoruntuk fast-path kursor-saja
(tanpa merakit ulang frame); prompt-engine.ts memakai memo grapheme 1-entri
- fast path ASCII;
composer-wrap.tsmelakukan wrap inkremental saat append/
backspace di ujung, jatuh ke chunkByWidth penuh bila pola tak didukung.
- Fase 4 — biaya salin.
transcript.selectionTextmemakai binary search
(orderLowerBound) + selectionIsEmpty murah; guard OSC 52 berbasis byte (MAX_CLIPBOARD_BYTES/MAX_SELECTION_BYTES = 256 KiB) di simple.ts dan app.ts; /copy dibatasi total byte dan melaporkan tui.copyTooLarge.
Penjaga: test/cells.test.ts (paritas diff vs tulis-penuh + minimalitas), test/composer-wrap.test.ts (paritas wrap inkremental), test/screen.test.ts (satu write/frame, repaint penuh pada drift/frame pertama, diff setara), plus test/pty.test.ts / test/screen-buffer.test.ts untuk verifikasi buffer nyata. Angka hasil historis (laporan sebelumnya) tetap berstatus historical.