Buddy, the Claude Code mascot, living above the prompt: it walks, waves when it is your turn, paces while the model thinks, types along when a file is edited…

Give your Claude Code prompt a tiny coworker.
Buddy (you may know it as Clawd, the Claude Code mascot) moves in right above your input box and reacts to whatever Claude is up to. It paces when Claude thinks, sprints when your commands run, types when files change, and hurls your pull requests into orbit.

Type this into the Claude Code prompt:
/plugin install buddy-go --marketplace magiclin99/buddy-go
That's it. Buddy shows up above the prompt and starts wandering.

A few steps out, a few steps back, a . o O bubble beside its head, and a pause now and then to shut its eyes and really think. Five seconds in, the pacing gets quicker. Fifteen seconds in, Buddy goes red and starts to sweat. When the answer finally lands, both hands go up: !

Every Bash command is a race. The ground rolls in, trees fly past, and Buddy tows your command behind it on a banner. Commands that follow each other are one long run. It ends with a green Finish! and a cheer, or a red Oops! and a stumble. Run for more than ten seconds and Buddy starts gasping.

Whenever Claude edits or writes a file, Buddy stops and hammers out a few lines of code, with the file's name underneath.

gh pr create deserves a ceremony. Buddy raises its hands, charges up a ball with your PR inside, and throws it clean off the screen.

When Claude ends on a question, Buddy vanishes, pops up at the left edge, and waves until you answer. No more staring at a prompt that was waiting for you.

Back and forth, above the prompt, minding its own business.
You don't have to wait for Claude. Type a cheat straight into the prompt, with no slash, and Buddy performs on demand. Buddy catches cheats before they are sent, so they never reach the model.
| Type this | Buddy will |
|---|---|
buddy:think 20 | pace and think for 20 seconds (8 if you leave the number out) |
buddy:run npm test | sprint for six seconds with npm test on the banner |
buddy:edit main.ts | type into main.ts |
buddy:send-pr | launch a PR |
buddy:your-turn | wave at you |
claude plugin marketplace update buddy-go
claude plugin update buddy-go@buddy-go
Then type /reload-plugins in any session that is already open.
Every animation is one small file, and drawing is plain math: given a frame number and where Buddy stands, return the characters and colors to draw. No timers, no engine calls.
hooks/animations/ that exports an Animation: a name, a priority, how many frames, how long each lasts, and a draw function. Export a trigger function and a cheat name if it needs them.ANIMATIONS in hooks/animations/index.ts, and its trigger to registerTriggers.Run it from your folder while you work:
claude --plugin-dir /path/to/buddy-go
claude plugin validate .
claude plugin test .
hooks/
├── register.tsx wiring: the clock, the published state, cheats, drawing
├── animations/ one file per animation, plus the registry in index.ts
│ ├── walk.ts
│ ├── wait.ts
│ ├── think.ts
│ ├── edit.ts
│ ├── run.ts
│ └── launch.ts
└── lib/
├── player.ts what is playing, what is queued, where Buddy stands
├── body.ts Buddy's body parts
├── render.tsx turns an animation's rows into elements
└── frame.ts shared types
tests/
├── player.test.ts the player's rules
├── animations.test.ts what each animation draws, frame by frame
└── walk.test.tsx events in, pixels out
demo/
├── record.sh records every animation as a GIF
├── settings.json recording only: load this folder's copy alone
└── *.gif the GIFs on this page
The GIFs are recorded in a real Claude Code session by typing each cheat. You need vhs and ffmpeg (brew install vhs ffmpeg).
demo/record.sh # all of them
demo/record.sh think # just one
To record a new animation, add a line at the bottom of demo/record.sh: record <name> "<cheat>" <seconds>.
buddy-go is an unofficial fan mod and is not affiliated with Anthropic.
hooks/register.tsx 106 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { IDLE, byCheat, byName, registerTriggers } from './animations'
5import { stageAt } from './animations/walk'
6import { BLANK_ROW } from './lib/frame'
7import type { Director } from './lib/frame'
8import { createPlayer } from './lib/player'
9import { renderRows } from './lib/render'
10
11// The one value the draw reads: the player's show, published after every change.
12const show = atom({ plugin: 'buddy-go', key: 'show' } as const, null)
13
14const player = createPlayer(IDLE, byName)
15
16let timer: Timer | undefined
17let writing: Promise<void> = Promise.resolve()
18// Set once the session has a `$` to tick and publish with; until then a request only changes the player.
19let wake: (() => void) | undefined
20
21// Chained so a slow write can never land after a newer one and leave a stale frame on screen.
22const publish = ($: EngineInterface) => {
23 const snapshot = player.show()
24
25 writing = writing
26 .then(() => update($, show, () => snapshot))
27 .then(
28 () => undefined,
29 () => undefined,
30 )
31}
32
33const schedule = ($: EngineInterface) => {
34 timer?.cancel()
35 timer = $.clock.after(player.delay(), () => {
36 player.tick()
37 publish($)
38 schedule($)
39 })
40}
41
42const director: Director = {
43 play: (animation, params) => {
44 player.play(animation, params)
45 wake?.()
46 },
47 stop: animation => {
48 player.stop(animation)
49 wake?.()
50 },
51 frameOf: animation => player.frameOf(animation),
52}
53
54export const register: Register = on => {
55 on('session.start', async ($, e, next) => {
56 // Nothing draws the band without a person at a terminal, so nothing needs to tick.
57 if (!e.isInteractive) {
58 return next(e)
59 }
60
61 // A reload drops the timer and the player's memory; the show in state says where it was.
62 const kept = await read($, show)
63
64 if (kept !== null && wake === undefined) {
65 player.restore(kept)
66 }
67
68 wake = () => {
69 publish($)
70 schedule($)
71 }
72 wake()
73
74 return next(e)
75 })
76
77 registerTriggers(on, director)
78
79 on('prompt.submit', ($, e, next) => {
80 const cheat = byCheat(e.text)
81
82 if (cheat === undefined) {
83 return next(e)
84 }
85
86 director.play(cheat.animation, cheat.params)
87
88 return { drop: cheat.reply }
89 })
90
91 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
92 if (e.props.hasSurvey) {
93 return next(e)
94 }
95
96 const shown = (await read($, show)) ?? player.show()
97 const animation = byName(shown.name) ?? IDLE
98
99 // A row of air over the mascot's head, whatever it is doing.
100 return renderRows($.ui.resolve(e), [
101 BLANK_ROW,
102 ...animation.draw(shown.frame, stageAt(shown.idleFrame, e.props.bodyColumns, shown.idleFrom), shown.params),
103 ])
104 })
105}
106hooks/animations/index.ts 44 lines1import type { Animation, Triggers } from '../lib/frame'
2import { edit, editTriggers } from './edit'
3import { launch, launchTriggers } from './launch'
4import { run, runTriggers } from './run'
5import { think, thinkTriggers } from './think'
6import { wait, waitTriggers } from './wait'
7import { walk } from './walk'
8
9export const IDLE = walk
10
11// An animation is a file beside these, a line here for it, and a line below for its triggers; its cheat comes with it.
12export const ANIMATIONS: readonly Animation[] = [walk, launch, wait, edit, run, think]
13
14export const registerTriggers: Triggers = (on, director) => {
15 launchTriggers(on, director)
16 editTriggers(on, director)
17 runTriggers(on, director)
18 thinkTriggers(on, director)
19 waitTriggers(on, director)
20}
21
22export const CHEAT_PREFIX = 'buddy:'
23
24export const byName = (name: string) => ANIMATIONS.find(animation => animation.name === name)
25
26export const byCheat = (typed: string) => {
27 const text = typed.trim()
28
29 if (!text.startsWith(CHEAT_PREFIX)) {
30 return undefined
31 }
32
33 const [name = '', ...rest] = text.slice(CHEAT_PREFIX.length).split(/\s+/)
34 const animation = ANIMATIONS.find(candidate => candidate.cheat?.name === name)
35
36 if (animation?.cheat === undefined) {
37 return undefined
38 }
39
40 const params = animation.cheat.params?.(rest.join(' ')) ?? {}
41
42 return { animation, params, reply: animation.cheat.reply(params) }
43}
44hooks/animations/walk.ts 42 lines1import { ARMS, FEET, SPRITE_COLUMNS, mascot } from '../lib/body'
2import type { Animation, IdleFrom, Stage } from '../lib/frame'
3
4export const STEP_MS = 300
5
6// The column that puts the mascot in the middle of the band.
7export const middle = (columns: number) => Math.max(0, Math.floor((columns - SPRITE_COLUMNS) / 2))
8
9export const STRIDE = [FEET.both, FEET.left, FEET.both, FEET.right] as const
10
11// The band's width is known only while drawing, so the idle show counts steps and the draw folds them into a there-and-back walk.
12export const stageAt = (idleFrame: number, columns: number, from: IdleFrom = 'left-edge'): Stage => {
13 const range = Math.max(0, columns - SPRITE_COLUMNS)
14
15 if (range === 0) {
16 return { columns, x: 0, facing: 'right' }
17 }
18
19 const position = (idleFrame + (from === 'center' ? middle(columns) : 0)) % (range * 2)
20
21 return position < range
22 ? { columns, x: position, facing: 'right' }
23 : { columns, x: range * 2 - position, facing: 'left' }
24}
25
26// Where the walk would be `steps` on from a stage it drew.
27export const ahead = (stage: Stage, steps: number): Stage => {
28 const range = Math.max(0, stage.columns - SPRITE_COLUMNS)
29
30 return stageAt((stage.facing === 'right' ? stage.x : range * 2 - stage.x) + steps, stage.columns)
31}
32
33// What plays whenever nothing else does.
34export const walk: Animation = {
35 name: 'walk',
36 priority: 0,
37 frames: null,
38 frameMs: () => STEP_MS,
39
40 draw: (frame, stage) => mascot(stage.x, ARMS.down, stage.facing, STRIDE[frame % STRIDE.length] ?? FEET.both),
41}
42hooks/lib/frame.ts 81 lines1import type { On } from 'claude-code'
2
3export type Span = {
4 text: string
5 color?: string
6 backgroundColor?: string
7 dimColor?: true
8 bold?: true
9 // Blank columns before the span; a Text's own leading spaces are not kept everywhere.
10 gap?: number
11}
12
13export type Row = { indent: number; spans: readonly Span[] }
14
15export type Stage = { columns: number; x: number; facing: 'left' | 'right' }
16
17// Where the walk starts counting its steps from.
18export type IdleFrom = 'left-edge' | 'center'
19
20export type Params = Readonly<Record<string, string>>
21
22// What an animation's triggers hold: they say what to play and never touch timers, state or `$`.
23export type Director = {
24 play: (animation: Animation, params?: Params) => void
25 stop: (animation: Animation) => void
26 // The frame it is on, when it is the one on stage.
27 frameOf: (animation: Animation) => number | undefined
28}
29
30export type Animation = {
31 name: string
32 // The higher one plays first; a looping one that gets pushed aside comes back afterwards.
33 priority: number
34 // null loops until something stops it.
35 frames: number | null
36 // How long this play of it is, where that depends on what it was asked to play; without it, `frames`.
37 framesFor?: (params: Params) => number | null
38 frameMs: (frame: number) => number
39 // Where it leaves the mascot when it ends at `frame`: where it stood (as when absent), where the walk
40 // starts over from, or that many steps further along the walk.
41 exit?: (frame: number, params: Params) => 'in-place' | IdleFrom | { steps: number }
42 cheat?: { name: string; reply: (params: Params) => string; params?: (typed: string) => Params }
43 draw: (frame: number, stage: Stage, params: Params) => Row[]
44}
45
46// The events that start and stop an animation: a named function its file exports, since the engine only lets `on` be handed to one.
47export type Triggers = (on: On, director: Director) => void
48
49// A frame number an animation was handed in its params.
50export const frameIn = (params: Params, key: string) => {
51 const frame = Number(params[key] ?? Number.NaN)
52
53 return Number.isFinite(frame) ? frame : undefined
54}
55
56export const BLANK_ROW: Row = { indent: 0, spans: [] }
57
58export const width = (spans: readonly Span[]) =>
59 spans.reduce((sum, span) => sum + (span.gap ?? 0) + span.text.length, 0)
60
61// Moves a run of spans right by `gap` columns: the blank goes before its first span.
62export const withGap = (spans: readonly Span[], gap: number): Span[] =>
63 gap === 0 ? [...spans] : spans.map((span, index) => (index === 0 ? { ...span, gap: (span.gap ?? 0) + gap } : span))
64
65export type Piece = { at: number; spans: readonly Span[] }
66
67// One row from pieces that each say which column they start at; they must not overlap.
68export const layRow = (pieces: readonly Piece[]): Row => {
69 const laid = pieces.filter(piece => piece.spans.length > 0).sort((a, b) => a.at - b.at)
70 const indent = laid[0]?.at ?? 0
71 const spans: Span[] = []
72 let end = indent
73
74 for (const piece of laid) {
75 spans.push(...withGap(piece.spans, piece.at - end))
76 end = piece.at + width(piece.spans)
77 }
78
79 return { indent, spans }
80}
81hooks/lib/player.ts 147 lines1import type { Animation, IdleFrom, Params } from './frame'
2
3export type Show = {
4 name: string
5 frame: number
6 params: Record<string, string>
7 idleFrame: number
8 idleFrom?: IdleFrom
9}
10
11type Act = { animation: Animation; params: Params }
12
13// What is on stage, what waits behind it, and where the mascot stands. No timers, no state, no `$`:
14// the caller ticks it and draws what `show()` says.
15export const createPlayer = (idle: Animation, find: (name: string) => Animation | undefined) => {
16 let act: Act | undefined
17 let frame = 0
18 let idleFrame = 0
19 let idleFrom: IdleFrom = 'left-edge'
20 let waiting: Act[] = []
21
22 const framesOf = (held: Act) =>
23 held.animation.framesFor === undefined ? held.animation.frames : held.animation.framesFor(held.params)
24
25 const begin = (next: Act) => {
26 act = next
27 frame = 0
28 }
29
30 const leave = () => {
31 const left = act?.animation.exit?.(frame, act.params) ?? 'in-place'
32
33 if (left === 'in-place') {
34 return
35 }
36
37 if (typeof left === 'object') {
38 idleFrame += left.steps
39
40 return
41 }
42
43 idleFrame = 0
44 idleFrom = left
45 }
46
47 const enqueue = (next: Act) => {
48 waiting = [...waiting.filter(held => held.animation.name !== next.animation.name), next]
49 }
50
51 const finish = () => {
52 leave()
53 act = undefined
54 frame = 0
55
56 // The highest priority goes next; among equals, the one that has waited longest.
57 const next = waiting.reduce<Act | undefined>(
58 (best, held) => (best === undefined || held.animation.priority > best.animation.priority ? held : best),
59 undefined,
60 )
61
62 if (next !== undefined) {
63 waiting = waiting.filter(held => held !== next)
64 begin(next)
65 }
66 }
67
68 return {
69 play: (animation: Animation, params: Params = {}) => {
70 if (animation.name === idle.name) {
71 return
72 }
73
74 if (act === undefined) {
75 begin({ animation, params })
76
77 return
78 }
79
80 if (act.animation.name === animation.name) {
81 // Asked again while playing: it carries on from where it is with the new params.
82 act = { animation, params }
83
84 return
85 }
86
87 if (act.animation.priority > animation.priority) {
88 enqueue({ animation, params })
89
90 return
91 }
92
93 if (framesOf(act) === null) {
94 enqueue(act)
95 }
96
97 leave()
98 begin({ animation, params })
99 },
100
101 stop: (animation: Animation) => {
102 waiting = waiting.filter(held => held.animation.name !== animation.name)
103
104 if (act?.animation.name === animation.name) {
105 finish()
106 }
107 },
108
109 frameOf: (animation: Animation) => (act?.animation.name === animation.name ? frame : undefined),
110
111 tick: () => {
112 if (act === undefined) {
113 idleFrame += 1
114
115 return
116 }
117
118 frame += 1
119
120 const frames = framesOf(act)
121
122 if (frames !== null && frame >= frames) {
123 finish()
124 }
125 },
126
127 delay: () => (act === undefined ? idle.frameMs(idleFrame) : act.animation.frameMs(frame)),
128
129 show: (): Show =>
130 act === undefined
131 ? { name: idle.name, frame: idleFrame, params: {}, idleFrame, idleFrom }
132 : { name: act.animation.name, frame, params: { ...act.params }, idleFrame, idleFrom },
133
134 // Picks up a show a reload left in state; what was waiting behind it is gone.
135 restore: (kept: Show) => {
136 const animation = find(kept.name)
137 idleFrame = kept.idleFrame
138 idleFrom = kept.idleFrom ?? 'left-edge'
139 waiting = []
140 act = animation === undefined || animation.name === idle.name ? undefined : { animation, params: kept.params }
141 frame = act === undefined ? 0 : kept.frame
142 },
143 }
144}
145
146export type Player = ReturnType<typeof createPlayer>
147hooks/lib/render.tsx 49 lines1import type { Elements, RenderElement } from 'claude-code'
2
3import type { Row, Span } from './frame'
4
5type Table = Pick<Elements['terminal'], 'Box' | 'Text'> | Pick<Elements['desktop'], 'Box' | 'Text'>
6
7const textProps = (span: Span) => {
8 const props: Record<string, unknown> = {}
9
10 if (span.color !== undefined) {
11 props.color = span.color
12 }
13
14 if (span.backgroundColor !== undefined) {
15 props.backgroundColor = span.backgroundColor
16 }
17
18 if (span.dimColor) {
19 props.dimColor = true
20 }
21
22 if (span.bold) {
23 props.bold = true
24 }
25
26 return props
27}
28
29// The one place frame data becomes elements: every animation draws through it.
30export const renderRows = ({ Box, Text }: Table, rows: readonly Row[]) =>
31 // `h` is typed for any node; a Box with children is always an element.
32 h(
33 Box,
34 { flexDirection: 'column' },
35 ...rows.map(row =>
36 h(
37 Box,
38 { height: 1, marginLeft: row.indent },
39 ...(row.spans.length === 0
40 ? [h(Text, null, ' ')]
41 : row.spans.map(span => {
42 const text = h(Text, textProps(span), span.text)
43
44 return span.gap === undefined ? text : h(Box, { marginLeft: span.gap }, text)
45 })),
46 ),
47 ),
48 ) as RenderElement
49hooks/animations/edit.ts 115 lines1import { ARMS, FEET, SPRITE_COLUMNS, mascot } from '../lib/body'
2import type { Gaze } from '../lib/body'
3import { width, withGap } from '../lib/frame'
4import type { Animation, Params, Row, Span, Triggers } from '../lib/frame'
5
6const FRAME_MS = 120
7const LINE_FRAMES = 8
8const REST_FRAMES = 4
9const LOOK_AWAY_FRAMES = 2
10
11const LINE_COLOR = 'success'
12const CURSOR = '▌'
13const FILE_MARK = '✎ '
14const FILE_COLUMNS = 16
15const PAGE_GAP = 3
16
17const LINES = ['▬▬▬ ▬▬▬▬▬ ▬▬ ▬▬▬▬', '▬▬ ▬▬▬ ▬▬▬▬▬▬ ▬'] as const
18
19const TYPING_FRAMES = LINE_FRAMES * LINES.length
20const PAGE_COLUMNS = Math.max(...LINES.map(line => line.length + CURSOR.length), FILE_MARK.length + FILE_COLUMNS)
21
22const fileLabel = (file: string | undefined): Span[] => {
23 const name = file?.split(/[\\/]/).pop() ?? ''
24
25 if (name === '') {
26 return []
27 }
28
29 const shown = name.length > FILE_COLUMNS ? `${name.slice(0, FILE_COLUMNS - 1)}…` : name
30
31 return [{ text: `${FILE_MARK}${shown}`, color: 'inactive' }]
32}
33
34// The page's three rows: the line before, the line being typed, and the file's name.
35const page = (frame: number, file: string | undefined): Span[][] => {
36 const isTyping = frame < TYPING_FRAMES
37 const lineIndex = isTyping ? Math.floor(frame / LINE_FRAMES) : LINES.length - 1
38 const line = LINES[lineIndex] ?? LINES[0]
39 const typed = isTyping ? Math.round((line.length * (frame % LINE_FRAMES)) / (LINE_FRAMES - 1)) : line.length
40 const before = LINES[lineIndex - 1]
41 const current: Span[] = typed === 0 ? [] : [{ text: line.slice(0, typed), color: LINE_COLOR }]
42
43 if (isTyping) {
44 current.push({ text: CURSOR, color: 'text' })
45 }
46
47 return [
48 before === undefined ? [] : [{ text: before, color: LINE_COLOR, dimColor: true }],
49 current,
50 fileLabel(file),
51 ]
52}
53
54export const edit: Animation = {
55 name: 'edit',
56 priority: 2,
57 frames: TYPING_FRAMES + REST_FRAMES,
58 frameMs: () => FRAME_MS,
59 cheat: {
60 name: 'edit',
61 params: (typed): Params => (typed === '' ? {} : { file: typed }),
62 reply: params => (params.file === undefined ? 'Clawd: editing.' : `Clawd: editing ${params.file}.`),
63 },
64
65 draw: (frame, { columns, x }, params) => {
66 const isTyping = frame < TYPING_FRAMES
67 const rightOf = x + SPRITE_COLUMNS + PAGE_GAP
68 const leftOf = x - PAGE_GAP - PAGE_COLUMNS
69 const side = rightOf + PAGE_COLUMNS <= columns ? 'right' : leftOf >= 0 ? 'left' : 'none'
70 const isLookingAway = frame < TYPING_FRAMES + REST_FRAMES - LOOK_AWAY_FRAMES
71 const gaze: Gaze = side === 'none' || !isLookingAway ? 'ahead' : side
72 const arms = !isTyping ? ARMS.down : frame % 2 === 0 ? ARMS.oneUp : ARMS.otherUp
73 const body = mascot(x, arms, gaze, FEET.both)
74
75 if (side === 'none') {
76 return body
77 }
78
79 const pageRows = page(frame, params.file)
80
81 return body.map((row, index): Row => {
82 const beside = pageRows[index] ?? []
83
84 if (beside.length === 0) {
85 return row
86 }
87
88 // A Text's own leading and trailing spaces are not kept everywhere, so every blank column is a gap.
89 return side === 'right'
90 ? { indent: row.indent, spans: [...row.spans, ...withGap(beside, rightOf - row.indent - width(row.spans))] }
91 : { indent: leftOf, spans: [...beside, ...withGap(row.spans, row.indent - leftOf - width(beside))] }
92 })
93 },
94}
95
96export const editTriggers: Triggers = (on, director) => {
97 on('tool.call', { tool: 'Edit' }, ($, e, next) => {
98 director.play(edit, { file: e.file_path })
99
100 return next(e)
101 })
102
103 on('tool.call', { tool: 'Write' }, ($, e, next) => {
104 director.play(edit, { file: e.file_path })
105
106 return next(e)
107 })
108
109 on('tool.call', { tool: 'NotebookEdit' }, ($, e, next) => {
110 director.play(edit, { file: e.notebook_path })
111
112 return next(e)
113 })
114}
115hooks/animations/launch.ts 131 lines1import { ARMS, FEET, SPRITE_COLUMNS, mascot } from '../lib/body'
2import { BLANK_ROW } from '../lib/frame'
3import type { Animation, Row, Triggers } from '../lib/frame'
4
5const FRAME_MS = 100
6const CHARGE_STAGE_FRAMES = 5
7const WIND_UP_FRAMES = 5
8const FLIGHT_FRAMES = 12
9
10const BALL_COLOR = '#9be7ff'
11const BALL_COLUMNS = 12
12const BALL_ROWS = 5
13const BALL_MIDDLE_ROW = 2
14const SPARK_REACH = 3
15const TRAIL = '─ ═ ━━━'
16
17// Smallest first, each bottom-aligned in the ball's 12 by 5 box so it grows upward, away from the head.
18const BALLS = [
19 [{ row: 4, indent: 6, glyphs: '∘' }],
20 [
21 { row: 3, indent: 4, glyphs: '╭──╮' },
22 { row: 4, indent: 4, glyphs: '╰──╯' },
23 ],
24 [
25 { row: 2, indent: 3, glyphs: '╭────╮' },
26 { row: 3, indent: 2, glyphs: '│ PR │' },
27 { row: 4, indent: 3, glyphs: '╰────╯' },
28 ],
29 [
30 { row: 0, indent: 2, glyphs: '╭──────╮' },
31 { row: 1, indent: 1, glyphs: '╱ ╲' },
32 { row: 2, indent: 0, glyphs: '│ PR │' },
33 { row: 3, indent: 1, glyphs: '╲ ╱' },
34 { row: 4, indent: 2, glyphs: '╰──────╯' },
35 ],
36] as const
37
38const CHARGE_FRAMES = CHARGE_STAGE_FRAMES * BALLS.length
39
40const PR_CREATE = /\bgh\s+pr\s+create\b/
41
42export const opensPr = (command: string) => PR_CREATE.test(command)
43
44export const launch: Animation = {
45 name: 'launch',
46 priority: 3,
47 frames: CHARGE_FRAMES + WIND_UP_FRAMES + FLIGHT_FRAMES,
48 frameMs: () => FRAME_MS,
49 cheat: { name: 'send-pr', reply: () => 'Clawd: PR launched.' },
50
51 draw: (frame, { columns, x }) => {
52 const flightFrame = frame - CHARGE_FRAMES - WIND_UP_FRAMES
53 const isFlying = flightFrame >= 0
54 const isCharging = frame < CHARGE_FRAMES
55 const flown = isFlying ? (flightFrame + 1) / FLIGHT_FRAMES : 0
56 const restX = Math.max(0, Math.min(x - 2, columns - BALL_COLUMNS))
57 // Thrown across the longer stretch of the band, so it always has room to fly.
58 const isThrownLeft = x + SPRITE_COLUMNS / 2 > columns / 2
59 const travel = isThrownLeft ? -(restX + BALL_COLUMNS) : columns - restX
60 // Squared so the ball leaves slowly and is fully past the edge on the last frame.
61 const ballX = restX + Math.round(travel * flown * flown)
62 const ball = BALLS[Math.min(BALLS.length - 1, Math.floor(frame / CHARGE_STAGE_FRAMES))] ?? BALLS[0]
63
64 // Drawn on a grid of the band's width so whatever passes an edge is simply not there.
65 const grid = Array.from({ length: BALL_ROWS }, () => Array.from({ length: columns }, () => ' '))
66 const put = (row: number, from: number, glyphs: string) => {
67 const cells = grid[row]
68
69 if (cells === undefined) {
70 return
71 }
72
73 for (const [offset, glyph] of [...glyphs].entries()) {
74 if (from + offset >= 0 && from + offset < columns) {
75 cells[from + offset] = glyph
76 }
77 }
78 }
79
80 if (isCharging) {
81 const reach = SPARK_REACH - (frame % SPARK_REACH)
82 put(frame % BALL_ROWS, restX - reach, '·')
83 put((frame + 2) % BALL_ROWS, restX + BALL_COLUMNS - 1 + reach, '·')
84 }
85
86 for (const line of ball) {
87 put(line.row, ballX + line.indent, line.glyphs)
88 }
89
90 if (isFlying) {
91 const length = Math.min(TRAIL.length, Math.abs(ballX - restX))
92
93 if (isThrownLeft) {
94 put(BALL_MIDDLE_ROW, ballX + BALL_COLUMNS, [...TRAIL].reverse().join('').slice(0, length))
95 } else {
96 put(BALL_MIDDLE_ROW, ballX - length, TRAIL.slice(TRAIL.length - length))
97 }
98 }
99
100 const sky = grid.map((cells): Row => {
101 const drawn = cells.join('').trimEnd()
102 const glyphs = drawn.trimStart()
103
104 return glyphs === ''
105 ? BLANK_ROW
106 : { indent: drawn.length - glyphs.length, spans: [{ text: glyphs, color: BALL_COLOR }] }
107 })
108
109 return [
110 ...sky,
111 BLANK_ROW,
112 ...mascot(
113 x,
114 isFlying ? ARMS.down : ARMS.up,
115 isCharging ? 'ahead' : isThrownLeft ? 'left' : 'right',
116 FEET.both,
117 ),
118 ]
119 },
120}
121
122export const launchTriggers: Triggers = (on, director) => {
123 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
124 if (opensPr(e.command)) {
125 director.play(launch)
126 }
127
128 return next(e)
129 })
130}
131hooks/animations/run.ts 440 lines1import { ARMS, FEET, GHOSTS, SPRITE_COLUMNS, SWEAT, SWEAT_COLOR, ghost, mascot } from '../lib/body'
2import { frameIn, layRow } from '../lib/frame'
3import type { Animation, Params, Piece, Row, Span, Stage, Triggers } from '../lib/frame'
4import { opensPr } from './launch'
5import { middle } from './walk'
6
7const INTRO_FRAME_MS = 100
8const FRAME_MS = 80
9
10// The opening, in order: the world appears, the mascot fades out where it walked and in at the middle of the
11// band, sets itself, pulls back, and only then runs.
12const WORLD_FRAMES = 2
13const VANISH_FRAMES = 3
14const GAP_FRAMES = 2
15const APPEAR_FRAMES = 3
16const SET_FRAMES = 3
17const WIND_UP_FRAMES = 3
18const REAPPEAR_FRAME = WORLD_FRAMES + VANISH_FRAMES + GAP_FRAMES
19const SET_FRAME = REAPPEAR_FRAME + APPEAR_FRAMES
20const WIND_UP_FRAME = SET_FRAME + SET_FRAMES
21const INTRO_FRAMES = WIND_UP_FRAME + WIND_UP_FRAMES
22
23const STEP_FRAMES = 2
24const TIRED_AFTER_FRAMES = 125
25const GASP_EVERY = 8
26const GASP_FRAMES = 2
27// Once the last command is back it runs on this long: a command that comes in the meantime finds it running.
28const LINGER_FRAMES = 38
29// How long a command's result shows beside the head while it runs on.
30const MARK_FRAMES = 10
31const END_FRAMES = 16
32const STUMBLE_FRAMES = 2
33// Six seconds from the cheat to its cheer.
34const CHEAT_FRAMES = INTRO_FRAMES + 55
35
36const ROWS = 3
37// The feet share their row with the ground.
38const GROUND_ROW = 2
39const GROUND_COLOR = 'inactive'
40// Drawn through the middle of the row; the pebbles are what shows it moving.
41const GROUND = '────────·──────────────·─────·───────────'
42const COMMAND_COLUMNS = 24
43const BANNER_COLOR = '#f2c94c'
44const BANNER_INK = '#000000'
45const ROPE_COLUMNS = 7
46// The tail, and the cloth's two edges.
47const BANNER_TRIM_COLUMNS = 3
48const SHORTEST_BANNER = 4
49const UNFURL_FRAMES = 6
50const FLUTTER_FRAMES = 2
51const MARK_GAP = 2
52const PASSED: Span = { text: 'Finish!', color: 'success', bold: true }
53const FAILED: Span = { text: 'Oops!', color: 'error', bold: true }
54
55const LEAF = '#6aa84f'
56const TRUNK = '#a47148'
57
58type Thing = readonly { row: number; indent: number; glyphs: string; color: string }[]
59
60// What stands on the ground and goes by at its speed. A trunk is a stem rising out of the ground's own line.
61const THINGS: readonly Thing[] = [
62 [
63 { row: 0, indent: 1, glyphs: '▟█▙', color: LEAF },
64 { row: 1, indent: 0, glyphs: '▟███▙', color: LEAF },
65 { row: GROUND_ROW, indent: 2, glyphs: '╨', color: TRUNK },
66 ],
67 [
68 { row: 1, indent: 0, glyphs: '▟█▙', color: LEAF },
69 { row: GROUND_ROW, indent: 1, glyphs: '┴', color: TRUNK },
70 ],
71 [{ row: GROUND_ROW, indent: 0, glyphs: '▄▆▄', color: LEAF }],
72]
73const THING_SLOT = 14
74const WIDEST_THING = 5
75
76// Kicked up behind the feet as it sets off, by frames run.
77const DUST = [
78 { until: 2, behind: 2, glyph: '∘' },
79 { until: 4, behind: 4, glyph: '·' },
80] as const
81
82type Cell = { glyph: string; color?: string; dimColor?: true } | undefined
83type Extra = { row: number; at: number; span: Span }
84// A stretch of a row the world is kept out of, beyond the mascot's own box.
85type Cover = { row: number; at: number; columns: number }
86type Actor = { box: number | null; rows: readonly Row[]; extras: readonly Extra[]; covers?: readonly Cover[] }
87
88// The same answer for the same number every time it is drawn, and no pattern a person would notice.
89const noise = (seed: number) => Math.imul(seed + 1, 0x9e3779b1) >>> 16
90
91// A blank in what is stamped clears the cell: the command keeps a column of air on each side.
92const stamp = (cells: Cell[] | undefined, column: number, glyphs: string, color: string) => {
93 for (const [index, glyph] of [...glyphs].entries()) {
94 const at = column + index
95
96 if (cells !== undefined && at >= 0 && at < cells.length) {
97 cells[at] = glyph === ' ' ? undefined : { glyph, color }
98 }
99 }
100}
101
102// Lays spans over what is there, a blank in them leaving it be: the ground shows between the feet.
103const overlay = (cells: Cell[], column: number, spans: readonly Span[]) => {
104 let at = column
105
106 for (const span of spans) {
107 at += span.gap ?? 0
108
109 for (const glyph of span.text) {
110 if (glyph !== ' ' && at >= 0 && at < cells.length) {
111 cells[at] = {
112 glyph,
113 ...(span.color === undefined ? {} : { color: span.color }),
114 ...(span.dimColor === true ? { dimColor: true } : {}),
115 }
116 }
117
118 at += 1
119 }
120 }
121}
122
123// Every other slot of a tape holds one thing, somewhere inside the slot; `scroll` is the column the band starts at.
124const scatter = (rows: Cell[][], columns: number, scroll: number, slot: number, things: readonly Thing[]) => {
125 for (let index = Math.floor(scroll / slot); index * slot < scroll + columns; index += 1) {
126 const thing = things[noise(index * 2) % (things.length * 2)]
127 const column = index * slot + (noise(index * 2 + 1) % (slot - WIDEST_THING)) - scroll
128
129 for (const part of thing ?? []) {
130 stamp(rows[part.row], column + part.indent, part.glyphs, part.color)
131 }
132 }
133}
134
135// A Text's own leading and trailing spaces are not kept everywhere, so a row is runs of one look with gaps between.
136const piecesOf = (cells: readonly Cell[]): Piece[] => {
137 const pieces: { at: number; text: string; color?: string; dimColor?: true }[] = []
138
139 for (const [column, cell] of cells.entries()) {
140 const last = pieces.at(-1)
141
142 if (cell === undefined) {
143 continue
144 }
145
146 if (
147 last !== undefined &&
148 last.color === cell.color &&
149 last.dimColor === cell.dimColor &&
150 last.at + last.text.length === column
151 ) {
152 last.text += cell.glyph
153 } else {
154 pieces.push({ ...cell, at: column, text: cell.glyph })
155 }
156 }
157
158 return pieces.map(({ at, text, color, dimColor }) => ({
159 at,
160 spans: [{ text, ...(color === undefined ? {} : { color }), ...(dimColor === true ? { dimColor } : {}) }],
161 }))
162}
163
164// One line of plain characters: anything wider than a column would push the banner out of shape.
165const brief = (command: string | undefined) => {
166 const line = (command ?? '').trim().split('\n')[0] ?? ''
167 const plain = /^[\x20-\x7e]*/.exec(line)?.[0] ?? ''
168 const text = plain.replace(/ +/g, ' ').trim()
169
170 return text.length > COMMAND_COLUMNS || plain.length < line.length
171 ? `${text.slice(0, COMMAND_COLUMNS - 1)}…`
172 : text
173}
174
175// The ground is one long tape the band looks at through a window that moves a column a frame.
176const ground = (columns: number, scroll: number): Cell[] =>
177 Array.from({ length: columns }, (_, column) => ({
178 glyph: GROUND[(scroll + column) % GROUND.length] ?? '─',
179 color: GROUND_COLOR,
180 }))
181
182// The whole picture: what goes by, with the mascot in front of it. Only the world scrolls. The ground runs
183// along the row the feet are on, and gives way only to the feet themselves.
184const scene = ({ columns }: Stage, scroll: number, actor: Actor): Row[] => {
185 const world: Cell[][] = Array.from({ length: ROWS }, () => Array.from({ length: columns }, () => undefined))
186
187 scatter(world, columns, scroll, THING_SLOT, THINGS)
188
189 const extras = actor.extras.filter(extra => extra.at >= 0 && extra.at + extra.span.text.length <= columns)
190
191 // Nothing of the world shows through the mascot, or through what is drawn around it.
192 for (const cells of world) {
193 if (actor.box !== null) {
194 stamp(cells, actor.box, ' '.repeat(SPRITE_COLUMNS), '')
195 }
196 }
197
198 for (const extra of extras) {
199 stamp(world[extra.row], extra.at, ' '.repeat(extra.span.text.length), '')
200 }
201
202 for (const cover of actor.covers ?? []) {
203 stamp(world[cover.row], cover.at, ' '.repeat(cover.columns), '')
204 }
205
206 return world.map((cells, index) => {
207 const body = actor.rows[index]
208 const beside = extras.filter(extra => extra.row === index)
209
210 if (index !== GROUND_ROW) {
211 return layRow([
212 ...piecesOf(cells),
213 ...(body === undefined ? [] : [{ at: body.indent, spans: body.spans }]),
214 ...beside.map(extra => ({ at: extra.at, spans: [extra.span] })),
215 ])
216 }
217
218 const floor = ground(columns, scroll)
219
220 for (const [column, cell] of cells.entries()) {
221 if (cell !== undefined) {
222 floor[column] = cell
223 }
224 }
225
226 if (body !== undefined) {
227 overlay(floor, body.indent, body.spans)
228 }
229
230 for (const extra of beside) {
231 overlay(floor, extra.at, [extra.span])
232 }
233
234 return layRow(piecesOf(floor))
235 })
236}
237
238// What it tows: the command on a strip of cloth, on a rope from its waist. The rope and the cloth's tail
239// flutter; the cloth itself holds still, so it can be read. Nothing of the world shows between the two.
240const banner = (ran: number, at: number, command: string): { extras: Extra[]; covers: Cover[] } => {
241 const room = at - ROPE_COLUMNS - BANNER_TRIM_COLUMNS
242
243 if (command === '' || room < SHORTEST_BANNER) {
244 return { extras: [], covers: [] }
245 }
246
247 const text = command.length > room ? `${command.slice(0, room - 1)}…` : command
248 // It unrolls from the rope's end, so its last letters are out first.
249 const out = Math.ceil((text.length * Math.min(UNFURL_FRAMES, ran + 1)) / UNFURL_FRAMES)
250 const shown = text.slice(text.length - out).trimStart()
251 const end = at - ROPE_COLUMNS
252 const start = end - shown.length - BANNER_TRIM_COLUMNS
253 const isFlapping = Math.floor(ran / FLUTTER_FRAMES) % 2 === 0
254 const rope = Array.from({ length: ROPE_COLUMNS }, (_, column) => column)
255 .filter(column => column % 2 === (isFlapping ? 0 : 1))
256 .map(column => ({ row: 1, at: end + column, span: { text: '─', color: GROUND_COLOR } }))
257
258 return {
259 extras: [
260 { row: 1, at: start, span: { text: isFlapping ? '≈' : '~', color: BANNER_COLOR } },
261 { row: 1, at: start + 1, span: { text: '▐', color: BANNER_COLOR } },
262 { row: 1, at: start + 2, span: { text: shown, color: BANNER_INK, backgroundColor: BANNER_COLOR } },
263 { row: 1, at: end - 1, span: { text: '▌', color: BANNER_COLOR } },
264 ...rope,
265 ],
266 covers: [{ row: 1, at: start, columns: at - start }],
267 }
268}
269
270const runner = (ran: number, at: number, command: string): Actor => {
271 const isTired = ran >= TIRED_AFTER_FRAMES
272 const isLeftStep = Math.floor(ran / STEP_FRAMES) % 2 === 0
273 const isGasping = isTired && ran % GASP_EVERY >= GASP_EVERY - GASP_FRAMES
274 const sweat = SWEAT[Math.floor(ran / STEP_FRAMES) % SWEAT.length] ?? SWEAT[0]
275 const dust = DUST.find(puff => ran < puff.until)
276 const towed = banner(ran, at, command)
277
278 return {
279 covers: towed.covers,
280 box: at,
281 rows: mascot(
282 at,
283 isLeftStep ? ARMS.oneUp : ARMS.otherUp,
284 isGasping ? 'closed' : 'right',
285 isLeftStep ? FEET.left : FEET.right,
286 ),
287 extras: [
288 ...towed.extras,
289 ...(isTired ? [{ row: sweat.row, at: at + SPRITE_COLUMNS + 1, span: { text: sweat.glyph, color: SWEAT_COLOR } }] : []),
290 ...(dust === undefined ? [] : [{ row: GROUND_ROW, at: at - dust.behind, span: { text: dust.glyph, color: GROUND_COLOR } }]),
291 ],
292 }
293}
294
295const actorAt = (frame: number, { columns, x }: Stage, command: string): Actor => {
296 const center = middle(columns)
297
298 // Already in the middle, it has nowhere to fade to.
299 if (frame < WORLD_FRAMES || (x === center && frame < SET_FRAME)) {
300 return { box: x, rows: mascot(x, ARMS.down, 'ahead', FEET.both), extras: [] }
301 }
302
303 if (frame < WORLD_FRAMES + VANISH_FRAMES) {
304 return { box: x, rows: ghost(x, GHOSTS[frame - WORLD_FRAMES] ?? GHOSTS[0]), extras: [] }
305 }
306
307 if (frame < REAPPEAR_FRAME) {
308 return { box: null, rows: [], extras: [] }
309 }
310
311 if (frame < SET_FRAME - 1) {
312 return { box: center, rows: ghost(center, GHOSTS[GHOSTS.length - 1 - (frame - REAPPEAR_FRAME)] ?? GHOSTS[0]), extras: [] }
313 }
314
315 if (frame < SET_FRAME) {
316 return { box: center, rows: mascot(center, ARMS.down, 'ahead', FEET.both, { tint: 'text' }), extras: [] }
317 }
318
319 if (frame < WIND_UP_FRAME) {
320 return { box: center, rows: mascot(center, ARMS.down, 'right', FEET.both), extras: [] }
321 }
322
323 if (frame < INTRO_FRAMES) {
324 const back = Math.max(0, center - 1)
325
326 return { box: back, rows: mascot(back, ARMS.otherUp, 'right', FEET.left), extras: [] }
327 }
328
329 return runner(frame - INTRO_FRAMES, center, command)
330}
331
332// How a run ends, in the middle of a world that has stopped: a cheer, or a stumble a column on.
333const finisher = (frame: number, hasPassed: boolean, { columns }: Stage): Actor => {
334 const center = middle(columns)
335 const isStumbling = !hasPassed && frame < STUMBLE_FRAMES
336 const at = isStumbling ? Math.min(center + 1, Math.max(0, columns - SPRITE_COLUMNS)) : center
337
338 return {
339 box: at,
340 rows: hasPassed
341 ? mascot(at, ARMS.up, 'ahead', FEET.both)
342 : mascot(at, ARMS.down, 'closed', isStumbling ? FEET.left : FEET.both),
343 extras: [],
344 }
345}
346
347// Before it has reappeared the mascot never left; after, the middle is where it is.
348const leftAt = (frame: number) => (frame < REAPPEAR_FRAME ? 'in-place' : 'center')
349
350// One run covers every command that starts before it is over. While a command is out it loops; `until` is
351// set once the last one is back, and from there it runs on a while, then stops the world and ends.
352// command what its banner says until the frame the run stops at
353// ok how the last command went ended the frame that one came back at
354export const run: Animation = {
355 name: 'run',
356 priority: 2,
357 frames: null,
358 framesFor: params => {
359 const until = frameIn(params, 'until')
360
361 return until === undefined ? null : until + END_FRAMES
362 },
363 frameMs: frame => (frame < INTRO_FRAMES ? INTRO_FRAME_MS : FRAME_MS),
364 exit: leftAt,
365 cheat: {
366 name: 'run',
367 params: (typed): Params => ({
368 ...(typed === '' ? {} : { command: typed }),
369 ok: 'true',
370 ended: String(CHEAT_FRAMES),
371 until: String(CHEAT_FRAMES),
372 }),
373 reply: params => (params.command === undefined ? 'Clawd: running.' : `Clawd: running ${params.command}.`),
374 },
375
376 draw: (frame, stage, params) => {
377 const until = frameIn(params, 'until')
378 const ended = frameIn(params, 'ended')
379 const hasPassed = params.ok === 'true'
380 const isOver = until !== undefined && frame >= until
381 const scroll = Math.max(0, (isOver ? until : frame) - INTRO_FRAMES)
382 const actor = isOver ? finisher(frame - until, hasPassed, stage) : actorAt(frame, stage, brief(params.command))
383 const markedFrom = Math.max(ended ?? Number.POSITIVE_INFINITY, INTRO_FRAMES)
384 const isMarked = isOver || (frame >= markedFrom && frame < markedFrom + MARK_FRAMES)
385 const mark = hasPassed ? PASSED : FAILED
386
387 const beside = middle(stage.columns) + SPRITE_COLUMNS
388
389 return scene(stage, scroll, {
390 ...actor,
391 extras: [...actor.extras, ...(isMarked ? [{ row: 0, at: beside + MARK_GAP, span: mark }] : [])],
392 // A blank on each side of the word, so nothing going by runs into it.
393 covers: [
394 ...(actor.covers ?? []),
395 ...(isMarked ? [{ row: 0, at: beside, columns: MARK_GAP + mark.text.length + 1 }] : []),
396 ],
397 })
398 },
399}
400
401// Commands can run side by side; the run is over only once the last of them is back.
402let running = 0
403let latest: Params = {}
404
405export const runTriggers: Triggers = (on, director) => {
406 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
407 // Opening a PR has an animation of its own.
408 if (opensPr(e.command)) {
409 return next(e)
410 }
411
412 let hasPassed = false
413 running += 1
414 // Asked again while it runs, it carries on from the frame it is on, with this command on its banner.
415 latest = { command: e.command }
416 director.play(run, latest)
417
418 try {
419 const ran = await next(e)
420 hasPassed = ran.deny === undefined && ran.isError !== true
421
422 return ran
423 } finally {
424 running -= 1
425
426 if (running === 0) {
427 const reached = director.frameOf(run) ?? 0
428
429 director.play(run, {
430 ...latest,
431 ok: String(hasPassed),
432 ended: String(reached),
433 // A command that is back before the opening is over still gets all of it.
434 until: String(Math.max(reached, INTRO_FRAMES) + LINGER_FRAMES),
435 })
436 }
437 }
438 })
439}
440hooks/animations/think.ts 227 lines1import { ARMS, FEET, SPRITE_COLUMNS, SWEAT, SWEAT_COLOR, mascot } from '../lib/body'
2import type { Gaze } from '../lib/body'
3import { frameIn, layRow } from '../lib/frame'
4import type { Animation, Director, Params, Row, Span, Stage, Triggers } from '../lib/frame'
5import { STRIDE, ahead } from './walk'
6
7const FRAME_MS = 125
8
9// It paces slowly at first, picks up after five seconds, and is sweating after fifteen.
10const HURRIED_AFTER_FRAMES = 40
11const WORRIED_AFTER_FRAMES = 120
12const CALM_STEP_FRAMES = 3
13const HURRIED_STEP_FRAMES = 2
14
15// A lap: a few steps out, a stop to ponder, the same steps back, another stop.
16const PACE_STEPS = 6
17const PONDER_STEPS = 3
18const LEG_STEPS = PACE_STEPS + PONDER_STEPS
19const GLANCE_STEP = 3
20const BLINK_EVERY = 16
21
22const THOUGHTS = ['.', '. o', '. o O', '. o O'] as const
23const THOUGHT_FRAMES = 3
24const THOUGHT_COLUMNS = Math.max(...THOUGHTS.map(thought => thought.length))
25const THOUGHT_COLOR = 'inactive'
26const BESIDE_GAP = 2
27const FLUSHED = '#e5533d'
28
29// The idea: it stops where it is, hands up, a mark where the thought was.
30const AHA_FRAMES = 8
31const FLASH_FRAMES = 2
32const AHA = '!'
33// A thought shorter than two seconds ends without one.
34const AHA_AFTER_FRAMES = 16
35const CHEAT_SECONDS = 8
36const LONGEST_CHEAT_SECONDS = 60
37
38type Extra = { row: number; at: number; span: Span }
39type Spot = { at: number; side: 'left' | 'right' }
40
41// How many steps it has taken by `frame`.
42const stepsAt = (frame: number) => {
43 const calm = Math.min(frame, HURRIED_AFTER_FRAMES)
44
45 return Math.floor(calm / CALM_STEP_FRAMES) + Math.floor((frame - calm) / HURRIED_STEP_FRAMES)
46}
47
48// Where a step count puts it in its lap: how far out along the walk, which leg, and which beat of a stop.
49const paceAt = (steps: number) => {
50 const lap = steps % (LEG_STEPS * 2)
51 const isSecondLeg = lap >= LEG_STEPS
52 const leg = lap % LEG_STEPS
53 const walked = Math.min(leg, PACE_STEPS)
54
55 return {
56 out: isSecondLeg ? PACE_STEPS - walked : walked,
57 isSecondLeg,
58 leg,
59 pondering: leg < PACE_STEPS ? undefined : leg - PACE_STEPS,
60 }
61}
62
63// A thought that has been told when to stop holds the pace it had reached.
64const pacedAt = (frame: number, params: Params) =>
65 paceAt(stepsAt(Math.min(frame, frameIn(params, 'until') ?? frame)))
66
67const flip = (facing: Stage['facing']) => (facing === 'right' ? 'left' : 'right')
68
69// Room for a thought beside the head: to the right while there is some, else to the left.
70const spotAt = (x: number, columns: number): Spot | undefined => {
71 const right = x + SPRITE_COLUMNS + BESIDE_GAP
72 const left = x - BESIDE_GAP - THOUGHT_COLUMNS
73
74 if (right + THOUGHT_COLUMNS <= columns) {
75 return { at: right, side: 'right' }
76 }
77
78 return left >= 0 ? { at: left, side: 'left' } : undefined
79}
80
81// Whatever sits in the spot starts at the head and trails away from it.
82const inSpot = (spot: Spot | undefined, text: string, look: Omit<Span, 'text'>): Extra[] => {
83 if (spot === undefined) {
84 return []
85 }
86
87 return spot.side === 'right'
88 ? [{ row: 0, at: spot.at, span: { ...look, text } }]
89 : [{ row: 0, at: spot.at + THOUGHT_COLUMNS - text.length, span: { ...look, text: [...text].reverse().join('') } }]
90}
91
92// The drop goes on the side the thought is not.
93const sweatAt = (frame: number, x: number, columns: number, spot: Spot | undefined): Extra[] => {
94 const drop = SWEAT[Math.floor(frame / HURRIED_STEP_FRAMES) % SWEAT.length] ?? SWEAT[0]
95 const at = spot?.side === 'right' ? x - BESIDE_GAP : x + SPRITE_COLUMNS + 1
96
97 return at >= 0 && at < columns ? [{ row: drop.row, at, span: { text: drop.glyph, color: SWEAT_COLOR } }] : []
98}
99
100const withExtras = (body: readonly Row[], extras: readonly Extra[]): Row[] =>
101 body.map((row, index) =>
102 layRow([
103 { at: row.indent, spans: row.spans },
104 ...extras.filter(extra => extra.row === index).map(extra => ({ at: extra.at, spans: [extra.span] })),
105 ]),
106 )
107
108const aha = (frame: number, x: number, columns: number): Row[] =>
109 withExtras(
110 mascot(x, ARMS.up, 'ahead', FEET.both, frame < FLASH_FRAMES ? { tint: 'text' } : {}),
111 inSpot(spotAt(x, columns), AHA, { color: 'warning', bold: true }),
112 )
113
114// While the model thinks it loops; `until` is set once the thought is over, and from there it has its idea and ends.
115// until the frame the thought ended at
116export const think: Animation = {
117 name: 'think',
118 priority: 1,
119 frames: null,
120 framesFor: params => {
121 const until = frameIn(params, 'until')
122
123 return until === undefined ? null : until + AHA_FRAMES
124 },
125 frameMs: () => FRAME_MS,
126 // It has paced away from where it stood, and the walk carries on from there.
127 exit: (frame, params) => ({ steps: pacedAt(frame, params).out }),
128 cheat: {
129 name: 'think',
130 params: (typed): Params => {
131 const asked = Number(typed)
132 const seconds =
133 typed !== '' && Number.isFinite(asked) ? Math.min(Math.max(asked, 1), LONGEST_CHEAT_SECONDS) : CHEAT_SECONDS
134
135 return { until: String(Math.round((seconds * 1000) / FRAME_MS)) }
136 },
137 reply: () => 'Clawd: thinking.',
138 },
139
140 draw: (frame, stage, params) => {
141 const until = frameIn(params, 'until')
142 const { out, isSecondLeg, leg, pondering } = pacedAt(frame, params)
143 const { x } = ahead(stage, out)
144
145 if (until !== undefined && frame >= until) {
146 return aha(frame - until, x, stage.columns)
147 }
148
149 const isPondering = pondering !== undefined
150 // The second leg retraces the first, and a stop looks the way the next leg goes.
151 const way =
152 isSecondLeg === isPondering ? ahead(stage, out).facing : flip(ahead(stage, Math.max(0, out - 1)).facing)
153 const isBlinking = frame % BLINK_EVERY === BLINK_EVERY - 1
154 const gaze: Gaze = isPondering
155 ? pondering === PONDER_STEPS - 1
156 ? way
157 : 'closed'
158 : isBlinking
159 ? 'closed'
160 : leg === GLANCE_STEP
161 ? 'ahead'
162 : way
163 const isWorried = frame >= WORRIED_AFTER_FRAMES
164 const spot = spotAt(x, stage.columns)
165 const thought = THOUGHTS[Math.floor(frame / THOUGHT_FRAMES) % THOUGHTS.length] ?? THOUGHTS[0]
166
167 return withExtras(
168 mascot(
169 x,
170 isPondering ? ARMS.oneUp : ARMS.down,
171 gaze,
172 isPondering ? FEET.both : (STRIDE[stepsAt(frame) % STRIDE.length] ?? FEET.both),
173 isWorried ? { tint: FLUSHED } : {},
174 ),
175 [
176 ...inSpot(spot, thought, { color: THOUGHT_COLOR }),
177 ...(isWorried ? sweatAt(frame, x, stage.columns, spot) : []),
178 ],
179 )
180 },
181}
182
183// A thought that was long enough ends on its idea; a short one just stops.
184const conclude = (director: Director) => {
185 const reached = director.frameOf(think)
186
187 if (reached === undefined || reached < AHA_AFTER_FRAMES) {
188 director.stop(think)
189
190 return
191 }
192
193 director.play(think, { until: String(reached) })
194}
195
196export const thinkTriggers: Triggers = (on, director) => {
197 on('turn.step', async function* ($, e, next) {
198 // A subagent thinks out of sight.
199 if (e.agentId !== undefined) {
200 return yield* next(e)
201 }
202
203 let isThinking = false
204
205 try {
206 for await (const chunk of next(e)) {
207 if (chunk.kind === 'thinking' && !isThinking) {
208 isThinking = true
209 // One that follows another starts over, from where the last one left the mascot.
210 director.stop(think)
211 director.play(think)
212 } else if (isThinking && (chunk.kind === 'text' || chunk.kind === 'tool' || chunk.kind === 'stop')) {
213 isThinking = false
214 conclude(director)
215 }
216
217 yield chunk
218 }
219 } finally {
220 // Interrupted mid-thought, nothing came of it.
221 if (isThinking) {
222 director.stop(think)
223 }
224 }
225 })
226}
227hooks/animations/wait.ts 95 lines1import { ARMS, FEET, GHOSTS, bubbleFits, ghost, mascot } from '../lib/body'
2import { BLANK_ROW } from '../lib/frame'
3import type { Animation, Triggers } from '../lib/frame'
4import { STEP_MS } from './walk'
5
6const CLOSING_LINES = 3
7const QUESTION_END = /[??][\s*_`"'”」))]*$/
8
9// A reply often asks its question and then adds a closing line, so the last few lines count, not just the last.
10export const asksThePerson = (answer: string) =>
11 answer
12 .split('\n')
13 .filter(line => line.trim() !== '')
14 .slice(-CLOSING_LINES)
15 .some(line => QUESTION_END.test(line))
16
17const TELEPORT_FRAME_MS = 150
18const VANISH_FRAMES = 3
19const GAP_FRAMES = 2
20const APPEAR_FRAMES = 3
21const TELEPORT_FRAMES = VANISH_FRAMES + GAP_FRAMES + APPEAR_FRAMES
22const BLINK_EVERY = 12
23const BUBBLE = '< your turn'
24
25export const wait: Animation = {
26 name: 'wait',
27 priority: 2,
28 frames: null,
29 // The teleport is quick; the wave after it only needs the walk's slower beat.
30 frameMs: frame => (frame < TELEPORT_FRAMES ? TELEPORT_FRAME_MS : STEP_MS),
31 // Stopped before it reappears, the mascot never left; after, it stands at the left edge.
32 exit: frame => (frame < VANISH_FRAMES + GAP_FRAMES ? 'in-place' : 'left-edge'),
33 cheat: { name: 'your-turn', reply: () => 'Clawd: your turn.' },
34
35 draw: (frame, { columns, x }) => {
36 if (frame < VANISH_FRAMES) {
37 return ghost(x, GHOSTS[frame] ?? GHOSTS[0])
38 }
39
40 const appearing = frame - VANISH_FRAMES - GAP_FRAMES
41
42 if (appearing < 0) {
43 return [BLANK_ROW, BLANK_ROW, BLANK_ROW]
44 }
45
46 if (appearing < APPEAR_FRAMES - 1) {
47 return ghost(0, GHOSTS[GHOSTS.length - 1 - appearing] ?? GHOSTS[0])
48 }
49
50 if (appearing === APPEAR_FRAMES - 1) {
51 return mascot(0, ARMS.down, 'ahead', FEET.both, { tint: 'text' })
52 }
53
54 const beat = frame - TELEPORT_FRAMES
55
56 return mascot(
57 0,
58 beat % 2 === 1 ? ARMS.oneUp : ARMS.down,
59 beat % BLINK_EVERY === BLINK_EVERY - 2 ? 'closed' : 'ahead',
60 FEET.both,
61 bubbleFits(columns, BUBBLE) ? { says: BUBBLE } : {},
62 )
63 },
64}
65
66export const waitTriggers: Triggers = (on, director) => {
67 on('turn.complete', ($, e, next) => {
68 if (e.agentId === undefined && asksThePerson(e.answer)) {
69 director.play(wait)
70 }
71
72 return next(e)
73 })
74
75 on('turn.start', ($, e, next) => {
76 director.stop(wait)
77
78 return next(e)
79 })
80
81 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
82 if (e.agentId !== undefined) {
83 return next(e)
84 }
85
86 director.play(wait)
87
88 try {
89 return await next(e)
90 } finally {
91 director.stop(wait)
92 }
93 })
94}
95hooks/lib/body.ts 101 lines1import type { Row, Span } from './frame'
2
3export const BODY = '#d77757'
4export const EYE_SOCKET = '#000000'
5export const SPRITE_COLUMNS = 9
6const EYES_COLUMNS = 6
7const BUBBLE_COLUMN = 11
8
9export const SWEAT_COLOR = '#9be7ff'
10
11// A drop beside the head that slides down a row as it alternates.
12export const SWEAT = [
13 { row: 0, glyph: '˙' },
14 { row: 1, glyph: "'" },
15] as const
16
17export type Gaze = 'ahead' | 'left' | 'right' | 'closed'
18
19// A lid swaps the two colors, so the eye reads as a thin dark line on the body.
20const EYES: Record<Gaze, readonly { glyphs: string; isLid?: true }[]> = {
21 ahead: [{ glyphs: '▛███▛█' }],
22 left: [{ glyphs: '▟███▟█' }],
23 right: [{ glyphs: '█▟███▟' }],
24 closed: [{ glyphs: '▂', isLid: true }, { glyphs: '███' }, { glyphs: '▂', isLid: true }, { glyphs: '█' }],
25}
26
27export const ARMS = {
28 down: { topIndent: 1, topLeft: '▐', topRight: '', torsoIndent: 0, torso: '▝▜██████▀' },
29 up: { topIndent: 0, topLeft: '▗▟', topRight: '▄', torsoIndent: 1, torso: '▜██████▘' },
30 oneUp: { topIndent: 1, topLeft: '▐', topRight: '▄', torsoIndent: 0, torso: '▝▜██████▘' },
31 otherUp: { topIndent: 0, topLeft: '▗▟', topRight: '', torsoIndent: 1, torso: '▜██████▀' },
32} as const
33
34export const FEET = {
35 both: { indent: 1, glyphs: '▝▝ ▝▝' },
36 left: { indent: 1, glyphs: '▝▝' },
37 right: { indent: 6, glyphs: '▝▝' },
38} as const
39
40export type Arms = (typeof ARMS)[keyof typeof ARMS]
41export type Feet = (typeof FEET)[keyof typeof FEET]
42
43export const bubbleFits = (columns: number, says: string) => columns >= BUBBLE_COLUMN + says.length
44
45export const mascot = (
46 at: number,
47 arms: Arms,
48 gaze: Gaze,
49 feet: Feet,
50 look: { tint?: string; says?: string } = {},
51): Row[] => {
52 const tint = look.tint ?? BODY
53 const top: Span[] = [{ text: arms.topLeft, color: tint }]
54
55 for (const part of EYES[gaze]) {
56 top.push(
57 part.isLid
58 ? { text: part.glyphs, color: EYE_SOCKET, backgroundColor: tint }
59 : { text: part.glyphs, color: tint, backgroundColor: EYE_SOCKET },
60 )
61 }
62
63 if (arms.topRight !== '') {
64 top.push({ text: arms.topRight, color: tint })
65 }
66
67 if (look.says !== undefined) {
68 const topEnd = arms.topIndent + arms.topLeft.length + EYES_COLUMNS + arms.topRight.length
69
70 top.push({ text: look.says, color: 'text', gap: BUBBLE_COLUMN - topEnd })
71 }
72
73 return [
74 { indent: at + arms.topIndent, spans: top },
75 { indent: at + arms.torsoIndent, spans: [{ text: arms.torso, color: tint }] },
76 { indent: at + feet.indent, spans: [{ text: feet.glyphs, color: tint }] },
77 ]
78}
79
80// Densest first: played forward the mascot fades out, backward it fades in.
81export const GHOSTS = [
82 [
83 { indent: 1, glyphs: '▒▒▒▒▒▒▒' },
84 { indent: 0, glyphs: '▒▒▒▒▒▒▒▒▒' },
85 { indent: 1, glyphs: '▒▒ ▒▒' },
86 ],
87 [
88 { indent: 1, glyphs: '░ ░░ ░░' },
89 { indent: 0, glyphs: '░░ ░░░ ░░' },
90 { indent: 1, glyphs: '░░ ░░' },
91 ],
92 [
93 { indent: 2, glyphs: '· ·' },
94 { indent: 4, glyphs: '·' },
95 { indent: 2, glyphs: '· ·' },
96 ],
97] as const
98
99export const ghost = (at: number, rows: (typeof GHOSTS)[number]): Row[] =>
100 rows.map(row => ({ indent: at + row.indent, spans: [{ text: row.glyphs, color: BODY, dimColor: true }] }))
101