Agent terminal
A terminal streams an agent run: live ticking time and token counters, steps that check off, then a diff summary.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const PROMPT = 'agent "add rate limiting to the upload route"';
const STEPS = [
{ text: "Read src/routes/upload.ts" },
{ text: 'Searched 18 files for "rateLimit"' },
{ text: "Edited src/middleware/rate-limit.ts", add: 31, del: 2 },
{ text: "Edited src/routes/upload.ts", add: 11, del: 5 },
{ text: "Ran npm test: 48 passed" },
];
// Real milliseconds: the run starts after the prompt is typed, each step appears then checks off.
const START = 1100, GAP = 1400, BUSY = 1100;
const EVENTS = [START, ...STEPS.flatMap((_, i) => [START + 300 + i * GAP, START + 300 + i * GAP + BUSY])];
const END = EVENTS[EVENTS.length - 1] + 200;
EVENTS.push(END, END + 3800);
// What the counters read when the run ends.
const SECONDS = 263, TOKENS = 9200;
const FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
const clock = (s: number) => (s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`);
const tokens = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`);
// Tokens arrive in bursts: p minus a sine keeps the curve rising but uneven.
const counters = (p: number) =>
`(${clock(Math.round(SECONDS * p))}, ${tokens(Math.round(TOKENS * (p - Math.sin(10 * Math.PI * p) / (10 * Math.PI))))} tokens)`;
const Check = () => (
<svg className={styles.check} viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" /></svg>
);
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const count = useRef<HTMLSpanElement>(null);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const [k, setK] = useState(0);
const [run, setRun] = useState(0);
const [stopped, setStopped] = useState(false);
const shown = reduced ? EVENTS.length - 1 : k;
const working = shown >= 1 && shown < EVENTS.length - 1;
// One timeout per event; the effect re-runs on each step, so pausing off screen just skips scheduling.
useEffect(() => {
if (reduced || !inView) return;
if (stopped) {
const timer = setTimeout(() => { setStopped(false); setK(0); setRun((r) => r + 1); }, 2600);
return () => clearTimeout(timer);
}
const wait = EVENTS[k] - (k ? EVENTS[k - 1] : 0);
const timer = setTimeout(() => {
if (k === EVENTS.length - 1) { setK(0); setRun((r) => r + 1); }
else setK(k + 1);
}, wait);
return () => clearTimeout(timer);
}, [k, inView, reduced, stopped]);
// While working, a frame loop ticks the counters and every spinner glyph through refs.
useEffect(() => {
const el = root.current;
const out = count.current;
if (!el || !out || reduced || !working || stopped || !inView) return;
const base = performance.now() - (EVENTS[k - 1] - START);
let raf = 0, frame = -1;
const tick = (now: number) => {
const p = Math.min(1, (now - base) / (END - START));
out.textContent = counters(p);
const f = Math.floor(now / 80) % FRAMES.length;
if (f !== frame) {
frame = f;
el.querySelectorAll("[data-spin]").forEach((s) => { s.textContent = FRAMES[f]; });
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [k, working, inView, reduced, stopped]);
const interrupt = () => { if (working && !stopped) setStopped(true); };
const replay = () => { setStopped(false); setK(0); setRun((r) => r + 1); };
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") interrupt(); };
const done = shown === EVENTS.length - 1;
return (
<div ref={root} className={styles.root} onKeyDown={onKey}>
<section className={styles.window} aria-label="Agent run in a terminal">
<header className={styles.bar}>
<span className={styles.dots} aria-hidden="true"><i /><i /><i /></span>
<p className={styles.title}>acme-api: agent</p>
{!reduced && (
<button type="button" className={styles.replay} onClick={replay} aria-label="Replay the run">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8a5 5 0 1 0 1.6-3.7M3 2.5v3h3" /></svg>
</button>
)}
</header>
<div className={styles.body} key={run}>
<p className={styles.cwd}>~/acme-api <span>main</span></p>
<p className={styles.line}>
<span className={styles.caret} aria-hidden="true">{">"}</span>
<span className={styles.prompt} style={{ "--n": PROMPT.length } as CSSProperties}>{PROMPT}</span>
</p>
{shown >= 1 && (
<p className={styles.status} data-state={stopped ? "stopped" : done ? "done" : "working"} aria-live="polite">
<span className={styles.glyph} aria-hidden="true">{done ? <Check /> : stopped ? "x" : <span data-spin>{FRAMES[0]}</span>}</span>
<span className={styles.word}>{done ? "Done" : stopped ? "Interrupted" : "Working"}</span>
<span ref={count} className={styles.count}>{counters(done ? 1 : 0)}</span>
{working && !stopped && (
<button type="button" className={styles.esc} onClick={interrupt}>
<kbd>esc</kbd> to interrupt
</button>
)}
</p>
)}
<ul className={styles.steps}>
{STEPS.map((s, i) => {
if (shown < 2 + 2 * i) return null;
const finished = shown >= 3 + 2 * i;
const state = finished ? "done" : stopped ? "stopped" : "busy";
return (
<li key={s.text} className={styles.step} data-state={state}>
<span className={styles.glyph} aria-hidden="true">
{state === "done" ? <Check /> : state === "stopped" ? "x" : <span data-spin>{FRAMES[0]}</span>}
</span>
<span className={styles.text}>{s.text}</span>
{s.add !== undefined && finished && (
<span className={styles.diff}><b>+{s.add}</b> <s>-{s.del}</s></span>
)}
</li>
);
})}
</ul>
{done && (
<p className={styles.summary}>
2 files changed <b>+42</b> <s>-7</s>
</p>
)}
</div>
</section>
</div>
);
}
demo.module.css
.root { container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 16px; box-sizing: border-box; background: var(--surface); }
.window {
display: grid; grid-template-rows: auto minmax(0, 1fr); width: min(600px, 100%); height: min(100%, 340px); overflow: hidden;
border-radius: 14px; border: 1px solid var(--line); background: var(--white);
box-shadow: 0 18px 40px -28px color-mix(in srgb, var(--ink) 55%, transparent);
}
.bar { position: relative; display: flex; align-items: center; gap: 10px; height: 38px; padding: 0 6px 0 14px; border-bottom: 1px solid var(--line); background: var(--surface); }
.dots { display: flex; gap: 6px; }
.dots i { width: 10px; height: 10px; border-radius: 50%; background: var(--line); }
.title { position: absolute; left: 50%; translate: -50% 0; margin: 0; font-size: 12px; font-weight: 600; color: var(--muted); white-space: nowrap; }
.replay {
margin-left: auto; display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 8px;
background: transparent; color: var(--muted); cursor: pointer; transition: background-color .2s, color .2s, transform .18s var(--out);
}
.replay:hover { background: var(--line); color: var(--ink); }
.replay:active { transform: rotate(-40deg); }
.replay:focus-visible, .esc:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 1px; }
.replay svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.body {
display: flex; flex-direction: column; gap: 3px; padding: 14px 16px; min-width: 0; overflow: hidden;
font: 500 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--ink);
}
.body p { margin: 0; }
.cwd { color: var(--muted); font-size: 12px; }
.cwd span { color: var(--accent-text); }
.line { display: flex; gap: 1ch; min-width: 0; margin-bottom: 4px !important; }
.caret { color: var(--accent-text); font-weight: 700; }
/* The prompt types itself: its width grows one character at a time. */
.prompt { display: block; overflow: hidden; white-space: nowrap; text-overflow: clip; max-width: calc(var(--n) * 1ch); animation: type .9s steps(45, end) both; }
@keyframes type { from { max-width: 0; } }
.status, .step { display: flex; align-items: center; gap: 1ch; min-width: 0; animation: line-in .3s var(--out); }
@keyframes line-in { from { opacity: 0; translate: 0 4px; } }
.glyph { flex: none; display: grid; place-items: center; width: 2ch; color: var(--accent-text); }
.glyph [data-spin] { font-size: 1.25em; line-height: 1; font-weight: 700; }
.check { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 16; animation: draw .35s var(--out) both; }
@keyframes draw { from { stroke-dashoffset: 16; } to { stroke-dashoffset: 0; } }
.word { font-weight: 700; }
.status[data-state="working"] .word {
background: linear-gradient(90deg, var(--ink) 30%, var(--accent) 50%, var(--ink) 70%) 0 0 / 300% 100%;
-webkit-background-clip: text; background-clip: text; color: transparent; animation: shine 1.8s linear infinite;
}
@keyframes shine { from { background-position: 100% 0; } to { background-position: 0 0; } }
.status[data-state="stopped"] .glyph, .step[data-state="stopped"] .glyph { color: var(--muted); }
.status[data-state="stopped"] .word { color: var(--muted); }
.count { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
.esc {
margin-left: auto; flex: none; display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 8px; border: 0; border-radius: 8px;
background: transparent; color: var(--muted); font: inherit; font-size: 11px; cursor: pointer; white-space: nowrap;
}
.esc:hover { background: var(--surface); color: var(--ink); }
.esc kbd { padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); background: var(--surface); font: inherit; }
.steps { list-style: none; margin: 0; padding: 0 0 0 3ch; display: grid; gap: 3px; }
.step { color: var(--ink); }
.step[data-state="busy"] .text { color: var(--ink); }
.step[data-state="done"] .text { color: var(--muted); }
.text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.diff, .summary { flex: none; white-space: nowrap; font-variant-numeric: tabular-nums; }
.diff { margin-left: auto; padding-left: 1ch; }
.diff b, .summary b { color: var(--accent-text); font-weight: 700; }
.diff s, .summary s { color: var(--muted); text-decoration: none; }
.summary { margin-top: 4px !important; padding: 6px 10px; align-self: flex-start; border-radius: 8px; background: var(--accent-pale); color: var(--ink); animation: line-in .35s var(--out); }
@container (max-width: 440px) {
.root { padding: 10px; }
.body { font-size: 11.5px; padding: 12px; }
.esc { display: none; }
.title { display: none; }
}
@container (max-height: 300px) {
.root { padding: 8px; }
.bar { height: 32px; }
.replay { width: 28px; height: 28px; }
.body { padding-block: 8px; gap: 1px; line-height: 1.45; }
.cwd { display: none; }
.steps { gap: 1px; }
.summary { padding: 2px 8px; }
}
@media (prefers-reduced-motion: reduce) {
.prompt, .status, .step, .check, .summary { animation: none; }
.status .word { animation: none; }
}
Use it for
- Developer tools and AI coding agents showing a run end to end
- Hero sections where the product lives in the command line
- Docs pages that explain what a CLI prints while it works
Avoid
- Real logs, where timing and output come from the process and not a script
- Long scripts: past six or seven steps the loop gets slow to read
How it works
A list of event times drives one index: each timeout reveals the next line or turns a running step into a drawn check, and the last event restarts the run under a new key so the prompt types itself again. While the agent works, one requestAnimationFrame loop writes the elapsed time, the token count and every braille spinner glyph through refs. Tokens follow p minus a small sine so they arrive in uneven bursts but never count down.
Accessibility
The whole run is real text, the status line is a polite live region and Replay and interrupt are real buttons (Escape also interrupts). The loop pauses off screen. Reduced motion skips the script and shows the finished run with its diff summary.