A live `tail -f` log pane with a real ring buffer, a per-line severity glyph column, and follow-mode that detaches the instant you scroll up and reports how many lines arrived while you were reading.
npx shadcn add https://design.helpmarq.com /r/log-viewer-ascii-tail.jsonregistry/core/log-viewer-ascii-tail/component.tsx"use client";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
// ---------------------------------------------------------------------------
// TailPane — a `tail -f` log viewer with a real bounded ring buffer, an ASCII
// severity column and follow-mode that detaches the instant you scroll up.
//
// The three things that make it honest rather than a log-shaped animation:
// 1. memory is bounded — appends overwrite the oldest slot of a fixed
// Array(capacity); nothing grows without limit, however long it runs.
// 2. the stream is deterministic — one LCG drives both the inter-line delay
// and the severity draw, so cadence jitters like real traffic instead of
// ticking, and the same seed always replays the same log.
// 3. appends never render per-line — they land in a ref and flush to React
// through ONE rAF-coalesced setState per frame, and only the rows inside
// the viewport are in the DOM (fixed 18px rows, absolute positioning).
// ---------------------------------------------------------------------------
export type LogSeverity = "DEBUG" | "INFO" | "WARN" | "ERROR";
export type LogLine = {
/** monotonically increasing sequence number; also its ring slot (seq % capacity) */
seq: number;
/** pre-formatted HH:MM:SS.mmm wall clock */
time: string;
sev: LogSeverity;
service: string;
message: string;
};
export interface LogViewerAsciiTailProps {
/** ring buffer size — appends past this overwrite the oldest line. Default 2000. */
capacity?: number;
/** LCG seed; the same seed replays the same stream exactly. Default 20240804. */
seed?: number;
/** lines synthesised before the stream starts, so the pane is dense at rest. Default 200. */
initialLines?: number;
/** service column values drawn from. */
services?: string[];
className?: string;
"aria-label"?: string;
}
const ROW_H = 18; // px — fixed, which is what makes windowing trivial
const OVERSCAN = 6; // rows rendered beyond the viewport, each side
const NEAR_BOTTOM = 8; // px — inside this, follow re-attaches
const SERVICE_W = 11; // chars, padded so the pipes form true vertical rules
const LEVELS: LogSeverity[] = ["DEBUG", "INFO", "WARN", "ERROR"];
const LEVEL_RANK: Record<LogSeverity, number> = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
const GLYPH: Record<LogSeverity, string> = {
DEBUG: "·", // ·
INFO: "─", // ─
WARN: "≡", // ≡
ERROR: "█", // █
};
// glyph AND weight together, never hue alone. The faintest ink is a muted
// tint, NOT `text-border` — --border is tuned for 1px hairlines and is
// invisible as type on the light theme (#ebebeb on #ffffff).
const FAINT = "text-muted/45";
const SEV_INK: Record<LogSeverity, string> = {
DEBUG: FAINT,
INFO: "text-muted",
WARN: "text-foreground font-medium",
ERROR: "text-accent font-semibold",
};
const DEFAULT_SERVICES = [
"api-gateway",
"auth-worker",
"orders-svc",
"ledger-sync",
"edge-cache",
"search-idx",
];
const MESSAGES: Record<LogSeverity, string[]> = {
DEBUG: [
"cache hit ratio 0.94 over 512 keys",
"span emitted trace=9f2a1c parent=3bd881",
"pool idle 12/32 connections",
"heartbeat ack in 4ms",
"resolved 3 dns records ttl=30s",
"compacted 18 segments into 6",
"token introspection cached 240s",
],
INFO: [
"GET /v2/orders 200 in 38ms",
"POST /v2/checkout 201 in 112ms",
"session refreshed for user 84213",
"published 128 events to orders.v2",
"shard 4 rebalanced in 1.8s",
"index snapshot written 41MB",
"GET /health 200 in 2ms",
"invoice 4471 settled in 74ms",
],
WARN: [
"retry 2/5 after connection reset",
"queue depth 8420 above soft limit",
"p99 latency 812ms over budget",
"clock skew 340ms against peer 3",
"rate limit 80% consumed for key ak_19",
],
ERROR: [
"upstream 502 after 3 retries",
"deadline exceeded on shard 7",
"checksum mismatch on segment 41",
"connection refused to ledger:5432",
],
};
// deterministic LCG — one stream drives delay, severity, service and message
function nextSeed(s: number): number {
return (s * 1664525 + 1013904223) >>> 0;
}
function formatClock(msOfDay: number): string {
const ms = Math.floor(msOfDay) % 86_400_000;
const h = Math.floor(ms / 3_600_000);
const m = Math.floor(ms / 60_000) % 60;
const s = Math.floor(ms / 1000) % 60;
const f = Math.floor(ms) % 1000;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(f).padStart(3, "0")}`;
}
function drawSeverity(r: number): LogSeverity {
if (r < 0.42) return "DEBUG";
if (r < 0.82) return "INFO";
if (r < 0.95) return "WARN";
return "ERROR";
}
/** splits a message around every case-insensitive occurrence of `q` */
function inkMatches(message: string, q: string) {
if (!q) return [{ text: message, hit: false }];
const out: { text: string; hit: boolean }[] = [];
const hay = message.toLowerCase();
const needle = q.toLowerCase();
let i = 0;
for (;;) {
const at = hay.indexOf(needle, i);
if (at === -1) break;
if (at > i) out.push({ text: message.slice(i, at), hit: false });
out.push({ text: message.slice(at, at + needle.length), hit: true });
i = at + needle.length;
}
if (i < message.length) out.push({ text: message.slice(i), hit: false });
return out;
}
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
export function LogViewerAsciiTail({
capacity = 2000,
seed = 20240804,
initialLines = 200,
services = DEFAULT_SERVICES,
className = "",
"aria-label": ariaLabel = "Application log, live tail",
}: LogViewerAsciiTailProps) {
// an inline `services={[...]}` prop would otherwise be a new array every
// render, re-creating appendOne and re-running the seed effect forever;
// rebuilding from a joined key makes the identity depend on the CONTENT
const serviceKey = services.join("|");
const serviceList = useMemo(() => serviceKey.split("|"), [serviceKey]);
// --- ring buffer ---------------------------------------------------------
const ringRef = useRef<(LogLine | undefined)[]>([]);
const totalRef = useRef(0); // total lines ever appended; head slot = total % capacity
const rngRef = useRef(seed >>> 0);
const clockRef = useRef(43_471_882); // 12:04:31.882, a fixed start so SSR/CSR agree
// --- flush plumbing (one setState per frame, never one per line) ----------
const [tick, setTick] = useState(0);
const rafRef = useRef(0);
const newRef = useRef(0);
const [newCount, setNewCount] = useState(0);
// --- follow mode ---------------------------------------------------------
const paneRef = useRef<HTMLDivElement>(null);
const followRef = useRef(true);
const [follow, setFollow] = useState(true);
// --- windowing -----------------------------------------------------------
const [range, setRange] = useState({ start: 0, end: 60 });
// --- filters -------------------------------------------------------------
const [minLevel, setMinLevel] = useState<LogSeverity>("DEBUG");
const [query, setQuery] = useState("");
// --- motion / lifecycle --------------------------------------------------
const [reduced, setReduced] = useState(false);
const [running, setRunning] = useState(true);
const runningRef = useRef(true);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scheduleFlush = useCallback(() => {
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0;
setNewCount(newRef.current);
setTick((t) => t + 1);
});
}, []);
/** synthesise exactly one line and write it into its ring slot */
const appendOne = useCallback(() => {
let s = rngRef.current;
s = nextSeed(s);
const delay = 90 + (s / 4_294_967_296) * 260;
clockRef.current += delay;
s = nextSeed(s);
const sev = drawSeverity(s / 4_294_967_296);
s = nextSeed(s);
const service =
serviceList[Math.floor((s / 4_294_967_296) * serviceList.length)] ?? serviceList[0]!;
s = nextSeed(s);
const pool = MESSAGES[sev];
const message = pool[Math.floor((s / 4_294_967_296) * pool.length)] ?? pool[0]!;
rngRef.current = s;
const seq = totalRef.current;
ringRef.current[seq % capacity] = {
seq,
time: formatClock(clockRef.current),
sev,
service,
message,
};
totalRef.current = seq + 1;
if (!followRef.current) newRef.current += 1;
return delay;
}, [capacity, serviceList]);
// reduced motion
useEffect(() => {
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => {
setReduced(mql.matches);
if (mql.matches) {
setRunning(false);
runningRef.current = false;
}
};
update();
mql.addEventListener("change", update);
return () => mql.removeEventListener("change", update);
}, []);
// seed the buffer once — the pane is dense and legible before any motion
useEffect(() => {
ringRef.current = new Array(capacity);
totalRef.current = 0;
rngRef.current = seed >>> 0;
clockRef.current = 43_471_882;
for (let i = 0; i < initialLines; i++) appendOne();
newRef.current = 0;
setNewCount(0);
setTick((t) => t + 1);
}, [capacity, seed, initialLines, appendOne]);
// the stream: a self-rescheduling timeout whose delay is drawn from the LCG,
// paused while the tab is hidden and while `running` is false
useEffect(() => {
runningRef.current = running;
if (!running) return;
let cancelled = false;
// document.hidden truly stops the generator (no polling); visibilitychange
// restarts it, so a backgrounded tab costs nothing.
const step = () => {
if (cancelled || document.hidden) {
timerRef.current = null;
return;
}
const delay = appendOne();
scheduleFlush();
timerRef.current = setTimeout(step, delay);
};
timerRef.current = setTimeout(step, 120);
const onVis = () => {
if (!document.hidden && !cancelled && timerRef.current === null) {
timerRef.current = setTimeout(step, 120);
}
};
document.addEventListener("visibilitychange", onVis);
return () => {
cancelled = true;
document.removeEventListener("visibilitychange", onVis);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
};
}, [running, appendOne, scheduleFlush]);
useEffect(() => () => cancelAnimationFrame(rafRef.current), []);
// --- derived views -------------------------------------------------------
const lines = useMemo(() => {
const total = totalRef.current;
const from = Math.max(0, total - capacity);
const out: LogLine[] = [];
for (let i = from; i < total; i++) {
const l = ringRef.current[i % capacity];
if (l) out.push(l);
}
return out;
// tick is the flush signal; the data itself lives in refs
}, [tick, capacity]);
const rows = useMemo(() => {
const min = LEVEL_RANK[minLevel];
const q = query.trim().toLowerCase();
return lines.filter(
(l) =>
LEVEL_RANK[l.sev] >= min &&
(q === "" ||
l.message.toLowerCase().includes(q) ||
l.service.toLowerCase().includes(q))
);
}, [lines, minLevel, query]);
// --- scroll plumbing -----------------------------------------------------
const measure = useCallback(() => {
const el = paneRef.current;
if (!el) return;
const start = Math.max(0, Math.floor(el.scrollTop / ROW_H) - OVERSCAN);
const end = Math.ceil((el.scrollTop + el.clientHeight) / ROW_H) + OVERSCAN;
setRange((r) => (r.start === start && r.end === end ? r : { start, end }));
}, []);
const attach = useCallback(() => {
followRef.current = true;
newRef.current = 0;
setNewCount(0);
setFollow(true);
const el = paneRef.current;
if (el) el.scrollTop = el.scrollHeight;
measure();
}, [measure]);
const onScroll = useCallback(() => {
const el = paneRef.current;
if (!el) return;
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distance > NEAR_BOTTOM) {
if (followRef.current) {
followRef.current = false;
setFollow(false);
}
} else if (!followRef.current) {
followRef.current = true;
newRef.current = 0;
setNewCount(0);
setFollow(true);
}
measure();
}, [measure]);
// pin to the bottom while following — after every flush and every filter change
useIsoLayoutEffect(() => {
const el = paneRef.current;
if (!el) return;
if (followRef.current) el.scrollTop = el.scrollHeight;
measure();
}, [rows, measure]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
const el = paneRef.current;
if (!el) return;
if (e.key === "End") {
e.preventDefault();
attach();
} else if (e.key === "Home") {
e.preventDefault();
el.scrollTop = 0;
} else if (e.key === "ArrowDown") {
e.preventDefault();
el.scrollTop += ROW_H;
} else if (e.key === "ArrowUp") {
e.preventDefault();
el.scrollTop -= ROW_H;
} else if (e.key === "PageDown") {
e.preventDefault();
el.scrollTop += el.clientHeight - ROW_H;
} else if (e.key === "PageUp") {
e.preventDefault();
el.scrollTop -= el.clientHeight - ROW_H;
}
};
// roving tabindex: selecting with the arrow keys must MOVE focus too,
// otherwise the ring is left behind on a button that is now tabIndex=-1
const radioRefs = useRef<Partial<Record<LogSeverity, HTMLButtonElement | null>>>({});
const moveLevel = (to: LogSeverity) => {
setMinLevel(to);
radioRefs.current[to]?.focus();
};
const onLevelKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
const i = LEVELS.indexOf(minLevel);
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
moveLevel(LEVELS[Math.min(LEVELS.length - 1, i + 1)]!);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
moveLevel(LEVELS[Math.max(0, i - 1)]!);
} else if (e.key === "Home") {
e.preventDefault();
moveLevel(LEVELS[0]!);
} else if (e.key === "End") {
e.preventDefault();
moveLevel(LEVELS[LEVELS.length - 1]!);
}
};
const visible = rows.slice(range.start, range.end);
const inputId = "log-tail-filter";
return (
<div
className={`flex h-full min-h-0 flex-col overflow-hidden rounded-md border border-border bg-background font-mono text-xs ${className}`}
>
{/* header: the level control doubles as the glyph legend */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border px-3 py-2">
<span className="text-muted">
tail -f <span className="text-foreground">/var/log/edge.log</span>
</span>
<div
role="radiogroup"
aria-label="Minimum severity"
onKeyDown={onLevelKeyDown}
className="flex items-center overflow-hidden rounded-sm border border-border"
>
{LEVELS.map((lv) => {
const on = lv === minLevel;
return (
<button
key={lv}
ref={(node) => {
radioRefs.current[lv] = node;
}}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => setMinLevel(lv)}
className={`px-2 py-1 whitespace-pre transition-colors duration-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent ${
on ? "bg-surface text-foreground" : "text-muted hover:text-foreground"
}`}
>
<span className={on ? "" : SEV_INK[lv]}>{GLYPH[lv]}</span> {lv}
</button>
);
})}
</div>
<div className="flex items-center gap-2">
<label htmlFor={inputId} className="text-muted">
grep
</label>
<input
id={inputId}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="502"
spellCheck={false}
className="w-28 rounded-sm border border-border bg-surface px-2 py-1 font-mono text-xs text-foreground placeholder:text-muted/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
/>
</div>
<span className="ml-auto tabular-nums text-muted">
{rows.length}/{lines.length} lines · ring {capacity}
</span>
</div>
<div className="relative flex min-h-0 flex-1">
{/* the log pane */}
<div
ref={paneRef}
data-log-pane
role="log"
aria-live="off"
aria-label={ariaLabel}
tabIndex={0}
onScroll={onScroll}
onKeyDown={onKeyDown}
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
>
<div className="relative w-full" style={{ height: rows.length * ROW_H }}>
{visible.map((l, i) => (
<div
key={l.seq}
className="absolute inset-x-0 flex items-center whitespace-pre px-3 text-muted transition-colors duration-100 hover:bg-surface hover:text-foreground"
style={{ top: (range.start + i) * ROW_H, height: ROW_H }}
>
<span className="tabular-nums">{l.time}</span>
<span className={FAINT}>{" │ "}</span>
<span className={SEV_INK[l.sev]}>{GLYPH[l.sev]}</span>
<span className={FAINT}>{" │ "}</span>
<span>{l.service.padEnd(SERVICE_W)}</span>
<span className={FAINT}>{" │ "}</span>
<span>
{inkMatches(l.message, query.trim()).map((seg, k) => (
<span key={k} className={seg.hit ? "text-accent" : undefined}>
{seg.text}
</span>
))}
</span>
</div>
))}
</div>
</div>
{/* detach bar — the count of what arrived while you were reading */}
{!follow && (
<button
type="button"
onClick={attach}
className="absolute inset-x-0 bottom-0 border-t border-border bg-surface py-1.5 text-center whitespace-pre text-muted transition-colors duration-100 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
>
{"└─ "}
<span className="tabular-nums text-accent">{newCount}</span>
{` new line${newCount === 1 ? "" : "s"} ─ End to follow ─┘`}
</button>
)}
</div>
{reduced && (
<div className="flex items-center justify-between gap-3 border-t border-border px-3 py-2">
<span className="text-muted">
{running
? `stream running — ${lines.length} lines in the ring`
: `stream paused — ${lines.length} lines loaded`}
</span>
<button
type="button"
onClick={() => setRunning((r) => !r)}
className="rounded-sm border border-border px-2 py-1 whitespace-pre text-muted transition-colors duration-100 hover:border-muted hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
>
{running ? "■ pause stream" : "▸ resume stream"}
</button>
</div>
)}
</div>
);
}
Pick log-viewer-ascii-tail when the subject is an unbounded, still-arriving log stream that a person needs to read while it moves — it is the only component here with a bounded ring buffer, windowed row rendering, and a follow/detach contract that counts what arrived while you were scrolled up. Reach for progress-telegraph-log instead when the lines are the finite, ordered sub-steps of one operation with per-step elapsed cost, not an open-ended firehose; reach for streaming-token-settle when the thing arriving is model output rather than service telemetry.
Build <LogViewerAsciiTail capacity? seed? initialLines? services? className? aria-label?> as a Geist Mono `tail -f` pane whose honesty is structural rather than decorative. RING BUFFER: a ref-held `Array(capacity)` (default 2000) with a monotonic `total` counter; a line's ring slot is `seq % capacity`, so an append overwrites the oldest slot and memory is bounded no matter how long the stream runs — there is no growing array anywhere on the path. The ordered view is read back from `max(0, total-capacity)` to `total`. STREAM: one deterministic LCG, `s = (s * 1664525 + 1013904223) >>> 0`, drives everything, so a given `seed` replays byte-identically: draw one for the inter-line delay `90 + (s/2^32)*260` ms (cadence therefore jitters like real traffic instead of ticking on a fixed interval, and the same draw advances a virtual wall clock so timestamps agree with the delays), one for the severity from a weighted table (DEBUG 0.42, INFO 0.40, WARN 0.13, ERROR 0.05), one for the service, one for the message. The generator is a self-rescheduling setTimeout, never setInterval. FLUSH: appends land in the ref and are coalesced into ONE rAF-scheduled setState per frame — a burst of lines inside a single frame costs a single render, never one render per line. RENDERING: rows are a fixed 18px tall and absolutely positioned inside a spacer of `rows.length * 18`, and only the rows intersecting the viewport (plus 6 of overscan each side) are in the DOM, so a full 2000-line buffer is a few dozen nodes. LINE FORMAT: `12:04:31.882 │ · │ api-gateway │ upstream 502 after 3 retries` — the service column is padEnd'd to 11 chars and the separators are drawn in `text-muted/45`, so the pipes read as true faint vertical rules down the pane (NOT `text-border` — `--border` is tuned for 1px hairlines and is invisible as type on the light theme). SEVERITY COLUMN: glyph AND weight together, never hue alone — DEBUG `·` in `text-muted/45`, INFO `─` in `text-muted`, WARN `≡` in `text-foreground font-medium`, ERROR `█` in `text-accent font-semibold`. The four-glyph column is the component's whole visual texture at rest: a mostly quiet field of `·` and `─` with the occasional accent `█` standing out, which is what makes an error burst findable without reading a word. FOLLOW: while following, every flush sets `scrollTop = scrollHeight`; the scroll handler drops follow the moment `scrollHeight - scrollTop - clientHeight > 8`, and a sticky bottom button renders `└─ 37 new lines ─ End to follow ─┘` with the count (inked in `text-accent`) of appends since detach. Clicking that bar, pressing End, or scrolling back inside 8px re-attaches and zeroes the counter — the counter is the component's actual promise: nothing scrolled past you unannounced. Home/ArrowUp/ArrowDown/PageUp/PageDown also scroll the pane from the keyboard. There is no minimap, gutter or density index of any kind — the severity column and the line count are the whole read. FILTER: a `role=radiogroup` minimum-level segmented control with roving tabindex and Arrow/Home/End navigation (selecting with the keyboard also MOVES focus to the newly checked radio, so the ring never strands on a now-`tabIndex=-1` button) — each option shows its own severity glyph, so the control doubles as the legend for the column — plus a substring `input[type=search]` matched against message and service; matches are re-inked to `text-accent` in place by splitting the message around each occurrence. Both re-derive the visible set straight from the ring buffer, and the header shows `visible/total lines · ring N`. ACCESSIBILITY: the pane is `role=log` with an `aria-label`, tabIndex=0, and an explicit `aria-live="off"` — a live region emitting ten lines a second is unusable with a screen reader, so announcement is deliberately opt-out and the pane is navigable as a focusable scroll region instead. Every focusable control (pane, level radios, filter input, the detach bar) carries a visible `focus-visible` accent outline. LIFECYCLE: `document.hidden` stops the generator entirely (the timeout is not rescheduled) and `visibilitychange` restarts it. REDUCED MOTION: `prefers-reduced-motion: reduce` seeds the same `initialLines` (default 200) of real data and then does not start the generator at all, rendering a `▸ resume stream` toggle instead — the data on screen is honest, only the motion is opt-in. THEME: every colour is a token (`--background --foreground --muted --border --surface --accent`); there is no hex anywhere, no canvas, and no dependencies. Row hover raises a line from `text-muted` to `text-foreground` over a `bg-surface` chip in 100ms and relaxes back on pointer-out.