SLOPSHOPPER

buddy-go

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…

newbandguardprompttimer
v0.4.0no licenseupdated 2026-10-08magiclin99/buddy-go
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · buddy-go
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ▐▛███▛█ ▟█▙ ▝▜██████▀ ▟███▙ ▟█▙ ─▝▝───▝▝·──────────────·╨────·───────────────────·▄▆▄───────────·─────·▄▆▄────────────┴───·───────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▐▛███▛█ ▟█▙ ▝▜██████▀ ▟███▙ ▟█▙ ─▝▝───▝▝·──────────────·╨────·───────────────────·▄▆▄───────────·─────·▄▆▄────────────┴───·─────────
README

buddy-go

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.

Buddy sprinting while a command runs

Install

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.

What Buddy does

Paces while Claude thinks

Buddy pacing under a thought bubble

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: !

Sprints while your commands run

Buddy running with the command on a banner

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.

Types along when files change

Buddy typing beside a file name

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

Launches your PR

Buddy charging a ball and throwing it

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.

Waves when it's your turn

Buddy waving beside a "your turn" bubble

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.

Wanders the rest of the time

Buddy walking above the prompt

Back and forth, above the prompt, minding its own business.

Cheat codes

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 thisBuddy will
buddy:think 20pace and think for 20 seconds (8 if you leave the number out)
buddy:run npm testsprint for six seconds with npm test on the banner
buddy:edit main.tstype into main.ts
buddy:send-prlaunch a PR
buddy:your-turnwave at you

Update

claude plugin marketplace update buddy-go
claude plugin update buddy-go@buddy-go

Then type /reload-plugins in any session that is already open.

Teach Buddy a new trick

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.

  1. Add a file to 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.
  2. Add it to 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.

Source 13 files
hooks/register.tsx 106 lines
1import { 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}
106
hooks/animations/index.ts 44 lines
1import 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}
44
hooks/animations/walk.ts 42 lines
1import { 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}
42
hooks/lib/frame.ts 81 lines
1import 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}
81
hooks/lib/player.ts 147 lines
1import 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>
147
hooks/lib/render.tsx 49 lines
1import 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
49
hooks/animations/edit.ts 115 lines
1import { 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}
115
hooks/animations/launch.ts 131 lines
1import { 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}
131
hooks/animations/run.ts 440 lines
1import { 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}
440
hooks/animations/think.ts 227 lines
1import { 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}
227
hooks/animations/wait.ts 95 lines
1import { 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}
95
hooks/lib/body.ts 101 lines
1import 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