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>
)
}








