Aadi

Tool Bash

Component

About

A shell command an AI agent runs, shown in the thread: the command, output streaming in with a live timer and Stop, then the run folds to its exit code and duration, with copy for the whole log.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Tool Bash to your project and fits it to your theme.

Code

How Tool Bash is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client"

import * as React from "react"
import { useMemo, useSyncExternalStore, useEffect } from "react"
import { Button, ChatcnToolPart, RotateCcwIcon, ToolBashPart, parsePartialJson } from "@/components/ui/tool-bash"

const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"

/**
 * 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
 * phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
 * render white on dark pages. FitFrame fills the frame exactly instead.
 *
 * - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
 * - "crop": the content stays at full size from the top and fades out at the bottom. For text
 *   that streams in, where a changing scale would make the page breathe.
 *
 * `minWidth` lays out a wide demo (a table, a two column panel) at that width and scales it down
 * to the frame, instead of letting it collapse to a narrow layout. The frame centers it, so it
 * shrinks evenly on both sides.
 *
 * Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
 * shorter than `hideBelow`, so a profile card shows the visual alone.
 */
function FitFrame({
  children,
  mode = "scale",
  hideBelow = 360,
  minWidth,
}: {
  children: React.ReactNode
  mode?: "scale" | "crop"
  hideBelow?: number
  minWidth?: string
}) {
  const content = React.useRef<HTMLDivElement>(null)
  const [cropped, setCropped] = React.useState(false)

  React.useLayoutEffect(() => {
    const el = content.current
    if (!el) return
    const measure = () => {
      const vw = window.innerWidth
      const vh = window.innerHeight
      el.toggleAttribute("data-fit-small", vh < hideBelow)
      // Layout sizes ignore the transform, so measuring never feeds back.
      const w = Math.max(el.offsetWidth, el.scrollWidth)
      const h = Math.max(el.offsetHeight, el.scrollHeight)
      const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
      el.style.transform = scale < 1 ? `scale(${scale})` : ""
      setCropped(mode === "crop" && h > vh + 1)
    }
    measure()
    const observer = new ResizeObserver(measure)
    observer.observe(el)
    // Content that changes inside a fixed width box (a headline that cycles words) never resizes
    // the box itself, so changes below it measure again on the next frame.
    let frame = 0
    const mutations = new MutationObserver(() => {
      cancelAnimationFrame(frame)
      frame = requestAnimationFrame(measure)
    })
    mutations.observe(el, { subtree: true, childList: true, characterData: true })
    window.addEventListener("resize", measure)
    return () => {
      observer.disconnect()
      mutations.disconnect()
      cancelAnimationFrame(frame)
      window.removeEventListener("resize", measure)
    }
  }, [mode, hideBelow])

  const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
  return (
    <div
      className="flex w-full justify-center overflow-hidden"
      style={{
        height: "100dvh",
        alignItems: mode === "crop" ? "flex-start" : "center",
        maskImage: cropped ? fade : undefined,
        WebkitMaskImage: cropped ? fade : undefined,
      }}
    >
      <style>{FIT_CSS}</style>
      <div
        ref={content}
        className="w-full shrink-0"
        style={{ transformOrigin: mode === "crop" ? "top center" : "center", minWidth }}
      >
        {children}
      </div>
    </div>
  )
}

/** Streaming state shared by text and reasoning parts. */
type ChatcnTextState = "streaming" | "done"

/** Assistant or user prose. */
type ChatcnTextPart = { type: "text"; text: string; state?: ChatcnTextState }

/** Model reasoning or thinking text. */
type ChatcnReasoningPart = {
  type: "reasoning"
  text: string
  id?: string
  state?: ChatcnTextState
}

/** A web source cited by the response. */
type ChatcnSourceUrlPart = {
  type: "source-url"
  sourceId: string
  url: string
  title?: string
}

/** A document source cited by the response. */
type ChatcnSourceDocumentPart = {
  type: "source-document"
  sourceId: string
  mediaType: string
  title: string
  filename?: string
}

/** An attached or generated file (hosted URL or data URL). */
type ChatcnFilePart = { type: "file"; mediaType: string; url: string; filename?: string }

/** App-defined structured data (`data-${name}`), reconciled by `id`. */
type ChatcnDataPart<D = unknown> = { type: `data-${string}`; id?: string; data: D }

/** Boundary between agent steps. */
type ChatcnStepStartPart = { type: "step-start" }

/** Every part type chatcn renders natively. */
type ChatcnKnownPart =
  | ChatcnTextPart
  | ChatcnReasoningPart
  | ChatcnToolPart
  | ChatcnSourceUrlPart
  | ChatcnSourceDocumentPart
  | ChatcnFilePart
  | ChatcnDataPart
  | ChatcnStepStartPart

/** Any other part (for example AI SDK `custom` or `reasoning-file`): render a fallback, never crash. */
type ChatcnUnknownPart = { type: string; [key: string]: unknown }

/** Any message part. Narrow with `isKnownPart` first, then switch on `part.type`. */
type ChatcnPart = ChatcnKnownPart | ChatcnUnknownPart

/** A chat message, structurally compatible with AI SDK `UIMessage`. */
interface ChatcnMessage<P extends ChatcnPart = ChatcnPart> {
  id: string
  role: "system" | "user" | "assistant"
  parts: P[]
  metadata?: unknown
}

/** Points at a part: by index for prose, by `toolCallId` for tool calls. */
type ChatcnPartRef = { messageId: string; partIndex?: number; toolCallId?: string }

type Resolved = Readonly<Record<string, unknown>>

/** One timeline operation. */
type ChatcnStreamOp =
  | { op: "message"; message: ChatcnMessage }
  | { op: "part"; messageId: string; part: ChatcnPart }
  | ({ op: "text-delta"; delta: string } & ChatcnPartRef)
  | ({ op: "tool-input-delta"; delta: string } & ChatcnPartRef)
  | ({
      op: "patch"
      patch: Record<string, unknown> | ((resolved: Resolved) => Record<string, unknown>)
    } & ChatcnPartRef)
  | { op: "await"; key: string; auto?: { after: number; value: unknown } }

/** A timed, optionally guarded operation. */
type ChatcnStreamStep = ChatcnStreamOp & {
  at: number
  when?: (resolved: Resolved) => boolean
}

/** A built script. */
interface ChatcnScript {
  steps: ChatcnStreamStep[]
  duration: number
}

/** Options for `useChatcnStream`. */
interface UseChatcnStreamOptions {
  /** Start playing on mount. Defaults to true. */
  autoplay?: boolean
  loop?: boolean
  speed?: number
}

/** Playback status. */
type ChatcnStreamStatus = "idle" | "playing" | "paused" | "awaiting" | "done"

/** Return value of `useChatcnStream`. */
interface UseChatcnStreamResult {
  messages: ChatcnMessage[]
  /** Parts of all messages, in order. */
  parts: ChatcnPart[]
  state: ChatcnStreamStatus
  time: number
  /** Key of the `await` step blocking playback, for example an approval id. */
  awaiting: string | null
  play: () => void
  pause: () => void
  reset: () => void
  seek: (ms: number) => void
  resolve: (key: string, value?: unknown) => void
}

type Guard = ChatcnStreamStep["when"]

/** Options for chunked streaming builder steps. */
interface ChunkOptions {
  /** Delay between chunks in ms. */
  chunkMs?: number
  /** Approximate characters per chunk. */
  chunkSize?: number
}

/** Split text into chunks of about `size` characters, breaking after whitespace when possible. */
function chunkText(text: string, size: number, byWord = true): string[] {
  const chunks: string[] = []
  let i = 0
  while (i < text.length) {
    let end = Math.min(text.length, i + size)
    if (byWord && end < text.length) {
      const space = text.indexOf(" ", end)
      if (space !== -1 && space - end < size) end = space + 1
    }
    chunks.push(text.slice(i, end))
    i = end
  }
  return chunks
}

/** True when an approval resolution value means yes (`true` or `{ approved: true }`). */
function isApproved(value: unknown): boolean {
  return (
    value === true ||
    (typeof value === "object" && value !== null && "approved" in value && value.approved === true)
  )
}

/** Fluent script builder. Every method appends steps at the builder's current time. */
class ChatcnScriptBuilder {
  private steps: ChatcnStreamStep[] = []
  private t = 0
  private messageId: string | null = null
  private partCount = new Map<string, number>()
  private tool: { id: string; guard: Guard } | null = null
  private ids = { message: 0, call: 0, approval: 0 }

  private push(op: ChatcnStreamOp, when?: Guard): this {
    this.steps.push(when ? { ...op, at: this.t, when } : { ...op, at: this.t })
    return this
  }

  private addMessage(role: ChatcnMessage["role"], id?: string): string {
    const messageId = id ?? `msg_${++this.ids.message}`
    this.partCount.set(messageId, 0)
    this.push({ op: "message", message: { id: messageId, role, parts: [] } })
    return messageId
  }

  private addPart(part: ChatcnPart): number {
    if (!this.messageId) this.messageId = this.addMessage("assistant")
    const messageId = this.messageId
    const index = this.partCount.get(messageId) ?? 0
    this.partCount.set(messageId, index + 1)
    this.push({ op: "part", messageId, part })
    return index
  }

  private toolRef(): { messageId: string; toolCallId: string } {
    if (!this.tool || !this.messageId) throw new Error("Call toolCall() before tool steps.")
    return { messageId: this.messageId, toolCallId: this.tool.id }
  }

  /** Advance the builder clock. */
  wait(ms: number): this {
    this.t += ms
    return this
  }

  /** Add a finished user message. */
  user(text: string, options: { id?: string } = {}): this {
    const id = this.addMessage("user", options.id)
    this.messageId = id
    this.addPart({ type: "text", text, state: "done" })
    this.messageId = null
    return this
  }

  /** Start an assistant message; later parts go into it. */
  assistant(options: { id?: string } = {}): this {
    this.messageId = this.addMessage("assistant", options.id)
    return this
  }

  /** Stream a text or reasoning part chunk by chunk, then mark it done. */
  text(text: string, options: ChunkOptions & { type?: "text" | "reasoning" } = {}): this {
    const { chunkMs = 30, chunkSize = 12, type = "text" } = options
    const partIndex = this.addPart({ type, text: "", state: "streaming" })
    const messageId = this.messageId as string
    for (const delta of chunkText(text, chunkSize)) {
      this.wait(chunkMs).push({ op: "text-delta", messageId, partIndex, delta })
    }
    return this.push({ op: "patch", messageId, partIndex, patch: { state: "done" } })
  }

  /** Stream a reasoning part. */
  reasoning(text: string, options: ChunkOptions = {}): this {
    return this.text(text, { ...options, type: "reasoning" })
  }

  /** Append any part as is. */
  part(part: ChatcnPart): this {
    this.addPart(part)
    return this
  }

  /** Start a tool call. Without `input` it begins in `input-streaming`. */
  toolCall(call: {
    name: string
    toolCallId?: string
    input?: unknown
    title?: string
    dynamic?: boolean
  }): this {
    const id = call.toolCallId ?? `call_${++this.ids.call}`
    const part: ChatcnToolPart = {
      type: call.dynamic ? "dynamic-tool" : `tool-${call.name}`,
      toolCallId: id,
      state: call.input === undefined ? "input-streaming" : "input-available",
    }
    if (call.dynamic) part.toolName = call.name
    if (call.title) part.title = call.title
    if (call.input !== undefined) part.input = call.input
    this.addPart(part)
    this.tool = { id, guard: undefined }
    return this
  }

  /** Stream the tool input as JSON in character chunks, then mark it `input-available`. */
  streamInput(input: unknown, options: ChunkOptions = {}): this {
    const { chunkMs = 40, chunkSize = 6 } = options
    const ref = this.toolRef()
    const raw = typeof input === "string" ? input : JSON.stringify(input)
    for (const delta of chunkText(raw, chunkSize, false)) {
      this.wait(chunkMs).push({ op: "tool-input-delta", ...ref, delta })
    }
    const value = typeof input === "string" ? parsePartialJson(input).value : input
    return this.push({
      op: "patch",
      ...ref,
      patch: { state: "input-available", input: value, rawInput: undefined },
    })
  }

  /**
   * Ask for approval and wait for `resolve(approvalId, value)`. Later tool steps only run when the
   * value is approving; a denial moves the call to `output-denied`. `auto` resolves on its own.
   */
  awaitApproval(
    options: {
      approvalId?: string
      requestReason?: string
      auto?: { after: number; value: unknown }
    } = {},
  ): this {
    const ref = this.toolRef()
    const id = options.approvalId ?? `approval_${++this.ids.approval}`
    const approval = options.requestReason ? { id, requestReason: options.requestReason } : { id }
    const approved: Guard = (r) => isApproved(r[id])
    this.push({ op: "patch", ...ref, patch: { state: "approval-requested", approval } })
    this.push(
      options.auto ? { op: "await", key: id, auto: options.auto } : { op: "await", key: id },
    )
    this.push(
      {
        op: "patch",
        ...ref,
        patch: { state: "approval-responded", approval: { ...approval, approved: true } },
      },
      approved,
    )
    this.push(
      {
        op: "patch",
        ...ref,
        patch: (r) => {
          const v = r[id]
          const reason = typeof v === "object" && v !== null && "reason" in v ? v.reason : undefined
          return { state: "output-denied", approval: { ...approval, approved: false, reason } }
        },
      },
      (r) => !isApproved(r[id]),
    )
    ;(this.tool as { guard: Guard }).guard = approved
    return this
  }

  /** Finish the tool call with output after `delayMs`. */
  output(output: unknown, options: { delayMs?: number } = {}): this {
    const ref = this.toolRef()
    return this.wait(options.delayMs ?? 400).push(
      { op: "patch", ...ref, patch: { state: "output-available", output } },
      this.tool?.guard,
    )
  }

  /** Fail the tool call after `delayMs`. */
  error(errorText: string, options: { delayMs?: number } = {}): this {
    const ref = this.toolRef()
    return this.wait(options.delayMs ?? 400).push(
      { op: "patch", ...ref, patch: { state: "output-error", errorText } },
      this.tool?.guard,
    )
  }

  /** Append a raw step at the current time. */
  step(op: ChatcnStreamOp, when?: Guard): this {
    return this.push(op, when)
  }

  /** Finish the script. */
  build(): ChatcnScript {
    return { steps: [...this.steps], duration: this.t }
  }
}

/** Start a script builder. */
function script(): ChatcnScriptBuilder {
  return new ChatcnScriptBuilder()
}

/** Time source. The default uses `performance.now()` and `setTimeout`. */
interface ChatcnClock {
  now(): number
  setTimeout(fn: () => void, ms: number): unknown
  clearTimeout(handle: unknown): void
}

/** Options for `createChatcnStream`. */
interface ChatcnStreamOptions {
  speed?: number
  loop?: boolean
  /** Hold on the last frame before looping, in script ms. Defaults to 1200. */
  loopDelay?: number
  clock?: ChatcnClock
}

/** Immutable engine state; a new object is created on every change. */
interface ChatcnStreamSnapshot {
  messages: ChatcnMessage[]
  /** Parts of all messages, in order. */
  parts: ChatcnPart[]
  time: number
  status: ChatcnStreamStatus
  /** Key of the unresolved `await` step blocking the timeline. */
  awaiting: string | null
}

/** Controller returned by `createChatcnStream`. */
interface ChatcnStream {
  play(): void
  pause(): void
  reset(): void
  seek(ms: number): void
  resolve(key: string, value?: unknown): void
  getSnapshot(): ChatcnStreamSnapshot
  subscribe(listener: () => void): () => void
  readonly duration: number
}

const realClock: ChatcnClock = {
  now: () => globalThis.performance?.now() ?? Date.now(),
  setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
  clearTimeout: (h) => globalThis.clearTimeout(h as ReturnType<typeof setTimeout>),
}

type AwaitStep = Extract<ChatcnStreamStep, { op: "await" }>

const EPSILON = 1e-6

function replaceAt<T>(list: T[], index: number, item: T): T[] {
  const copy = list.slice()
  copy[index] = item
  return copy
}

function applyOp(
  messages: ChatcnMessage[],
  step: ChatcnStreamOp,
  resolved: Resolved,
): ChatcnMessage[] {
  if (step.op === "await") return messages
  if (step.op === "message") {
    const i = messages.findIndex((m) => m.id === step.message.id)
    return i < 0 ? [...messages, step.message] : replaceAt(messages, i, step.message)
  }
  const mi = messages.findIndex((m) => m.id === step.messageId)
  const msg = messages[mi]
  if (!msg) return messages
  let parts: ChatcnPart[]
  if (step.op === "part") parts = [...msg.parts, step.part]
  else {
    const pi =
      step.toolCallId === undefined
        ? (step.partIndex ?? msg.parts.length - 1)
        : msg.parts.findIndex((p) => (p as ChatcnToolPart).toolCallId === step.toolCallId)
    const part = msg.parts[pi] as Record<string, unknown> | undefined
    if (!part) return messages
    let next: Record<string, unknown>
    if (step.op === "text-delta") next = { ...part, text: `${part.text ?? ""}${step.delta}` }
    else if (step.op === "tool-input-delta") {
      const rawInput = `${part.rawInput ?? ""}${step.delta}`
      next = { ...part, rawInput, input: parsePartialJson(rawInput).value }
    } else {
      next = { ...part, ...(typeof step.patch === "function" ? step.patch(resolved) : step.patch) }
      for (const key in next) if (next[key] === undefined) delete next[key]
    }
    parts = replaceAt(msg.parts, pi, next as ChatcnPart)
  }
  return replaceAt(messages, mi, { ...msg, parts })
}

/** Create a player for a script. Nothing runs until `play()` or `seek()`. */
function createChatcnStream(
  script: ChatcnScript | ChatcnStreamStep[],
  options: ChatcnStreamOptions = {},
): ChatcnStream {
  const list = Array.isArray(script) ? script : script.steps
  const steps = [...list].sort((a, b) => a.at - b.at)
  const last = steps.at(-1)?.at ?? 0
  const duration = Array.isArray(script) ? last : Math.max(script.duration, last)
  const { speed = 1, loop = false, loopDelay = 1200, clock = realClock } = options
  const end = loop ? duration + loopDelay : duration
  const listeners = new Set<() => void>()

  let messages: ChatcnMessage[] = []
  let time = 0
  let cursor = 0
  let playing = false
  let awaiting: AwaitStep | null = null
  let resolved: Record<string, unknown> = {}
  let timer: unknown = null
  let anchorWall = 0
  let anchorTime = 0
  let snapshot = makeSnapshot()

  function status(): ChatcnStreamStatus {
    if (awaiting) return "awaiting"
    if (playing) return "playing"
    if (cursor >= steps.length && time >= duration) return "done"
    return cursor === 0 && time === 0 ? "idle" : "paused"
  }

  function makeSnapshot(): ChatcnStreamSnapshot {
    const parts = messages.flatMap((m) => m.parts)
    return { messages, parts, time, status: status(), awaiting: awaiting?.key ?? null }
  }

  function emit() {
    snapshot = makeSnapshot()
    for (const fn of listeners) fn()
  }

  const scriptNow = () => anchorTime + (clock.now() - anchorWall) * speed

  function anchor() {
    anchorWall = clock.now()
    anchorTime = time
  }

  function clear(keepResolved: boolean) {
    messages = []
    time = 0
    cursor = 0
    awaiting = null
    if (!keepResolved) resolved = {}
  }

  /** Apply steps due by `target`, stopping at an unresolved await. Seeking passes `auto` awaits. */
  function advance(target: number, seeking = false) {
    awaiting = null
    while (cursor < steps.length) {
      const step = steps[cursor] as ChatcnStreamStep
      if (step.at > target + EPSILON) break
      if (step.op === "await" && !(step.key in resolved)) {
        if (!seeking || !step.auto) {
          awaiting = step
          time = step.at
          return
        }
        resolved = { ...resolved, [step.key]: step.auto.value }
      }
      if (!step.when || step.when(resolved)) messages = applyOp(messages, step, resolved)
      cursor++
    }
    time = Math.min(target, duration)
  }

  /** Arm the timer for the next step, an `auto` await or the loop restart. */
  function schedule() {
    if (timer !== null) clock.clearTimeout(timer)
    timer = null
    if (!playing) return
    const blocker = awaiting
    if (blocker) {
      const auto = blocker.auto
      if (auto)
        timer = clock.setTimeout(() => api.resolve(blocker.key, auto.value), auto.after / speed)
      return
    }
    const next = steps[cursor]
    if (!next && !loop && time >= duration - EPSILON) {
      playing = false
      return
    }
    const target = next ? next.at : end
    timer = clock.setTimeout(tick, Math.max(0, (target - scriptNow()) / speed))
  }

  function tick() {
    timer = null
    const now = scriptNow()
    advance(now)
    if (loop && !awaiting && cursor >= steps.length && now >= end - EPSILON) {
      clear(false)
      advance(0)
    }
    anchor()
    schedule()
    emit()
  }

  const api: ChatcnStream = {
    duration,
    play() {
      if (playing) return
      if (status() === "done") clear(false)
      playing = true
      advance(time)
      anchor()
      schedule()
      emit()
    },
    pause() {
      if (!playing) return
      if (!awaiting) advance(scriptNow())
      playing = false
      schedule()
      emit()
    },
    reset() {
      playing = false
      clear(false)
      schedule()
      emit()
    },
    seek(ms) {
      clear(true)
      advance(Math.max(0, Math.min(ms, duration)), true)
      anchor()
      schedule()
      emit()
    },
    resolve(key, value = true) {
      resolved = { ...resolved, [key]: value }
      if (awaiting?.key !== key) return
      advance(time)
      anchor()
      schedule()
      emit()
    },
    getSnapshot: () => snapshot,
    subscribe(listener) {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
  }
  return api
}

/**
 * Play a scripted chatcn stream in React. Pass a stable script (a module constant or memoized);
 * a new script, `loop` or `speed` value starts a fresh stream.
 */
function useChatcnStream(
  script: ChatcnScript | ChatcnStreamStep[],
  { autoplay = true, loop = false, speed = 1 }: UseChatcnStreamOptions = {},
): UseChatcnStreamResult {
  const stream = useMemo(() => createChatcnStream(script, { loop, speed }), [script, loop, speed])
  const snapshot = useSyncExternalStore(stream.subscribe, stream.getSnapshot, stream.getSnapshot)

  useEffect(() => {
    if (autoplay) stream.play()
    return () => stream.pause()
  }, [stream, autoplay])

  return {
    messages: snapshot.messages,
    parts: snapshot.parts,
    state: snapshot.status,
    time: snapshot.time,
    awaiting: snapshot.awaiting,
    play: stream.play,
    pause: stream.pause,
    reset: stream.reset,
    seek: stream.seek,
    resolve: stream.resolve,
  }
}

const LINES = [
  "> web@0.1.0 build /repo/apps/web\n> next build\n\n",
  "   \u001b[1mNext.js 16.4.2\u001b[22m (Turbopack)\n   - Environments: .env.production\n\n",
  " ✓ Compiled successfully in 6.1s\n",
  " ✓ Linting and checking validity of types\n",
  "   Collecting page data ...\n",
  " ✓ Generating static pages (24/24)\n",
  "   Finalizing page optimization ...\n\n",
  "Route (app)                    Size  First Load JS\n",
  "┌ ○ /                         4.1 kB        118 kB\n",
  "├ ƒ /runs                     6.3 kB        124 kB\n",
  "├ ƒ /runs/[id]                9.8 kB        131 kB\n",
  "└ ○ /settings                 2.2 kB        112 kB\n\n",
  "○  (Static)   prerendered as static content\nƒ  (Dynamic)  server-rendered on demand\n",
]

function build() {
  const s = script()
    .assistant({ id: "m" })
    .toolCall({ name: "bash", toolCallId: "b", input: { command: "pnpm build --filter web" } })
    .wait(500)
  let stdout = ""
  for (const line of LINES) {
    stdout += line
    s.wait(320).step({
      op: "patch",
      messageId: "m",
      toolCallId: "b",
      patch: { state: "output-available", preliminary: true, output: { stdout } },
    })
  }
  return s
    .wait(300)
    .step({
      op: "patch",
      messageId: "m",
      toolCallId: "b",
      patch: { preliminary: undefined, output: { stdout, exitCode: 0 } },
    })
    .wait(4000)
    .build()
}

const run = build()

/** True for tool parts, static or dynamic. */
function isToolPart(part: { type: string }): part is ChatcnToolPart {
  return part.type === "dynamic-tool" || part.type.startsWith("tool-")
}

function ToolBashDemo() {
  const { parts, pause, play, seek } = useChatcnStream(run, { loop: true })
  const [stopped, setStopped] = React.useState(false)
  const part = parts.find(isToolPart)
  return (
    <div className="flex w-full max-w-xl flex-col items-start gap-2">
      {part ? (
        <ToolBashPart
          part={part}
          stopped={stopped}
          defaultOpen
          className="w-full"
          onStop={() => {
            pause()
            setStopped(true)
          }}
        />
      ) : null}
      {stopped ? (
        <Button
          size="sm"
          onClick={() => {
            setStopped(false)
            seek(0)
            play()
          }}
        >
          <RotateCcwIcon />
          Run again
        </Button>
      ) : null}
    </div>
  )
}

/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
  return (
    <p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
      {children}{" "}
      <a
        href={`https://chatcn.dev/docs/components/${slug}`}
        target="_blank"
        rel="noopener"
        className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
      >
        chatcn.dev
      </a>
    </p>
  )
}

/** The kit's own example, framed for the 21st preview. */
export default function ToolBashPreview() {
  return (
    <FitFrame minWidth="40rem">
      <div className="flex w-full flex-col items-center justify-center gap-4 p-6">
        <div className="flex h-[25rem] w-full max-w-xl items-start justify-center">
          <ToolBashDemo />
        </div>
        <MoreOn slug="tool-bash">Shell commands an agent runs, with live output and exit codes. On</MoreOn>
      </div>
    </FitFrame>
  )
}

Dependencies

More by Aadi

View all
  • Dither Orb

    Dither Orb

    12
  • Audio Visualizer

    Audio Visualizer

    9
  • Voice Orb

    Voice Orb

    8
  • Agent Lanes

    Agent Lanes

    6
  • Computer View

    Computer View

    3
  • Waveform

    Waveform

    2
  • Reasoning

    Reasoning

    2
  • Voice Session

    Voice Session

    1

Explore more

Part of chatcn — browse the full library on 21st.dev.