Claude Code
A Claude Code session from prompt to response.
Customize
import {Scene, Video} from "odori";
import {ClaudeCode} from "../components/claude-code/claude-code";
<Video>
<Scene id="claude-code" duration="9s">
<ClaudeCode
command="add a changelog video to the marketing site"
name=""
cwd="~/code/odori"
model="Fable 5"
plan="Max 20x"
response={[
{
line: "Read videos/changelog/video.tsx",
detail: "Read 84 lines (ctrl+o to expand)"
},
{
line: "Added the release cards and wired the cue"
}
]}
verb="Working"
charactersPerSecond={22}
/>
</Scene>
</Video>Installation
pnpm odori add component claude-codenpx odori add component claude-codebunx odori add component claude-codeEdit the source in videos/components/claude-code/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/claude-code/claude-code.tsx
import {Easing, Fill, interpolate, useDesignScale, useFrame, useTyping, typingFrames} from "odori";
/** A step the agent reports, and what it found underneath. */
export type ClaudeCodeStep = {line: string; detail?: string};
export type ActivityRow = {when: string; what: string};
export type ClaudeCodeProps = {
/** What gets typed at the prompt. Empty shows the resting placeholder. */
command?: string;
/** Whose terminal this is. Empty keeps the greeting impersonal. */
name?: string;
/** The working directory, as the welcome pane states it. */
cwd?: string;
/** Model and plan, joined with a dot the way the tool prints them. */
model?: string;
plan?: string;
version?: string;
/** The right-hand panes. Empty arrays drop a pane entirely. */
recentActivity?: ActivityRow[];
whatsNew?: string[];
/** Steps the agent prints back once the instruction is sent. */
response?: (ClaudeCodeStep | string)[];
/** The word the spinner uses while it works. */
verb?: string;
charactersPerSecond?: number;
};
const INK = "#E6E4DE";
const FAINT = "#84827B";
const EDGE = "#34322D";
const ACCENT = "#D97757";
/** A terminal sets its own face; this is the stack a session lands in. */
const FONT = '"SF Mono", "JetBrains Mono", "Fira Code", ui-monospace, Menlo, Consolas, monospace';
/** The frames the spinner cycles, at the rate the real one turns. */
const SPINNER = ["✻", "✳", "✽", "✻", "✺", "✸"];
/*
* The mascot, as the pixel grid it is. Drawing it from a bitmap keeps it
* crisp at any scale and identical on every render; an image would be an
* asset to ship and a decode to wait on for sixty-odd squares.
*/
const MASCOT = [
"..#.....#..",
"..##...##..",
"..#######..",
".#########.",
"#..##.##..#",
"###########",
"..#..#..#..",
"..#..#..#..",
];
const Mascot = ({unit}: {unit: number}) => (
<div style={{display: "grid", gap: 0, gridTemplateColumns: `repeat(11, ${unit}px)`}}>
{MASCOT.flatMap((row, y) =>
[...row].map((cell, x) => (
<span key={`${x}-${y}`} style={{background: cell === "#" ? ACCENT : "transparent", height: unit, width: unit}} />
)),
)}
</div>
);
/**
* Claude Code at the top of a session, then taking an instruction.
*
* The welcome layout is the tool's own: a dashed frame with the version
* broken into its top edge, the greeting and mascot on the left, recent
* activity and what's new on the right. It arrives whole, because a CLI does
* not animate its banner and a video that pretends otherwise stops reading as
* a terminal. The instruction is the only thing moving until it is sent; then
* the agent's lines print one at a time, which is how the tool behaves and
* the reason to show it working rather than a finished transcript.
*/
export const ClaudeCode = ({
command = "",
name = "",
cwd = "~/code/odori",
model = "Fable 5",
plan = "Max 20x",
version = "v2.0.0",
recentActivity = [
{when: "1m ago", what: "Updated project memory"},
{when: "2d ago", what: "Add new words to spinner"},
{when: "1w ago", what: "Update unit tests"},
],
whatsNew = ["/agents to create subagents", "/security-review for review agent", "ctrl+b to background bashes"],
response = [],
verb = "Working",
charactersPerSecond = 22,
}: ClaudeCodeProps) => {
const frame = useFrame();
const scale = useDesignScale();
const px = (value: number) => value * scale;
const from = 26;
const typed = useTyping(command, {from, charactersPerSecond});
const sentAt = command ? from + typingFrames(command, {charactersPerSecond}) + 14 : Number.MAX_SAFE_INTEGER;
const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
const sent = frame >= sentAt;
const steps = response.map((item) => (typeof item === "string" ? {line: item} : item));
/* The counters the tool keeps while it works, derived from the frame so
they are the same on every render of it. */
const elapsed = Math.max(0, Math.floor((frame - sentAt) / 30));
const tokens = Math.max(0, Math.round(((frame - sentAt) / 30) * 420));
const spinner = SPINNER[Math.floor(frame / 4) % SPINNER.length];
const spent = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : `${tokens}`;
const dashed = `${px(1.5)}px dashed ${ACCENT}`;
const pane: React.CSSProperties = {padding: `${px(20)}px ${px(24)}px`};
const paneTitle: React.CSSProperties = {color: ACCENT, fontSize: px(22), marginBottom: px(12)};
const row: React.CSSProperties = {color: INK, fontSize: px(21), lineHeight: 1.6};
return (
<Fill style={{alignItems: "center", background: "#262624", justifyContent: "center", padding: px(100)}}>
<div
style={{
fontFamily: FONT,
maxWidth: px(1340),
opacity: enter,
transform: `translateY(${(1 - enter) * px(14)}px)`,
width: "100%",
}}
>
<div style={{position: "relative"}}>
{/* The version sits in the frame's top edge, the way the tool
prints it: a chip on the border, ground showing through. */}
<span
style={{
background: "#262624",
color: ACCENT,
fontSize: px(22),
left: px(46),
padding: `0 ${px(14)}px`,
position: "absolute",
top: px(-14),
zIndex: 1,
}}
>
Claude Code {version}
</span>
<div style={{border: dashed, display: "grid", gridTemplateColumns: "1fr 1.1fr"}}>
{/* The welcome pane: greeting, mascot, model, path. */}
<div
style={{
...pane,
alignItems: "center",
borderRight: dashed,
display: "flex",
flexDirection: "column",
gap: px(24),
justifyContent: "center",
padding: `${px(36)}px ${px(24)}px`,
}}
>
<div style={{color: INK, fontSize: px(26)}}>
Welcome back{name ? ` ${name}` : ""}!
</div>
<Mascot unit={px(11)} />
<div style={{textAlign: "center"}}>
<div style={{color: FAINT, fontSize: px(22)}}>
{model} · {plan}
</div>
<div style={{color: FAINT, fontSize: px(21), marginTop: px(6)}}>{cwd}</div>
</div>
</div>
<div style={{display: "grid"}}>
{recentActivity.length > 0 ? (
<div style={{...pane, borderBottom: whatsNew.length > 0 ? dashed : undefined}}>
<div style={paneTitle}>Recent activity</div>
{recentActivity.map((item) => (
<div key={item.when + item.what} style={{...row, display: "flex", gap: px(20)}}>
<span style={{color: FAINT, flex: "none", width: px(96)}}>{item.when}</span>
<span>{item.what}</span>
</div>
))}
<div style={{color: FAINT, fontSize: px(20), marginTop: px(4)}}>… /resume for more</div>
</div>
) : null}
{whatsNew.length > 0 ? (
<div style={pane}>
<div style={paneTitle}>What’s new</div>
{whatsNew.map((item) => (
<div key={item} style={row}>
{item}
</div>
))}
<div style={{color: FAINT, fontSize: px(20), marginTop: px(4)}}>… /help for more</div>
</div>
) : null}
</div>
</div>
</div>
{/* The prompt: a rule, then the line itself. Resting, it shows the
tool's own placeholder; given a command, the typing is the shot. */}
<div style={{background: EDGE, height: px(1.5), marginTop: px(56)}} />
<div style={{alignItems: "center", display: "flex", gap: px(14), marginTop: px(18), paddingLeft: px(4)}}>
<span style={{color: FAINT, fontSize: px(26)}}>></span>
{command ? (
<span style={{color: INK, fontSize: px(26), whiteSpace: "pre"}}>
{typed.text}
{sent ? null : (
<span
style={{
background: typed.caret ? INK : "transparent",
display: "inline-block",
height: px(28),
transform: `translateY(${px(5)}px)`,
width: px(13),
}}
/>
)}
</span>
) : (
<span style={{color: FAINT, fontSize: px(26), whiteSpace: "pre"}}>
<span
style={{
background: Math.floor(frame / 16) % 2 === 0 ? INK : "transparent",
display: "inline-block",
height: px(28),
marginRight: px(2),
transform: `translateY(${px(5)}px)`,
width: px(13),
}}
/>
try "edit <filepath> to …"
</span>
)}
</div>
{sent ? (
<div
style={{
alignItems: "center",
color: FAINT,
display: "flex",
fontSize: px(21),
gap: px(12),
marginTop: px(24),
paddingLeft: px(6),
}}
>
<span style={{color: ACCENT}}>{spinner}</span>
<span style={{color: INK}}>{verb}…</span>
<span>
({elapsed}s · ↑ {spent} tokens · esc to interrupt)
</span>
</div>
) : null}
{steps.length > 0 ? (
<div style={{display: "grid", gap: px(14), marginTop: px(24), paddingLeft: px(6)}}>
{steps.map((step, index) => {
// One line at a time: the agent reports as it goes, and a block
// arriving at once would read as a transcript instead of work.
const at = interpolate(frame, [sentAt + 10 + index * 14, sentAt + 22 + index * 14], [0, 1], {
easing: Easing.standard,
});
return (
<div key={step.line} style={{opacity: at}}>
<div style={{alignItems: "baseline", display: "flex", gap: px(12)}}>
<span style={{color: ACCENT, fontSize: px(20)}}>⏺</span>
<span style={{color: INK, fontSize: px(22)}}>{step.line}</span>
</div>
{step.detail ? (
<div style={{alignItems: "baseline", display: "flex", gap: px(12), marginTop: px(6), paddingLeft: px(32)}}>
<span style={{color: FAINT, fontSize: px(20)}}>⎿</span>
<span style={{color: FAINT, fontSize: px(21)}}>{step.detail}</span>
</div>
) : null}
</div>
);
})}
</div>
) : null}
</div>
</Fill>
);
};
videos/components/claude-code/claude-code.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {ClaudeCode} from "./claude-code";
export default defineComponentPreview({
title: "Claude Code",
category: "Agents",
description: "A Claude Code session from prompt to response.",
component: ClaudeCode,
canvas: {width: 1920, height: 1080, duration: "9s"},
controls: {
command: {type: "text", defaultValue: "add a changelog video to the marketing site", maxLength: 90},
name: {type: "text", defaultValue: "", maxLength: 24},
cwd: {type: "text", defaultValue: "~/code/odori", maxLength: 40},
model: {type: "text", defaultValue: "Fable 5", maxLength: 24},
plan: {type: "text", defaultValue: "Max 20x", maxLength: 24},
verb: {type: "text", defaultValue: "Working", maxLength: 24},
charactersPerSecond: {type: "number", defaultValue: 22, min: 8, max: 60, step: 1},
},
examples: [
{
name: "Default",
props: {
response: [
{line: "Read videos/changelog/video.tsx", detail: "Read 84 lines (ctrl+o to expand)"},
{line: "Added the release cards and wired the cue"},
],
},
},
{name: "Resting", props: {command: ""}},
{
name: "Investigating",
props: {
command: "why is the export 40MB bigger than yesterday?",
verb: "Investigating",
response: [
{line: "Read packages/odori-cli/src/render.ts", detail: "Read 212 lines (ctrl+o to expand)"},
{line: "Compared both manifests", detail: "2 differences found"},
{line: "The toolchain pin changed the encoder defaults."},
],
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {ClaudeCode} from "../components/claude-code/claude-code";
<Video>
<Scene id="claude-code" duration="9s">
<ClaudeCode
command="add a changelog video to the marketing site"
name=""
cwd="~/code/odori"
model="Fable 5"
plan="Max 20x"
response={[
{
line: "Read videos/changelog/video.tsx",
detail: "Read 84 lines (ctrl+o to expand)"
},
{
line: "Added the release cards and wired the cue"
}
]}
verb="Working"
charactersPerSecond={22}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Agents
- Recommended duration
- 270 frames · 9s
- Minimum duration
- 120 frames · 4s
- Entrance
- 16 frames
- Exit
- 12 frames
- Reduced motion
- command shown whole, caret still
- Requires
- mono font
- Content limits
- command ≤ 90, cwd ≤ 40, model ≤ 24
Props
| Prop | Type | Default | Required |
|---|---|---|---|
commandWhat gets typed at the prompt. Empty shows the resting placeholder. | string | — | — |
nameWhose terminal this is. Empty keeps the greeting impersonal. | string | "" | — |
cwdThe working directory, as the welcome pane states it. | string | "~/code/odori" | — |
modelModel and plan, joined with a dot the way the tool prints them. | string | "Fable 5" | — |
plan | string | "Max 20x" | — |
version | string | "v2.0.0" | — |
recentActivityThe right-hand panes. Empty arrays drop a pane entirely. | ActivityRow[] | — | — |
whatsNew | string[] | ["/agents to create subagents" | — |
responseSteps the agent prints back once the instruction is sent. | (ClaudeCodeStep | string)[] | [] | — |
verbThe word the spinner uses while it works. | string | "Working" | — |
charactersPerSecond | number | 22 | — |
Related components
All componentsChatGPTA ChatGPT composer with an animated prompt.ClaudeA Claude composer with an animated prompt.fxA coding agent session in a terminal.OpenCodeAn open coding agent's terminal session, with the query typed under its wordmark.v0A v0 composer with an animated prompt.Absolute stageFull-frame surface with stable clipping and positioning.