43 / Code
Terminal Output
Streaming command output with stderr called out, an exit code, and scroll that follows without trapping you.
pnpm install mischief-uiRunning"use client"
import * as React from "react"
import { DemoVariants } from "@/components/demos/demo-variants"
import { RestartButton } from "@/components/demos/restart-button"
import {
TerminalOutput,
type TerminalLine,
} from "mischief-ui/terminal-output"
const script: TerminalLine[] = [
{ text: "Packages: +214" },
{ text: "Progress: resolved 214, reused 210, downloaded 4" },
{ text: "" },
{ text: "dependencies:" },
{ text: "+ mischief-ui 0.7.1" },
{ text: "" },
{ text: "Done in 1.8s" },
]
const failure: TerminalLine[] = [
{
text: "src/total.ts(4,17): error TS2554: Expected 1 arguments, but got 2.",
stream: "stderr",
},
{ text: "", stream: "stderr" },
{ text: "Found 1 error in src/total.ts", stream: "stderr" },
]
function LiveRun() {
const [count, setCount] = React.useState(0)
const [runId, setRunId] = React.useState(0)
React.useEffect(() => {
if (count >= script.length) return
const timer = setTimeout(() => setCount((shown) => shown + 1), 420)
return () => clearTimeout(timer)
}, [count, runId])
const done = count >= script.length
return (
<div className="grid gap-4">
<TerminalOutput
command="pnpm install mischief-ui"
cwd="~/projects/shop"
output={script.slice(0, count)}
running={!done}
exitCode={done ? 0 : undefined}
maxHeight="11rem"
/>
{done ? (
<RestartButton
onClick={() => {
setCount(0)
setRunId((id) => id + 1)
}}
/>
) : null}
</div>
)
}
export function TerminalOutputDemo() {
return (
<DemoVariants
label="Command run"
variants={[
{ id: "live", label: "Streaming", render: () => <LiveRun /> },
{
id: "failed",
label: "Failed",
render: () => (
<TerminalOutput
command="pnpm typecheck"
output={failure}
exitCode={2}
maxHeight="11rem"
/>
),
},
]}
/>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/terminal-outputimport { TerminalOutput } from "mischief-ui/terminal-output"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { ChevronRight, Loader2 } from "lucide-react" import { cn } from "@/lib/utils" export type TerminalStream = "stdout" | "stderr" export type TerminalLine = { text: string stream?: TerminalStream} Usage
export function Install() {
return (
<TerminalOutput
command="pnpm install"
output={lines}
running={pending}
exitCode={code}
/>
)
}Streaming output
Append to the array as lines arrive and the log grows. Keep running true until the command settles, then pass its exit code -- that is what turns the running indicator into a result.
const [lines, setLines] = useState<TerminalLine[]>([])
const [exitCode, setExitCode] = useState<number>()
for await (const chunk of process.stdout) {
setLines((current) => [...current, { text: chunk }])
}Send stderr through with stream set, rather than merging both into one string, so failures stay distinguishable after the fact.
Following, and when it stops
The log sticks to the newest line while it is already at the bottom. The moment the reader scrolls up it stops following, and it resumes when they come back within a couple of dozen pixels of the end. Reading back through output is therefore never interrupted by more of it arriving.
ANSI escape sequences are stripped rather than rendered, so colour codes from a shell do not appear as noise. Colour is not reconstructed: stderr is distinguished, and nothing else is.
API
outputstring | (TerminalLine | string)[]A plain string is split on newlines as stdout.commandstringThe command that produced the output, shown above it.cwdstringWorking directory, shown beside the command on wider screens.runningbooleanShows the running indicator and marks the log busy.exitCodenumberShown once settled. Anything other than zero reads as a failure.maxHeightnumber | stringHeight before the log scrolls. Defaults to "18rem".followbooleanKeeps the newest line in view. Defaults to true.TerminalLine
textstringOne line, without its ending. ANSI escapes are stripped.stream"stdout" | "stderr"Which stream it came from. Defaults to "stdout".Accessibility
Output is a log region marked busy while the command runs, so assistive technology reads new lines without the page stealing focus. stderr is distinguished by a data attribute as well as colour. Following is abandoned the moment the reader scrolls up and resumes when they return to the bottom, so reading back is never interrupted. ANSI escape sequences are stripped rather than rendered.