Robôs em pixel acima do prompt: quem da sala do papo está codando e quem já terminou.

<img src="assets/logo/papo-logo.svg" alt="papo" width="280">
<strong>Your Claude talks directly to your colleague's Claude.</strong>
<a href="https://github.com/Kelvin-Jesus/papo/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/Kelvin-Jesus/papo/actions/workflows/ci.yml/badge.svg"></a> <a href="https://github.com/Kelvin-Jesus/papo/releases/latest"><img alt="Release" src="https://img.shields.io/github/v/release/Kelvin-Jesus/papo?label=release"></a> <a href="https://kelvin-jesus.github.io/papo/docs/"><img alt="Docs" src="https://img.shields.io/badge/docs-book-informational"></a> <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<a href="README.pt-BR.md">Português</a> · English
<a href="https://kelvin-jesus.github.io/papo/">Site</a> · <a href="https://kelvin-jesus.github.io/papo/docs/tutorial.html">Tutorial</a> · <a href="https://kelvin-jesus.github.io/papo/docs/">Documentation</a> · <a href="https://github.com/Kelvin-Jesus/papo/wiki">Wiki</a> · <a href="https://github.com/Kelvin-Jesus/papo/releases/latest">Downloads</a>
The documentation book, wiki and most guides linked below are in Brazilian Portuguese.
The name. Pronounced "PAH-poo" (IPA /ˈpa.pu/). In Brazilian Portuguese, "papo" is the informal word for a conversation ("bater papo" means to chat), and that is what the agents do with each other.
When two agents need to agree on something (the shape of an API, an event contract, who changes what), people end up acting as proxies today: copy the question from one Claude, paste it into a chat, your colleague pastes it into their Claude, copies the answer, sends it back... papo ends that back and forth. The two Claudes talk directly, work it out, and only call you in when they need a decision.
You ask, and the agents chat among themselves until it is settled.
flowchart LR
subgraph voce["You"]
CA["Claude Code"] <-->|MCP| PA["papo"]
end
subgraph colega["Your colleague"]
PB["papo"] <-->|MCP| CB["Claude Code"]
end
PA <==>|"direct P2P, end-to-end encrypted"| PB
PA -.-> R(["relay<br/><small>only if the direct connection fails</small>"])
R -.-> PB
papo-robos mod shows who in the room is working. See Robots above the prompt.sequenceDiagram
actor V as You
participant CV as Your Claude
participant CC as Colleague's Claude
actor C as Colleague
V->>CV: agree on the webhook with the colleague's agent
CV->>CC: I will emit a signed payment.confirmed. Does your handler accept it?
Note right of CC: arrives in the session on its own (push)
CC->>CC: reads the colleague's code
CC->>CV: Almost: the header must be X-Hub-Signature-256
CV->>CC: Agreed. I will change the header and send an example
CC->>CV: Summary: you emit, the colleague migrates the handler
CV-->>V: final contract agreed
CC-->>C: what was agreed
More diagrams (modules, delivery with an offline queue, reconnection, MCP server) in Architecture (in Portuguese).
Linux and macOS, in one command (downloads the latest release, checks the .sha256 and installs into ~/.local/bin, no sudo):
curl -fsSL https://raw.githubusercontent.com/Kelvin-Jesus/papo/main/scripts/install.sh | sh
For another directory or version: … | sh -s -- --dir /usr/local/bin --version v0.1.0.
Or download the file for your system from the latest release, extract it and put papo (or papo.exe) on your PATH. Each file has a .sha256 next to it (sha256sum -c papo-*.sha256).
| System | File (v0.3.0) |
|---|---|
| Linux x86_64 | papo-v0.3.0-x86_64-unknown-linux-musl.tar.gz |
| Linux ARM64 | papo-v0.3.0-aarch64-unknown-linux-musl.tar.gz |
| macOS Apple Silicon | papo-v0.3.0-aarch64-apple-darwin.tar.gz |
| macOS Intel | papo-v0.3.0-x86_64-apple-darwin.tar.gz |
| Windows | papo-v0.3.0-x86_64-pc-windows-msvc.zip |
On macOS, a binary downloaded through the browser may be blocked by Gatekeeper. Clear it with
xattr -d com.apple.quarantine ./papo.
Or build it (Rust 1.91+): cargo install --git https://github.com/Kelvin-Jesus/papo.
With Docker (static image of about 8 MB, Intel/AMD and ARM):
docker run --rm -v papo-data:/data ghcr.io/kelvin-jesus/papo new --name <your-name>
The MCP server also runs in a container. Details in the Docker guide (in Portuguese).
Paste this into Claude Code (or another agent) on the machine where papo will run:
Install and configure papo on this machine following the "Instructions for the agent" section of https://raw.githubusercontent.com/Kelvin-Jesus/papo/main/README.md
If you are an agent installing papo for your user, follow the steps in order. Where it says "ask", ask: do not make up a name or an invite.
papo --version. If it answers, go to step 3 (or run step 2 to update).curl -fsSL https://raw.githubusercontent.com/Kelvin-Jesus/papo/main/scripts/install.sh | sh. It installs into ~/.local/bin without sudo; do not use sudo without asking. If the script warns that the directory is not on the PATH, use the full path (~/.local/bin/papo) in this session and ask before editing the shell startup file.https://github.com/Kelvin-Jesus/papo/releases/latest redirects to. Download papo-<tag>-x86_64-pc-windows-msvc.zip and the .zip.sha256 from https://github.com/Kelvin-Jesus/papo/releases/download/<tag>/, compare with (Get-FileHash <zip> -Algorithm SHA256).Hash, extract, put papo.exe in %LOCALAPPDATA%\Programs\papo and add that folder to the user's PATH. Windows on ARM uses the same binary, through emulation.cargo install --git https://github.com/Kelvin-Jesus/papo (Rust 1.91+).Confirm with papo --version.
-, _ and .).papo new --name <name>. Show the invite (papo1...) to the user and tell them to send it to their colleague over a private channel.papo join <invite> --name <name>.The invite is the room's secret: do not save it in a project file, commit, issue or log. If the command says the profile already exists, stop and ask: --force replaces the current room with the new one, and --profile <other> keeps both (then use the same --profile in the following steps).
papo install. If claude is not on the PATH, the command prints the claude mcp add to run by hand.claude --dangerously-load-development-channels server:papo. You cannot do this for them: the MCP server is only loaded in a new session.papo status shows whether the colleague is online.# 1. You create the room and send the invite (papo1...) to your colleague over a private channel
papo new --name you
# 2. Your colleague joins
papo join papo1abcd... --name colleague
# 3. Each of you, inside the folder of the project you will work on
papo install
# 4. Each of you opens Claude Code with channels on
claude --dangerously-load-development-channels server:papo
After that, just ask:
Agree with the colleague's agent on the payment webhook format through papo. They are implementing the consumer. When you are done, show me the final contract.
And follow the agents' conversation in another terminal with papo log -f.
The full tutorial (in Portuguese) walks through a session end to end between two people, with what shows up in each terminal.
| Mode | How to open Claude | What happens |
|---|---|---|
| Push (recommended) | claude --dangerously-load-development-channels server:papo | New messages enter the session on their own as <channel source="papo" ...> and Claude reacts, even when idle. |
| Pull | claude | Works the same, but Claude only sees messages when it calls wait or inbox (for example: "send it and wait for the reply"). |
Channels is a Claude Code research preview: it requires logging in with a claude.ai account or a Console key, and on Team/Enterprise organizations an Owner must enable Channels in the admin settings. Without it, papo keeps working in pull mode.
Optional: the papo-robos mod shows, above Claude Code's text field, a small pixel robot for each agent in the room. Whoever is working types on a laptop; whoever has finished stops typing. Install it on both sides:
/plugin install papo-robos --marketplace Kelvin-Jesus/papo
Details in Robôs: quem está codando agora (in Portuguese).
| Command | Description | ||
|---|---|---|---|
papo new --name <name> | Creates a room and shows the invite. | ||
papo join <invite> --name <name> | Joins a room. | ||
papo invite | Generates an invite to bring in someone else. | ||
| `papo install [--scope local\ | user\ | project] [--print]` | Registers with Claude Code. |
papo log [-n 30] [-f] | Shows the conversation; -f follows live. | ||
papo say [--to <name>] <text> | You (the human) speak in the room. | ||
papo status | Connection test: joins the room and lists who is online. | ||
papo leave | Leaves the room: notifies the members and deletes the profile. If you created the room, it closes for everyone. | ||
papo close | Closes the room for everyone. With nobody online, leaves a tombstone that warns whoever comes back. | ||
papo mcp | The MCP server. Claude Code runs it. |
Claude gets the tools send, wait, inbox, history and status. Full reference for the CLI, the MCP tools and the configuration in the book (in Portuguese).
.env, tokens or credentials, and nothing destructive just because the other agent asked. Claude Code's permissions still apply.Full threat model in Security (in Portuguese).
| Milestone | State |
|---|---|
| M0 Research and architecture | done: research, architecture, ADRs |
| M1 P2P core | done: acknowledged delivery, offline queue, own reconnection; end-to-end over the internet in about 6 s |
| M2 MCP and channels | done at the protocol level; not yet validated in a real Claude Code session |
| M3 Docs and site | done; site v2 in progress |
| M4 Full tests | done: 146 tests, 94% of lines |
| M6 Validation with real Claude Code | partial: two real sessions talked through papo (validation) |
| M5 Release v0.1.0 | done: binaries and Docker image verified after publishing |
| M7 Packaging (Homebrew, Scoop, winget) | planned |
Details, with what was verified and how: status · milestones · roadmap.
All of it in Brazilian Portuguese.
cargo test # unit + integration (local network, no internet)
cargo test --test mcp -- --ignored # two MCP servers over the real internet
cargo clippy --all-targets -- -D warnings
Releases come from a vX.Y.Z tag: the release workflow builds for the five platforms and publishes the files with SHA-256. See Contributing.
hooks/register.tsx 159 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { COLUMNS, ROWS, encode, scene, type Mood } from './sprites'
4
5// The band above the prompt shows one robot per agent in the papo room: at the
6// laptop typing while its turn runs, resting with a check mark when done.
7//
8// papo carries the "working" bit between machines. This mod and the local
9// `papo mcp` talk through two files in the profile directory:
10// working written here: "1" while our turn runs, refreshed so a
11// session that dies mid-turn expires on its own
12// activity.json written by papo: who in the room is working right now
13
14type Member = { name: string; online: boolean; working: boolean }
15type Activity = { cwd: string; name: string; working: boolean; members: Member[]; updated_ms: number }
16
17/** papo rewrites activity.json every 5 s; older than this, its server is gone. */
18const STALE_MS = 15_000
19/** papo ignores a working marker older than 30 s; refresh well before. */
20const MARKER_REFRESH_MS = 10_000
21const FRAME_MS = 200
22const POLL_EVERY_FRAMES = 5
23const SCENE_GAP = 2
24
25/** Module state: a reload starts it over, which is what a fresh load should do. */
26const S = {
27 frame: 0,
28 profileDir: undefined as string | undefined,
29 activity: undefined as Activity | undefined,
30 working: false,
31 markerWrittenAt: 0,
32}
33
34/**
35 * Finds our papo profile: the one whose live server runs in this session's
36 * directory, or, with a single live server on the machine, that one. Answers
37 * whether the room's members changed since the last poll.
38 */
39async function poll($: EngineInterface): Promise<boolean> {
40 const now = await $.clock.now()
41 // papo's own rule: PAPO_HOME, else the user's home. Windows has no HOME
42 // (outside Git Bash); its home is USERPROFILE.
43 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
44 const base = (await $.env.get('PAPO_HOME')) ?? `${home}/.papo`
45 const root = `${base}/profiles`
46 const cwd = await $.session.cwd()
47 const live: { dir: string; a: Activity }[] = []
48 const entries = await $.fs.list(root).catch(() => [])
49 for (const entry of entries) {
50 if (entry.kind !== 'dir') continue
51 const dir = `${root}/${entry.name}`
52 const text = await $.fs.read(`${dir}/activity.json`).catch(() => undefined)
53 const a = parseActivity(text)
54 if (a && now - a.updated_ms < STALE_MS) live.push({ dir, a })
55 }
56 const mine = live.find(l => l.a.cwd === cwd) ?? (live.length === 1 ? live[0] : undefined)
57 const changed = JSON.stringify(mine?.a.members) !== JSON.stringify(S.activity?.members)
58 if (mine?.dir !== S.profileDir) {
59 S.profileDir = mine?.dir
60 S.markerWrittenAt = 0
61 }
62 S.activity = mine?.a
63 return changed
64}
65
66export function parseActivity(text: unknown): Activity | undefined {
67 if (typeof text !== 'string') return undefined
68 try {
69 const a = JSON.parse(text)
70 return typeof a?.updated_ms === 'number' && Array.isArray(a?.members) ? (a as Activity) : undefined
71 } catch {
72 return undefined
73 }
74}
75
76async function writeMarker($: EngineInterface, force: boolean): Promise<void> {
77 if (!S.profileDir) return
78 const now = await $.clock.now()
79 if (!force && !(S.working && now - S.markerWrittenAt >= MARKER_REFRESH_MS)) return
80 S.markerWrittenAt = now
81 await $.fs.write(`${S.profileDir}/working`, S.working ? '1' : '0').catch(() => undefined)
82}
83
84async function setWorking($: EngineInterface, value: boolean): Promise<void> {
85 if (value === S.working) return
86 S.working = value
87 await writeMarker($, true)
88 $.ui.invalidate('ui.render')
89}
90
91async function tick($: EngineInterface): Promise<void> {
92 S.frame += 1
93 const polled = S.frame % POLL_EVERY_FRAMES === 0
94 const changed = polled ? await poll($) : false
95 if (polled) await writeMarker($, false)
96 const animating = S.working || (S.activity?.members.some(m => m.working) ?? false)
97 // Idle robots only blink: once a second is plenty for that.
98 if (S.activity && (animating || changed || polled)) $.ui.invalidate('ui.render')
99}
100
101export const register: Register = on => {
102 on('session.start', async ($, e, next) => {
103 const result = await next(e)
104 await poll($)
105 await writeMarker($, true)
106 $.clock.every(FRAME_MS, () => tick($))
107 return result
108 })
109
110 on('turn.start', async ($, e, next) => {
111 await setWorking($, true)
112 return next(e)
113 })
114
115 on('turn.complete', async ($, e, next) => {
116 const result = await next(e)
117 await setWorking($, false)
118 return result
119 })
120
121 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
122 const activity = S.activity
123 if (e.props.hasSurvey || !activity) return next(e)
124 // The band's own flag is the truth for this session; the turn events only
125 // make the marker go out sooner.
126 if (e.props.isWorking !== S.working) void setWorking($, e.props.isWorking)
127
128 // Pixels need the terminal's Raster; elsewhere the names and states still say it.
129 const els = $.ui.resolve(e)
130 const { Box, Text } = els
131 const Raster = 'Raster' in els ? els.Raster : undefined
132 const robots: { key: string; name: string; mood: Mood }[] = [
133 { key: 'eu', name: `${activity.name} (você)`, mood: e.props.isWorking ? 'working' : 'done' },
134 ...activity.members.map(m => ({
135 key: `m-${m.name}`,
136 name: m.name,
137 mood: (!m.online ? 'offline' : m.working ? 'working' : 'done') as Mood,
138 })),
139 ]
140 const fit = Math.max(1, Math.floor((e.props.bodyColumns + SCENE_GAP) / (COLUMNS + SCENE_GAP)))
141 const shown = robots.slice(0, fit)
142 const label = { working: 'codando…', done: 'terminou ✓', offline: 'offline' } as const
143
144 return (
145 <Box flexDirection="row" columnGap={SCENE_GAP}>
146 {shown.map(r => (
147 <Box key={r.key} flexDirection="column" width={COLUMNS}>
148 {Raster ? <Raster key={r.key} columns={COLUMNS} rows={ROWS} cells={encode(scene(r.mood, S.frame))} /> : null}
149 <Text wrap="truncate-end" dimColor={r.mood !== 'working'} bold={r.mood === 'working'}>
150 {r.name} · {label[r.mood]}
151 </Text>
152 </Box>
153 ))}
154 {robots.length > shown.length ? <Text dimColor>+{robots.length - shown.length}</Text> : null}
155 </Box>
156 )
157 })
158}
159hooks/sprites.ts 277 lines1// Pixel art for the band: a little robot at a desk with a laptop. Drawn on a
2// 32x20 pixel grid and packed two pixels per terminal cell with half blocks
3// ("▀" top in the foreground, bottom in the background), so one scene is
4// 32 columns by 10 rows.
5
6export const WIDTH = 32
7export const HEIGHT = 20
8export const COLUMNS = WIDTH
9export const ROWS = HEIGHT / 2
10
11/** Terminal default color, per the Raster cell encoding. */
12const DEFAULT = 0x01000000
13const UPPER_HALF = 0x2580 // ▀
14const LOWER_HALF = 0x2584 // ▄
15const SPACE = 0x20
16
17export type Mood = 'working' | 'done' | 'offline'
18
19type Px = number | null
20/** Row-major pixels, `WIDTH * HEIGHT`; null is transparent. */
21export type Grid = Px[]
22
23const C = {
24 deskTop: 0x9c6b46,
25 deskFront: 0x6e4a30,
26 deskLeg: 0x553823,
27 lid: 0x2b2e35,
28 screen: 0x0f1726,
29 screenOff: 0x1a1d23,
30 hinge: 0x7d838c,
31 base: 0xc9ced6,
32 baseShade: 0x9aa1ab,
33 body: 0xeef3f8,
34 bodyShade: 0xbac6d4,
35 visor: 0x1d2535,
36 eye: 0x5ce1e6,
37 cheek: 0xff8fb1,
38 stalk: 0x9aa1ab,
39 bulbBusy: 0xff5c7a,
40 bulbBusyDim: 0x8f3245,
41 bulbDone: 0x7cd992,
42 check: 0x7cd992,
43 hand: 0xd3dde8,
44 key: 0x5ce1e6,
45 cursor: 0xe6edf3,
46 offBody: 0x6b7280,
47 offShade: 0x4b5260,
48 offVisor: 0x2a2f3a,
49 offEye: 0x5b6472,
50}
51
52const CODE = [0xc678dd, 0x61afef, 0x98c379, 0xe5c07b, 0xe06c75, 0x56b6c2] as const
53
54function grid(): Grid {
55 return Array<Px>(WIDTH * HEIGHT).fill(null)
56}
57
58function put(g: Grid, x: number, y: number, c: Px) {
59 if (x >= 0 && x < WIDTH && y >= 0 && y < HEIGHT) g[y * WIDTH + x] = c
60}
61
62export function pixel(g: Grid, x: number, y: number): Px {
63 return g[y * WIDTH + x] ?? null
64}
65
66/** Picks from a non-empty list, wrapping the index. */
67function pick<T>(list: readonly [T, ...T[]], i: number): T {
68 return list[i % list.length] ?? list[0]
69}
70
71function rect(g: Grid, x: number, y: number, w: number, h: number, c: Px) {
72 for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) put(g, i, j, c)
73}
74
75/** A rect with its four corner pixels left out: reads as rounded at this size. */
76function round(g: Grid, x: number, y: number, w: number, h: number, c: Px) {
77 rect(g, x, y, w, h, c)
78 for (const [i, j] of [[x, y], [x + w - 1, y], [x, y + h - 1], [x + w - 1, y + h - 1]] as const) put(g, i, j, null)
79}
80
81/**
82 * A deterministic line of "code" for screen row `n`: indentation and a few
83 * colored tokens, the same every time so scrolling reads as a real file.
84 */
85function codeLine(n: number): { indent: number; tokens: { len: number; color: number }[] } {
86 const h = (n * 2654435761) >>> 0
87 const indent = pick([0, 1, 2, 2, 1, 3], h)
88 const tokens: { len: number; color: number }[] = []
89 let left = 9 - indent
90 let k = h >>> 3
91 while (left > 1) {
92 const len = Math.min(left, 1 + (k % 4))
93 tokens.push({ len, color: pick(CODE, k >>> 2) })
94 left -= len + 1
95 k = (k * 1103515245 + 12345) >>> 0
96 if (k % 5 === 0) break
97 }
98 return { indent, tokens }
99}
100
101function desk(g: Grid) {
102 rect(g, 0, 17, WIDTH, 1, C.deskTop)
103 rect(g, 1, 18, WIDTH - 2, 1, C.deskFront)
104 rect(g, 2, 19, 2, 1, C.deskLeg)
105 rect(g, WIDTH - 4, 19, 2, 1, C.deskLeg)
106}
107
108function laptop(g: Grid, mood: Mood, frame: number) {
109 // Lid with the screen facing the robot, hinge, and the keyboard base.
110 rect(g, 18, 5, 12, 10, C.lid)
111 rect(g, 18, 15, 12, 1, C.hinge)
112 // The base reaches back under the robot's hands, so the keys are within reach.
113 rect(g, 13, 16, 17, 1, C.base)
114 put(g, 13, 16, C.baseShade)
115 const sx = 19
116 const sy = 6
117 const sw = 10
118 const sh = 8
119 rect(g, sx, sy, sw, sh, mood === 'offline' ? C.screenOff : C.screen)
120 if (mood === 'offline') return
121
122 if (mood === 'done') {
123 // Code at rest, dimmed, under a big check mark.
124 for (let row = 0; row < sh; row++) {
125 const { indent, tokens } = codeLine(row + 3)
126 let x = sx + indent
127 for (const t of tokens) {
128 rect(g, x, sy + row, t.len, 1, 0x2c3a52)
129 x += t.len + 1
130 }
131 }
132 const check = [[21, 10], [22, 11], [23, 12], [24, 11], [25, 10], [26, 9], [27, 8]] as const
133 for (const [x, y] of check) {
134 put(g, x, y, C.check)
135 put(g, x, y + 1, C.check)
136 }
137 return
138 }
139
140 // Working: the file scrolls up a line every other frame and the last line
141 // grows as if being typed, with a blinking cursor at its end.
142 const scroll = Math.floor(frame / 2)
143 for (let row = 0; row < sh; row++) {
144 const n = scroll + row
145 const { indent, tokens } = codeLine(n)
146 const typing = row === sh - 1
147 let budget = typing ? 1 + (frame % 2) * 2 + (scroll % 3) : Infinity
148 let x = sx + indent
149 for (const t of tokens) {
150 const len = Math.min(t.len, budget)
151 if (len <= 0) break
152 rect(g, x, sy + row, len, 1, t.color)
153 budget -= len
154 x += len + 1
155 if (typing && budget <= 0) {
156 if (frame % 2 === 0) put(g, Math.min(x - 1, sx + sw - 1), sy + row, C.cursor)
157 break
158 }
159 }
160 }
161}
162
163function robot(g: Grid, mood: Mood, frame: number) {
164 const off = mood === 'offline'
165 const body = off ? C.offBody : C.body
166 const shade = off ? C.offShade : C.bodyShade
167
168 // Antenna: busy blinks red, done glows green, offline is dark.
169 put(g, 9, 2, C.stalk)
170 const bulb = off ? C.offShade : mood === 'done' ? C.bulbDone : frame % 4 < 2 ? C.bulbBusy : C.bulbBusyDim
171 rect(g, 8, 1, 3, 1, bulb)
172 put(g, 9, 0, bulb)
173
174 // Head and face.
175 round(g, 4, 3, 10, 8, body)
176 rect(g, 4, 4, 1, 6, shade)
177 round(g, 6, 5, 7, 4, off ? C.offVisor : C.visor)
178 const eye = off ? C.offEye : C.eye
179 const blink = !off && frame % 24 === 23
180 if (off || blink) {
181 rect(g, 7, 7, 2, 1, eye)
182 rect(g, 10, 7, 2, 1, eye)
183 } else if (mood === 'done') {
184 // Happy ^ ^ eyes.
185 put(g, 7, 7, eye)
186 put(g, 8, 6, eye)
187 put(g, 9, 7, eye)
188 put(g, 10, 7, eye)
189 put(g, 11, 6, eye)
190 put(g, 12, 7, eye)
191 } else {
192 // Looking right, at the screen.
193 rect(g, 8, 6, 2, 2, eye)
194 rect(g, 11, 6, 2, 2, eye)
195 }
196 if (!off) {
197 put(g, 6, 9, C.cheek)
198 put(g, 12, 9, C.cheek)
199 }
200
201 // Body, a chest light, and the stool it sits on behind the desk.
202 round(g, 5, 11, 8, 6, body)
203 rect(g, 5, 12, 1, 4, shade)
204 put(g, 9, 13, off ? C.offShade : mood === 'done' ? C.bulbDone : C.eye)
205
206 if (mood === 'working') {
207 // Two arms hammering the keyboard in turns: one hand strikes (and its
208 // key lights up) while the other is raised for the next key.
209 const left = frame % 2 === 0
210 arm(g, 12, 12, 16, left ? 11 : 14, shade, C.hand)
211 arm(g, 12, 14, 13, left ? 14 : 11, body, C.hand)
212 put(g, left ? 13 : 16, 16, C.key)
213 put(g, left ? 14 : 17, 16, C.key)
214 } else {
215 // Arms resting at its sides.
216 rect(g, 13, 12, 1, 4, shade)
217 }
218}
219
220/**
221 * A one-pixel arm from the shoulder to a 2x2 hand whose top-left is (hx, hy),
222 * stepping one pixel at a time so diagonals stay connected.
223 */
224function arm(g: Grid, sx: number, sy: number, hx: number, hy: number, color: number, hand: number) {
225 let x = sx
226 let y = sy
227 while (x !== hx || y !== hy) {
228 put(g, x, y, color)
229 if (x !== hx) x += Math.sign(hx - x)
230 if (y !== hy) y += Math.sign(hy - y)
231 }
232 rect(g, hx, hy, 2, 2, hand)
233}
234
235/** Pixels of one scene at animation `frame`. */
236export function scene(mood: Mood, frame: number): Grid {
237 const g = grid()
238 desk(g)
239 laptop(g, mood, frame)
240 robot(g, mood, frame)
241 return g
242}
243
244/** The Raster `cells` for a pixel grid: base64 of `[codePoint, fg, bg]` u32 triplets. */
245export function encode(g: Grid): string {
246 const words = new Uint32Array(COLUMNS * ROWS * 3)
247 let i = 0
248 for (let r = 0; r < ROWS; r++) {
249 for (let x = 0; x < COLUMNS; x++) {
250 const top = pixel(g, x, 2 * r)
251 const bottom = pixel(g, x, 2 * r + 1)
252 if (top === null && bottom === null) words.set([SPACE, DEFAULT, DEFAULT], i)
253 else if (top === null) words.set([LOWER_HALF, bottom ?? DEFAULT, DEFAULT], i)
254 else words.set([UPPER_HALF, top, bottom ?? DEFAULT], i)
255 i += 3
256 }
257 }
258 return base64(new Uint8Array(words.buffer))
259}
260
261const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
262
263/** Standard padded base64; written out so it does not depend on the runtime having toBase64. */
264export function base64(bytes: Uint8Array): string {
265 let out = ''
266 for (let i = 0; i < bytes.length; i += 3) {
267 const a = bytes[i] ?? 0
268 const b = bytes[i + 1] ?? 0
269 const c = bytes[i + 2] ?? 0
270 const n = (a << 16) | (b << 8) | c
271 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
272 out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '='
273 out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
274 }
275 return out
276}
277