Membangun antarmuka untuk developer tools berbasis AI seperti Claude Code, Codex CLI, atau platform observabilitas membutuhkan perlakuan khusus pada layer visual. Dua komponen paling kritikal adalah terminal monospaced yang mendukung ANSI code dan visual diff viewer untuk perubahan kode sumber.
Panduan ini membahas arsitektur rendering berbasis token CSS Tailwind, strategi buffering streaming chunk LLM guna menghindari Cumulative Layout Shift (CLS), serta render perbandingan kode secara efisien di Next.js/React.
Arsitektur Desain Sistem dan CSS Variables
Terminal dan diff viewer menuntut saturasi warna yang terkontrol agar teks tetap terbaca pada resolusi tinggi. Konfigurasi globals.css berikut menyediakan semantic tokens untuk diff status dan ANSI colors:
@layer base {
:root {
--terminal-bg: 222.2 84% 4.9%;
--terminal-fg: 210 40% 98%;
--diff-add-bg: 142 70% 45% / 0.15;
--diff-add-fg: 142 76% 36%;
--diff-del-bg: 0 72% 51% / 0.15;
--diff-del-fg: 0 72% 51%;
--diff-gutter: 215 16% 47% / 0.4;
}
.dark {
--terminal-bg: 0 0% 8%;
--terminal-fg: 0 0% 95%;
--diff-add-bg: 142 70% 30% / 0.2;
--diff-add-fg: 142 69% 58%;
--diff-del-bg: 0 62% 30% / 0.25;
--diff-del-fg: 0 84% 60%;
--diff-gutter: 215 14% 34%;
}
}Terminal Output: ANSI Parsing dan Streaming Buffer
Memasukkan token dari SSE (Server-Sent Events) langsung ke React state pada setiap frame akan memicu micro-re-renders yang membuat thread UI terblokir. Gunakan buffer dengan requestAnimationFrame untuk mengelompokkan token yang masuk.
Implementasi Hook Streaming Terminal
import { useState, useEffect, useRef } from "react";
export function useStreamBuffer() {
const [content, setContent] = useState("");
const bufferRef = useRef("");
const frameRef = useRef<number | null>(null);
const appendChunk = (chunk: string) => {
bufferRef.current += chunk;
if (!frameRef.current) {
frameRef.current = requestAnimationFrame(() => {
setContent((prev) => prev + bufferRef.current);
bufferRef.current = "";
frameRef.current = null;
});
}
};
useEffect(() => {
return () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
};
}, []);
return { content, appendChunk };
}Komponen Terminal Monospaced
Gunakan pustaka parser ringan seperti ansi-to-react untuk menerjemahkan escape sequence (seperti warna hijau `\x1b[32m`) menjadi tag HTML bersesuaian, diapit container berbasis font monospaced.
import Ansi from "ansi-to-react";
import { useEffect, useRef } from "react";
interface TerminalViewProps {
rawLog: string;
}
export function TerminalView({ rawLog }: TerminalViewProps) {
const viewportRef = useRef<HTMLDivElement>(null);
// Auto-scroll hanya jika posisi scroll sudah di batas bawah
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 40;
if (isAtBottom) {
el.scrollTop = el.scrollHeight;
}
}, [rawLog]);
return (
<div
ref={viewportRef}
className="h-96 w-full overflow-y-auto rounded-lg border border-border bg-[hsl(var(--terminal-bg))] p-4 font-mono text-xs leading-relaxed text-[hsl(var(--terminal-fg))] selection:bg-muted"
>
<pre className="whitespace-pre-wrap font-inherit">
<Ansi>{rawLog}</Ansi>
</pre>
</div>
);
}Membangun Visual Unified/Split Diff Viewer
Komputasi diff sebaiknya diproses menggunakan pustaka diff (LCS algorithm) dan di-memoize agar tidak dieksekusi ulang saat parent component render.
Komponen Diff Viewer Berbasis CSS Grid
Trik stabilitas layout ada pada pengaturan grid dengan kolom tetap untuk nomor baris (gutter) dan baris kode (wrap-safe):
import { useMemo } from "react";
import * as Diff from "diff";
interface DiffViewerProps {
oldCode: string;
newCode: string;
}
export function DiffViewer({ oldCode, newCode }: DiffViewerProps) {
const changes = useMemo(() => {
return Diff.diffLines(oldCode, newCode);
}, [oldCode, newCode]);
let oldLineCounter = 1;
let newLineCounter = 1;
return (
<div className="overflow-hidden rounded-md border border-border bg-background font-mono text-xs">
<div className="grid grid-cols-[3rem_3rem_1fr] border-b border-border bg-muted/40 px-2 py-1 text-muted-foreground select-none">
<span>Old</span>
<span>New</span>
<span>Content</span>
</div>
<div className="divide-y divide-border/20 overflow-x-auto">
{changes.map((part, partIdx) => {
const lines = part.value.replace(/\n$/, "").split("\n");
return lines.map((line, lineIdx) => {
let rowBg = "bg-transparent";
let textClass = "text-foreground";
let oldNum: number | string = "";
let newNum: number | string = "";
if (part.added) {
rowBg = "bg-[hsl(var(--diff-add-bg))]";
textClass = "text-[hsl(var(--diff-add-fg))]";
newNum = newLineCounter++;
} else if (part.removed) {
rowBg = "bg-[hsl(var(--diff-del-bg))]";
textClass = "text-[hsl(var(--diff-del-fg))]";
oldNum = oldLineCounter++;
} else {
oldNum = oldLineCounter++;
newNum = newLineCounter++;
}
return (
<div
key={`${partIdx}-${lineIdx}`}
className={`grid grid-cols-[3rem_3rem_1fr] px-2 py-0.5 leading-5 hover:bg-muted/30 ${rowBg}`}
>
<span className="text-right pr-3 select-none text-[hsl(var(--diff-gutter))]">
{oldNum}
</span>
<span className="text-right pr-3 select-none text-[hsl(var(--diff-gutter))]">
{newNum}
</span>
<span className={`whitespace-pre-wrap break-all ${textClass}`}>
{part.added ? "+ " : part.removed ? "- " : " "}
{line}
</span>
</div>
);
});
})}
</div>
</div>
);
}Mitigasi Bottleneck dan Best Practices
- Virtualization untuk file besar: Jika baris diff melebihi 1.000 baris, gunakan
@tanstack/react-virtual. Komputasi DOM node ribuan baris tanpa virtualisasi menyebabkan framerate drop drastis. - CLS Prevention: Terapkan
contain-intrinsic-sizepada parent container atau tentukan tinggi minimum menggunakan class Tailwindmin-h-[...]agar layout induk tidak bergeser ketika streaming berjalan. - Web Workers: Lakukan komputasi perbandingan diff pada Web Worker jika diff mencakup file teks di atas 500 KB agar thread interaksi UI tetap berjalan pada 60 FPS.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!