← ui

claude-code

ui

Claude Code's terminal UI, drawn from an agent session

npx shadcn add @ag/claude-code
Already installed? Preview an update
npx shadcn add @ag/claude-code --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-09-30
  • New: Claude Code's terminal UI drawn from an agent-session: the welcome box, your prompts as full-width grey bars (the only tinted lines, as in the real CLI), ⏺ replies and tool calls with their ⎿ results, the working line as the CLI draws it (✢ Shimmying… (1s), then (5s · thinking), (14s · still thinking), (25s · thinking more), the stage in the verb's colour, and ↓ 2.9k tokens once the turn has output: the spinner breathing through · ✢ ✳ ✶ ✻ ✽, a light band sweeping the verb, a new verb from Claude Code's own list on every request, all a pure function of time), the grey line a finished turn leaves (✻ Sautéed for 8m 37s), the prompt box. Reads like a terminal: from the top while it fits, the oldest lines scrolled away once it does not.
Updated 2026-09-30 · release notes

shelf devices & demos

uses agent-session

the demo above, verbatim

claude-code.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import {
  type AgentEntry,
  requestTokens,
} from "@/registry/base-nova/lib/agent-session"
import { ClaudeCode } from "@/registry/base-nova/ui/claude-code"

// #region session
const ENTRIES: AgentEntry[] = [
  { kind: "prompt", text: "Migrate billing to the v2 API and run the tests." },
  { kind: "say", text: "I'll start with the client, then the call sites." },
  {
    kind: "tool",
    name: "Read",
    arg: "src/billing/client.ts",
    result: ["Read 214 lines"],
  },
  {
    kind: "tool",
    name: "Update",
    arg: "src/billing/client.ts",
    result: ["Updated src/billing/client.ts with 38 additions and 12 removals"],
  },
]
// #endregion

/** Time since the demo mounted: the working line is a function of it. */
function useMs() {
  const [ms, setMs] = React.useState(0)
  React.useEffect(() => {
    const t0 = performance.now()
    let frame = requestAnimationFrame(function tick(now) {
      setMs(now - t0)
      frame = requestAnimationFrame(tick)
    })
    return () => cancelAnimationFrame(frame)
  }, [])
  return ms
}

export default function ClaudeCodeDemo() {
  const ms = useMs()
  return (
    <Sample
      name="working"
      glance="large"
      title="at work"
      description="a transcript and the working line"
      with="session"
    >
      <div className="h-[420px] rounded-xl bg-[#161617] p-5 font-mono text-[13px] leading-[1.45]">
        <ClaudeCode
          entries={ENTRIES}
          work={{
            seconds: 192 + ms / 1000,
            // One request's life, over and over: a beat, thinking, streaming.
            request: (ms / 1000) % 40,
            tokens: 2140 + requestTokens((ms / 1000) % 40),
            ms,
          }}
          draft=""
          cwd="~/billing"
        />
      </div>
    </Sample>
  )
}

the source, every file an install writes

claude-code.tsx
// Claude Code's terminal UI, drawn from an agent-session: the welcome box, the
// transcript (prompts, ⏺ replies, ⏺ tool calls with their ⎿ results), the orange
// working line, and the prompt box. Presentational: it fills its box with the
// terminal's own monospace and text size, and reads like a terminal: from the top
// while it fits, the oldest lines scrolled away once it does not (a column-reverse
// box anchors its one child at the bottom, and the child's auto bottom margin takes
// any free space: at the top while it fits, anchored at the bottom with its top cut
// once it overflows). A page or a story supplies the entries.

import { cn } from "@/lib/utils"
import {
  type AgentEntry,
  type AgentViewProps,
  type AgentWork,
  formatElapsed,
  formatTokens,
  THINK_SECONDS,
} from "@/registry/base-nova/lib/agent-session"

/** Claude's own orange. */
const CLAUDE = "#d97757"

export function ClaudeCode({
  entries,
  work,
  finished,
  draft,
  cwd = "~",
  className,
}: AgentViewProps) {
  return (
    <div
      data-slot="claude-code"
      className={cn(
        "flex h-full min-h-0 flex-col-reverse overflow-hidden whitespace-pre-wrap text-[#e6e6e6]",
        className,
      )}
    >
      <div className="mb-auto flex flex-col gap-[0.9em]">
        <div
          className="rounded-[6px] border px-[1.2ch] py-[0.5em]"
          style={{ borderColor: CLAUDE }}
        >
          <div>
            <span style={{ color: CLAUDE }}>✻</span>{" "}
            <span className="font-semibold">Welcome to Claude Code!</span>
          </div>
          <div className="text-[#8a8a8a]">
            {"\n  /help for help, /status for your current setup\n\n  cwd: "}
            {cwd}
          </div>
        </div>
        {entries.map((e, i) => (
          // biome-ignore lint/suspicious/noArrayIndexKey: a transcript is positional
          <Entry key={i} entry={e} />
        ))}
        {work && <Working work={work} request={entries.length} />}
        {finished && (
          <div className="text-[#8a8a8a]">
            ✻ {pick(PAST, entries.length)} for {formatElapsed(finished.seconds)}
          </div>
        )}
        <div>
          <div className="rounded-[6px] border border-[#5a5a5a] px-[1ch] py-[0.2em]">
            <span className="text-[#8a8a8a]">{"> "}</span>
            {draft}
            <span className="text-[#8a8a8a]">▌</span>
          </div>
          <div className="px-[1ch] text-[#6f6f6f]">? for shortcuts</div>
        </div>
      </div>
    </div>
  )
}

/** The spinner's glyphs, played forth and back. */
const SPINNER = ["·", "✢", "✳", "✶", "✻", "✽"]
const SPIN_MS = 120
/** The highlight's speed across the verb, and the dark stretch between passes. */
const SWEEP_MS_PER_CHAR = 70
const SWEEP_GAP = 8

/** One of `list` for the `n`th request: a hash, not a random call, so the server, the
 *  browser and a film draw the same word. */
function pick(list: string[], n: number): string {
  return list[(Math.imul(n + 1, 2654435761) >>> 0) % list.length] as string
}

/** What a finished turn says it did, grey: "✻ Sautéed for 8m 37s". */
const PAST = [
  "Baked",
  "Brewed",
  "Churned",
  "Cogitated",
  "Cooked",
  "Crunched",
  "Sautéed",
  "Worked",
]

/** The words Claude Code says it is working with, one drawn at random per request. */
const VERBS = [
  "Accomplishing",
  "Actioning",
  "Actualizing",
  "Baking",
  "Blanching",
  "Booping",
  "Brewing",
  "Calculating",
  "Cerebrating",
  "Channelling",
  "Churning",
  "Clauding",
  "Coalescing",
  "Cogitating",
  "Combobulating",
  "Computing",
  "Concocting",
  "Conjuring",
  "Considering",
  "Contemplating",
  "Cooking",
  "Crafting",
  "Creating",
  "Crunching",
  "Deciphering",
  "Deliberating",
  "Determining",
  "Discombobulating",
  "Divining",
  "Doing",
  "Effecting",
  "Elucidating",
  "Enchanting",
  "Envisioning",
  "Finagling",
  "Flibbertigibbeting",
  "Forging",
  "Forming",
  "Frolicking",
  "Generating",
  "Germinating",
  "Hatching",
  "Herding",
  "Honking",
  "Hustling",
  "Ideating",
  "Imagining",
  "Incubating",
  "Inferring",
  "Ionizing",
  "Jiving",
  "Manifesting",
  "Marinating",
  "Meandering",
  "Moseying",
  "Mulling",
  "Mustering",
  "Musing",
  "Noodling",
  "Percolating",
  "Perusing",
  "Philosophising",
  "Pondering",
  "Pontificating",
  "Processing",
  "Puttering",
  "Puzzling",
  "Reticulating",
  "Ruminating",
  "Scheming",
  "Schlepping",
  "Shimmying",
  "Shucking",
  "Simmering",
  "Smooshing",
  "Spelunking",
  "Spinning",
  "Stewing",
  "Sussing",
  "Synthesizing",
  "Thinking",
  "Tinkering",
  "Transmuting",
  "Unfurling",
  "Unravelling",
  "Vibing",
  "Wandering",
  "Whirring",
  "Wibbling",
  "Wizarding",
  "Working",
  "Wrangling",
]

/** What a request is doing by its age, as the CLI says it: nothing for a beat, then
 *  it thinks, longer and longer, until its output streams. */
function stage(request: number): string | null {
  if (request < 3) return null
  if (request < 10) return "thinking"
  if (request < 20) return "still thinking"
  if (request < THINK_SECONDS) return "thinking more"
  return null
}

/** "✻ Shimmying… (14s · still thinking)", later "(35s · ↓ 2.9k tokens)": the
 *  spinner breathes, a lighter band sweeps across the verb, the stage wears the
 *  verb's colour, and the token count shows once the turn has output. The verb
 *  changes with each request (every entry the transcript gains is the model asked
 *  again). The animation runs on `work.ms`, so a frame drawn alone is the frame
 *  played to. */
function Working({ work, request }: { work: AgentWork; request: number }) {
  const cycle = SPINNER.length * 2 - 2
  const step = Math.floor(work.ms / SPIN_MS) % cycle
  const glyph = SPINNER[step < SPINNER.length ? step : cycle - step]
  const verb = `${pick(VERBS, request)}…`
  const at = (work.ms / SWEEP_MS_PER_CHAR) % (verb.length + SWEEP_GAP)
  const doing = stage(work.request)
  return (
    <div>
      <span style={{ color: CLAUDE }}>{glyph} </span>
      {[...verb].map((ch, i) => (
        <span
          // biome-ignore lint/suspicious/noArrayIndexKey: letters are positional
          key={i}
          style={{ color: Math.abs(i - at) < 1.5 ? "#f5c4b0" : CLAUDE }}
        >
          {ch}
        </span>
      ))}{" "}
      <span className="text-[#8a8a8a]">
        ({formatElapsed(work.seconds)}
        {work.tokens > 0 && ` · ↓ ${formatTokens(work.tokens)} tokens`}
        {doing && (
          <>
            {" · "}
            <span style={{ color: CLAUDE }}>{doing}</span>
          </>
        )}
        )
      </span>
    </div>
  )
}

function Entry({ entry: e }: { entry: AgentEntry }) {
  // The one tinted thing in the transcript: what you asked, as a full-width bar.
  if (e.kind === "prompt")
    return <div className="-mx-[1ch] bg-white/[0.1] px-[1ch]">{e.text}</div>
  if (e.kind === "say")
    return (
      <div className="flex gap-[1ch]">
        <span>⏺</span>
        <span>{e.text}</span>
      </div>
    )
  const done = e.result.length > 0
  return (
    <div>
      <div className="flex gap-[1ch]">
        <span style={{ color: done ? "#4eba65" : "#8a8a8a" }}>⏺</span>
        <span>
          <span className="font-semibold">{e.name}</span>({e.arg})
        </span>
      </div>
      {e.result.map((line, i) => (
        // biome-ignore lint/suspicious/noArrayIndexKey: output lines are positional
        <div key={i} className="flex text-[#9a9a9a]">
          {/* A fixed gutter: ⎿ is narrower than a cell in most monospace faces,
              so spaces cannot line the rows up. */}
          <span className="w-[5ch] shrink-0 pl-[2ch]">{i === 0 && "⎿"}</span>
          <span>{line.trimStart()}</span>
        </div>
      ))}
    </div>
  )
}