a phosphor terminal that plays a session script, live or as a still
npx shadcn add @ag/terminalAlready installed? Preview an update
npx shadcn add @ag/terminal --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-17
- A session taller than its window scrolls as a terminal does: the newest line stays at the screen's foot and the oldest leave at its top. A window sized by its whole session (no frame of its own) never overflows, so it is unchanged. Before, a window given a size cut the newest lines off.
also called console
shelf devices & demos
comes with terminal-session
uses playback
used by terminal-editor
the demo above, verbatim
"use client"
import { Sample } from "@/app/samples"
import {
parseSession,
renderSessionSvg,
sessionTimeline,
} from "@/registry/base-nova/lib/terminal-session"
import { Image } from "@/registry/base-nova/ui/image"
import { Player } from "@/registry/base-nova/ui/player"
import { Terminal } from "@/registry/base-nova/ui/terminal"
// #region session
const SESSION = `$ trash thesis-draft.txt
trashed: ~/Desktop/thesis-draft.txt
@700 $ trash list
thesis-draft.txt 2026-08-20 14:27 ~/Desktop/thesis-draft.txt
@700 $ trash restore thesis-draft.txt
restored: ~/Desktop/thesis-draft.txt
~ 0 items left`
const ACCENT = "#e7a13c"
// The session's clip: one chapter per command, played by the player around it.
const CLIP = sessionTimeline(parseSession(SESSION)).clip
// #endregion
// #region colours
// A CLI's own colours, as it prints them: ANSI escapes in the output lines.
const sgr = (codes: string, s: string) => `\x1b[${codes}m${s}\x1b[0m`
const COLOURED = `$ trash thesis-draft.txt
${sgr("32", "✓")} trashed ${sgr("1", "thesis-draft.txt")} ${sgr("2", "from ~/Desktop")}
@700 $ trash restore notes.md
${sgr("31", "✗")} ${sgr("1", "notes.md")} is not in the trash ${sgr("2", "· trash list")}`
const COLOURED_CLIP = sessionTimeline(parseSession(COLOURED)).clip
// #endregion
export default function Demo() {
// The same script, drawn twice: live in the DOM, and as an SVG still by the
// renderer in terminal-session. One source, so a feature card and the page it
// links to can never disagree about what the terminal did.
const still = renderSessionSvg(parseSession(SESSION), { accent: ACCENT })
return (
<div className="space-y-8">
<div className="grid gap-8 lg:grid-cols-2">
<Sample
name="live"
glance="large"
title="live · terminal in a player"
with="session"
>
<Player clip={CLIP} label="trash, list, restore">
<Terminal
session={SESSION}
accent={ACCENT}
rows={12}
alt="A terminal session: trash, list, restore."
/>
</Player>
</Sample>
<Sample
name="still"
title="still · terminal-session"
description="the same script as an SVG, drawn without React"
>
<Image
alt="The same session rendered as a still."
className="w-full rounded-xl border border-border"
src={`data:image/svg+xml;utf8,${encodeURIComponent(still)}`}
/>
</Sample>
</div>
<Sample
name="colours"
title="a CLI's own colours"
description="output lines carry the ANSI escapes the program prints: bold, dim, the named colours, the 256"
with="colours"
>
<Player clip={COLOURED_CLIP} label="trash, in its colours">
<Terminal
session={COLOURED}
accent="#e6e6e6"
rows={6}
alt="A terminal session in the CLI's colours: a green tick, a red cross, bold names, dim notes."
/>
</Player>
</Sample>
<Sample name="phosphor" title="the default phosphor" with="session">
<Player clip={CLIP} label="the same session, default phosphor">
<Terminal
session={SESSION}
rows={12}
alt="The same session on the default phosphor."
/>
</Player>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// A phosphor terminal that draws a session script (lib/terminal-session) at a moment:
// commands type char by char, output lands whole, `@ms` pauses. It draws; something
// else moves time: inside `<Player clip={sessionTimeline(…).clip}>` it plays, inside
// a scroll stage it scrolls, with a fixed `progress` it is a still. The still renderer in the same lib draws
// the identical frame as SVG, so a CLI's media and its live demo cannot drift.
// Display-only: role="img", zero focusables.
//
// A full-screen program (an editor, `terminal-editor`) is `children` instead of a
// session: the window draws it on its screen, and hands it its palette
// (`useTerminalPalette`), so the program wears the window's phosphor.
import * as React from "react"
import {
ansiColor,
DIM_OPACITY,
LINE_HEIGHT,
parseSession,
type SessionLine,
sessionTimeline,
spansOf,
type TerminalPalette,
TYPE_MS,
terminalPalette,
} from "@/registry/base-nova/lib/terminal-session"
import { usePlayhead } from "@/registry/base-nova/ui/playback"
export type TerminalProps = TerminalWindowProps &
(
| ScriptProps
| {
/** A program that takes the whole screen (an editor, `terminal-editor`),
* drawn in the window in place of a session: the alternate screen a
* full-screen program runs in. It draws its own rows from the top-left
* corner, in the window's type and its palette (`useTerminalPalette`). */
children: React.ReactNode
session?: never
progress?: never
rows?: never
}
)
interface ScriptProps {
/** A session script (`$ cmd` · `~ muted` · plain · `# comment` · `@ms` pause). */
session: string
children?: never
/** The moment drawn, 0..1 of the session, for a still. Inside a driver (a Player)
* omit it: the driver provides the moment. */
progress?: number
/** Minimum rows the frame reserves, so a short session still gets a window that
* feels like one (a hero). The script's own length always wins when longer. */
rows?: number
}
interface TerminalWindowProps {
/** One accent drives the whole palette (default: phosphor green). */
accent?: string
/** The type size, any CSS length; the whole frame scales with it. A frame that
* must fill its box, as a still fills its image, passes a share of the box's width
* (`cqw`, lib/terminal-session `stillSize()`). */
size?: string
/** Accessible description (the terminal is one picture). */
alt: string
className?: string
}
/** The chrome is drawn at 13px type and kept in em, so it scales with `size`. */
const em = (px: number) => `${px / 13}em`
/** The palette a program in the window draws with (`children`): the window's own,
* from its accent. */
const PaletteContext = React.createContext<TerminalPalette | null>(null)
/** The palette of the terminal around: what a full-screen program inside it
* (`children`) draws with, so it wears the window's phosphor. Screams outside one. */
export function useTerminalPalette(): TerminalPalette {
const palette = React.useContext(PaletteContext)
if (!palette)
throw new Error(
"useTerminalPalette: no terminal around; place the program inside <Terminal>",
)
return palette
}
export function Terminal(props: TerminalProps) {
if (props.session === undefined) {
const { children, accent, size, alt, className } = props
return (
<TerminalWindow
accent={accent}
size={size}
alt={alt}
className={className}
>
{children}
</TerminalWindow>
)
}
return <TerminalScript {...props} />
}
/** The window: its bar and its dots, the screen under them in the palette. */
function TerminalWindow({
accent,
size = "13px",
alt,
className,
children,
}: TerminalWindowProps & { children: React.ReactNode }) {
const palette = React.useMemo(() => terminalPalette(accent), [accent])
return (
<div
data-slot="terminal"
className={className}
role="img"
aria-label={alt}
style={{
// A column: the bar, then the screen taking what is left of a window given
// a size (a fixed frame), or as much as it holds when it sizes itself.
display: "flex",
flexDirection: "column",
background: palette.bg,
borderRadius: em(12),
overflow: "hidden",
// Parted from the page by a hairline in its own accent and a soft, low
// shadow: a window resting on it, not a hole in it.
border: `1px solid ${palette.edge}`,
boxShadow: `0 ${em(1)} ${em(2)} rgb(0 0 0 / 0.25), 0 ${em(18)} ${em(40)} -${em(12)} rgb(0 0 0 / 0.55)`,
fontFamily: "var(--font-mono, Menlo, Monaco, monospace)",
fontSize: size,
lineHeight: LINE_HEIGHT,
}}
>
<div
aria-hidden="true"
style={{
display: "flex",
flexDirection: "column",
flex: 1,
minHeight: 0,
}}
>
<div
data-slot="terminal-bar"
style={{
display: "flex",
gap: em(8),
alignItems: "center",
background: palette.bar,
padding: `${em(10)} ${em(16)}`,
}}
>
{[0, 1, 2].map((d) => (
<span
key={d}
style={{
width: em(10),
height: em(10),
borderRadius: "50%",
background: palette.dot,
}}
/>
))}
</div>
<PaletteContext.Provider value={palette}>
<div
data-slot="terminal-screen"
style={{ flex: 1, minHeight: 0, overflow: "hidden" }}
>
{children}
</div>
</PaletteContext.Provider>
</div>
</div>
)
}
function TerminalScript({
session,
progress,
accent,
rows = 0,
size,
alt,
className,
}: TerminalWindowProps & ScriptProps) {
const timeline = React.useMemo(
() => sessionTimeline(parseSession(session)),
[session],
)
const palette = React.useMemo(() => terminalPalette(accent), [accent])
const at = usePlayhead(progress).at * timeline.total
let full = 0
while (full < timeline.lines.length && (timeline.ends[full] as number) <= at)
full++
const current =
full < timeline.lines.length ? timeline.lines[full] : undefined
const start =
full < timeline.lines.length ? (timeline.starts[full] as number) : 0
const typedChars =
current && current.kind === "command" && at > start
? Math.max(0, Math.floor((at - start) / TYPE_MS))
: 0
const done = full >= timeline.lines.length
// A session taller than its window scrolls as a terminal does: the newest line
// stays at the screen's foot and the oldest leave at its top. Measured whenever
// the live lines or the screen change size, never per frame; a window sized by
// the whole session never overflows, so it never moves.
const sheet = React.useRef<HTMLDivElement>(null)
const live = React.useRef<HTMLDivElement>(null)
React.useLayoutEffect(() => {
const page = sheet.current
const layer = live.current
const screen = page?.parentElement
if (!page || !layer || !screen)
throw new Error("terminal: the screen rendered without its sheet")
const follow = () => {
const below = Number.parseFloat(getComputedStyle(page).paddingBottom)
const bottom =
layer.getBoundingClientRect().bottom -
page.getBoundingClientRect().top +
below
screen.scrollTop = Math.max(0, bottom - screen.clientHeight)
}
follow()
const observer = new ResizeObserver(follow)
observer.observe(layer)
observer.observe(screen)
return () => observer.disconnect()
}, [])
const prompt = (key: string, cursor: boolean) => (
<div key={key}>
<span style={{ color: palette.prompt }}>$ </span>
{cursor && <span style={{ color: palette.dot }}>█</span>}
</div>
)
const renderLine = (line: SessionLine, key: number, chars?: number) => {
if (line.kind === "blank") return <div key={key}> </div>
if (line.kind === "command")
return (
<div key={key}>
<span style={{ color: palette.prompt }}>$ </span>
<span style={{ color: palette.command }}>
{chars === undefined ? line.text : line.text.slice(0, chars)}
</span>
</div>
)
return (
<div
key={key}
style={{
color: line.kind === "muted" ? palette.muted : palette.accent,
}}
>
{spansOf(line.text).map((s) =>
s.color === null && !s.bold && !s.dim ? (
s.text
) : (
<span
key={s.at}
style={{
color: s.color === null ? undefined : ansiColor(s.color),
fontWeight: s.bold ? 700 : undefined,
opacity: s.dim ? DIM_OPACITY : undefined,
}}
>
{s.text}
</span>
),
)}
</div>
)
}
return (
<TerminalWindow accent={accent} size={size} alt={alt} className={className}>
{/* The box is sized by the FINISHED session (an invisible full copy in the
same cell), so playback never grows the page: text streams into a fixed
frame. Both layers share width, so wrapping is identical. */}
<div
ref={sheet}
style={{
display: "grid",
padding: `${em(20)} ${em(24)} ${em(24)}`,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
}}
>
<div
style={{
gridArea: "1 / 1",
visibility: "hidden",
minHeight: `${rows * LINE_HEIGHT}em`,
}}
>
{timeline.lines.map((l, i) => renderLine(l, i))}
{prompt("end", true)}
</div>
{/* Its own height, never the cell's (which the finished copy sets): the
screen follows the lines that have landed. */}
<div ref={live} style={{ gridArea: "1 / 1", alignSelf: "start" }}>
{timeline.lines.slice(0, full).map((l, i) => renderLine(l, i))}
{current &&
current.kind === "command" &&
typedChars > 0 &&
renderLine(current, full, typedChars)}
{prompt("live", done)}
</div>
</div>
</TerminalWindow>
)
}