| name | tool-animation |
| description | Add custom animations to tools in swarm-cli TUI. Use when creating spinner animations for tools, adding visual feedback during tool execution, or customizing how tools appear in the terminal interface. |
| allowed-tools | Read, Edit, Glob, Grep |
Tool Animation Guide
Create custom animations for tools in the swarm-cli terminal UI. This skill covers the complete pattern for adding animated visual feedback during tool execution.
When to Use This Skill
- Adding a new tool animation to an existing or new tool
- Customizing how a tool displays during pending/running/completed states
- Creating breathing, morphing, or wave animations for tools
- Understanding the tool animation architecture
Architecture Overview
Tool animations in swarm-cli consist of three parts:
- Animation Components (
tool-animations.tsx) - SolidJS components with animated spinners, icons, and state machines - Tool Registration (
session/index.tsx) - Maps tools to their animation components viaToolRegistry - Spinner Definitions (
tool-spinner-map.ts) - Optional: defines spinner presets for tools
File Locations
| File | Purpose |
|---|---|
packages/swarm/src/cli/cmd/tui/ui/tool-animations.tsx |
Animation component definitions |
packages/swarm/src/cli/cmd/tui/routes/session/index.tsx |
Tool registration and rendering |
packages/swarm/src/cli/cmd/tui/ui/tool-spinner-map.ts |
Tool-to-spinner mapping |
packages/swarm/src/cli/cmd/tui/ui/spinner-definitions.ts |
Reusable spinner frame definitions |
Step 1: Create the Animation Component
Add your animation to tool-animations.tsx. Follow this pattern:
Animation State Machine Pattern
Each tool animation typically has four states matching tool execution:
// ============================================================================
// MY-TOOL ANIMATION - Description of the visual theme
// Pattern: brief description of the animation
// ============================================================================
// Color palette - use Catppuccin colors for consistency
const MYTOOL_COLOR = RGBA.fromHex("#cba6f7") // Pick from Catppuccin palette
const MYTOOL_COLORS = {
primary: MYTOOL_COLOR,
success: RGBA.fromHex("#a6e3a1"), // Green for success
error: RGBA.fromHex("#f38ba8"), // Pink for error
dim: RGBA.fromHex("#6c7086"), // Dimmed for resolved state
}
// Icon sequence for morphing (use Nerd Font icons)
const MYTOOL_ICONS = ["", "", ""] // Example file icons
Morphing Icon Component
/**
* MyToolMorph - Single icon morphing through states with breathing opacity
*/
export function MyToolMorph(props: { color?: RGBA }) {
const baseColor = props.color ?? MYTOOL_COLOR
const [frame, setFrame] = createSignal(0)
const [opacity, setOpacity] = createSignal(0.7)
// Morph through icons - 400ms per icon for smooth transitions
const morphInterval = setInterval(() => {
setFrame((f) => (f + 1) % MYTOOL_ICONS.length)
}, 400)
// Breathing opacity - 2 second cycle for zen feel
const breathInterval = setInterval(() => {
const now = Date.now()
const phase = ((now / 2000) * Math.PI * 2) % (Math.PI * 2)
setOpacity(0.5 + (Math.sin(phase) + 1) * 0.25)
}, 50)
onCleanup(() => {
clearInterval(morphInterval)
clearInterval(breathInterval)
})
const color = () => RGBA.fromInts(
baseColor.r * 255,
baseColor.g * 255,
baseColor.b * 255,
opacity() * 255
)
return <span style={{ fg: color() }}>{MYTOOL_ICONS[frame()]}</span>
}
Breathing Text Component
/**
* MyToolText - Breathing "MyTool" text synchronized with icon
*/
export function MyToolText(props: { color?: RGBA }) {
const baseColor = props.color ?? MYTOOL_COLOR
const [opacity, setOpacity] = createSignal(0.7)
const interval = setInterval(() => {
const now = Date.now()
const phase = ((now / 2000) * Math.PI * 2) % (Math.PI * 2)
setOpacity(0.5 + (Math.sin(phase) + 1) * 0.25)
}, 50)
onCleanup(() => clearInterval(interval))
const color = () => RGBA.fromInts(
baseColor.r * 255,
baseColor.g * 255,
baseColor.b * 255,
opacity() * 255
)
return <span style={{ fg: color() }}>mytool</span>
}
State Components
/**
* MyToolPending - Initial state, just spinner + breathing text
*/
export function MyToolPending() {
return (
<box gap={0}>
<text>
<MyToolMorph color={MYTOOL_COLOR} /> <MyToolText color={MYTOOL_COLOR} />
</text>
</box>
)
}
/**
* MyToolRunning - Active state with input display + elapsed time
*/
export function MyToolRunning(props: { someInput?: string; startTime?: number }) {
const { theme } = useTheme()
return (
<box gap={0}>
<text>
<MyToolMorph color={MYTOOL_COLOR} /> <MyToolText color={MYTOOL_COLOR} />
<Show when={props.someInput}>
{" "}<span style={{ fg: theme.text }}>{props.someInput}</span>
</Show>
<Show when={props.startTime}>
{" "}<ElapsedTime startTime={props.startTime!} color={theme.textMuted} />
</Show>
</text>
</box>
)
}
/**
* MyToolResolved - Completed state with success/error + metadata
*/
export function MyToolResolved(props: {
success: boolean
someInput?: string
executionTime?: number
resultCount?: number
}) {
const { theme } = useTheme()
const icon = props.success ? "✓" : "✗"
const iconColor = props.success ? MYTOOL_COLORS.success : MYTOOL_COLORS.error
const dimmedColor = RGBA.fromInts(
MYTOOL_COLOR.r * 255,
MYTOOL_COLOR.g * 255,
MYTOOL_COLOR.b * 255,
153 // ~60% opacity
)
const formatTime = (ms: number) => {
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`
}
return (
<box gap={0}>
<text>
<span style={{ fg: iconColor, bold: true }}>{icon}</span>
{" "}<span style={{ fg: dimmedColor }}>mytool</span>
<Show when={props.someInput}>
{" "}<span style={{ fg: dimmedColor }}>{props.someInput}</span>
</Show>
<Show when={props.resultCount !== undefined}>
{" "}<span style={{ fg: theme.textMuted }}>•</span>
{" "}<span style={{ fg: theme.textMuted }}>{props.resultCount} items</span>
</Show>
<Show when={props.executionTime !== undefined}>
{" "}<span style={{ fg: theme.textMuted }}>{formatTime(props.executionTime!)}</span>
</Show>
</text>
</box>
)
}
Complete Tool Animation (State Machine)
/**
* MyToolToolAnimation - Complete state machine for mytool
*/
export function MyToolToolAnimation(props: {
status: "pending" | "running" | "completed" | "error"
someInput?: string
startTime?: number
executionTime?: number
resultCount?: number
}) {
return (
<Switch>
<Match when={props.status === "pending"}>
<MyToolPending />
</Match>
<Match when={props.status === "running"}>
<MyToolRunning someInput={props.someInput} startTime={props.startTime} />
</Match>
<Match when={props.status === "completed"}>
<MyToolResolved
success={true}
someInput={props.someInput}
executionTime={props.executionTime}
resultCount={props.resultCount}
/>
</Match>
<Match when={props.status === "error"}>
<MyToolResolved
success={false}
someInput={props.someInput}
executionTime={props.executionTime}
/>
</Match>
</Switch>
)
}
Step 2: Export the Animation
Add your animation to the exports in tool-animations.tsx:
export {
// ... existing exports
MyToolToolAnimation,
}
Step 3: Register the Tool Animation
In session/index.tsx, register your tool with ToolRegistry:
Import the Animation
At the top of the file, add the import:
import {
// ... existing imports
MyToolToolAnimation,
} from "@tui/ui/tool-animations"
Register the Tool
Add the registration after other tool registrations (around line 2900+):
ToolRegistry.register({
name: "mytool", // Must match the tool name exactly
container: "inline", // "inline" for single-line, "block" for multi-line
render(props) {
const executionTime = createMemo(() => {
if (props.state.status === "completed" && props.state.time) {
return props.state.time.end - props.state.time.start
}
return undefined
})
const startTime = createMemo(() => {
if (props.state.status !== "pending" && props.state.time?.start) {
return props.state.time.start
}
return undefined
})
return (
<ToolCard status={props.state.status} inline={true}>
<MyToolToolAnimation
status={props.state.status}
someInput={props.input.someField as string | undefined}
startTime={startTime()}
executionTime={executionTime()}
resultCount={props.metadata.count as number | undefined}
/>
</ToolCard>
)
},
})
Step 4: (Optional) Add Spinner Mapping
If you want a simple spinner without full custom animation, add to tool-spinner-map.ts:
export const TOOL_SPINNER_MAP: Record<string, ToolSpinnerConfig> = {
// ... existing entries
mytool: {
spinner: "braille_fade", // Use existing spinner name
fallback: "snake_orbit",
},
}
Examples from Existing Tools
Inline Tool (Single Line) - Memory Tool
// Registration
ToolRegistry.register<typeof MemoryTool>({
name: "memory",
container: "inline", // Single-line display
render(props) {
const executionTime = createMemo(() => {
if (props.state.status === "completed" && props.state.time) {
return props.state.time.end - props.state.time.start
}
return undefined
})
return (
<ToolCard status={props.state.status} inline={true}>
<MemoryToolAnimation
status={props.state.status}
section={props.input.section}
content={props.input.content}
startTime={props.state.status !== "pending" && props.state.time?.start
? props.state.time.start : undefined}
executionTime={executionTime()}
/>
</ToolCard>
)
},
})
Block Tool (Multi-Line) - TodoWrite Tool
// Registration
ToolRegistry.register<typeof TodoWriteTool>({
name: "todowrite",
container: "block", // Multi-line display with content
render(props) {
const { theme } = useTheme()
const isRunning = createMemo(() => props.state.status === "running")
const isCompleted = createMemo(() => props.state.status === "completed")
return (
<ToolCard status={props.state.status} inline={false}>
<box gap={1}>
<ToolTitle
icon="✓"
fallback="Updating todos..."
when={props.input.todos?.length}
status={isRunning() ? "running" : undefined}
color={TOOL_COLORS.todo}
spinner="todowrite_spinner"
>
<span style={{ fg: theme.accent, bold: true }}>Todo List</span>
<Show when={isRunning()}>{" "}<StreamingDots /></Show>
<Show when={isCompleted()}>{" "}<SuccessCheckmark /></Show>
</ToolTitle>
{/* Additional content for block display */}
<Show when={props.input.todos?.length}>
<box>
<For each={props.input.todos ?? []}>
{(todo) => (
<text style={{ fg: theme.textMuted }}>
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
</text>
)}
</For>
</box>
</Show>
</box>
</ToolCard>
)
},
})
Animation Patterns Reference
Breathing Animation
Smooth opacity pulsing (0.5 -> 1.0 -> 0.5):
const breathInterval = setInterval(() => {
const now = Date.now()
const phase = ((now / 2000) * Math.PI * 2) % (Math.PI * 2)
setOpacity(0.5 + (Math.sin(phase) + 1) * 0.25) // Range: 0.5 - 1.0
}, 50)
Wave Animation (Multiple Icons)
Staggered breathing for 3 icons:
const interval = setInterval(() => {
const now = Date.now()
const newOpacities = [0, 1, 2].map((i) => {
const phase = ((now / 1800) * Math.PI * 2 + i * 0.7) % (Math.PI * 2)
return 0.4 + (1 - 0.4) * ((Math.sin(phase) + 1) / 2)
})
setOpacities(newOpacities)
}, 50)
Icon Morphing
Cycle through icon sequence:
const morphInterval = setInterval(() => {
setFrame((f) => (f + 1) % icons.length)
}, 400) // 400ms per icon = 2.4s full cycle for 6 icons
Catppuccin Color Palette
Use these colors for consistency:
| Color | Hex | Use Case |
|---|---|---|
| Rosewater | #f5e0dc |
Subtle highlights |
| Flamingo | #f2cdcd |
Soft accents |
| Pink | #f5c2e7 |
Feminine tools |
| Mauve | #cba6f7 |
Purple/search tools |
| Red | #f38ba8 |
Errors, grep |
| Maroon | #eba0ac |
Warnings |
| Peach | #fab387 |
Write operations |
| Yellow | #f9e2af |
Warnings, todos |
| Green | #a6e3a1 |
Success, write |
| Teal | #94e2d5 |
Read operations |
| Sky | #89dceb |
Batch operations |
| Sapphire | #74c7ec |
Web fetch |
| Blue | #89b4fa |
Info, navigation |
| Lavender | #b4befe |
Ask/question |
Common Nerd Font Icons
| Icon | Code | Use Case |
|---|---|---|
| | \u{f0219} |
File document |
| | \u{f021e} |
Search/find |
| | \u{f0349} |
Magnify glass |
| | \u{f02d7} |
Help circle |
| | \u{f0361} |
Message |
| | \u{f173a} |
Message question |
\u{ea68} |
Git/source control | |
| | \u{f0133} |
Checkbox checked |
| | \u{f0130} |
Checkbox empty |
| ✓ | \u{2713} |
Checkmark |
| ✗ | \u{2717} |
X mark |
Checklist
When adding a new tool animation:
- Create animation component in
tool-animations.tsx - Export the main animation component
- Import animation in
session/index.tsx - Register tool with
ToolRegistry.register() - Test all four states: pending, running, completed, error
- Verify colors match Catppuccin palette
- Check timing feels smooth (not too fast, not too slow)
- Ensure metadata (time, counts) displays correctly
Tips
- Use
createMemo()for computed values that depend on props - Always call
onCleanup()to clear intervals - Match animation timing to existing tools (2s breathing cycle, 400ms morph)
- Use
<Show when={...}>for conditional rendering - Prefer
inlinecontainer for simple tools,blockfor tools with content